How to build a CLI for your web app (a weekend project, honestly)

    If your app has an API, a CLI is one of the cheapest power features you can ship: it delights your terminal-native users, makes the product scriptable, and gives AI agents a clean way in. We built one for Culinary Card, a recipe app of all things, and the lessons transfer to almost any product.

    MattCulinary Card6 min read

    The short answer

    Ship a single zero-dependency script that wraps a small set of dedicated /api/cli endpoints. Handle auth with a long-lived refresh token stored in a chmod 600 file in the home directory, add a one-time connect code flow for OAuth users, and give every command a --json flag with meaningful exit codes. That combination covers human users, shell scripts, and AI agents with one codebase, and it fits in a weekend.

    Step-by-step

    1. 1

      Decide what the CLI is for before writing code

      A good v1 CLI is not your web app in a terminal. Pick the handful of verbs that make sense without a screen. For Culinary Card that was: whoami (account and credits), list and view (read the collection), and create (turn text, a file, or a URL into a recipe). Editing and anything visual stayed in the web app. A CLI that does five things well beats one that mirrors forty routes badly.

    2. 2

      Give the CLI its own API surface

      Resist pointing the CLI at the same endpoints your frontend uses. Browser endpoints assume cookies, CSRF tokens, and a session that a terminal does not have, and every frontend refactor becomes a CLI break. A small /api/cli/* namespace with token auth is a stable contract you control, and it is where you set CLI-appropriate rate limits.

      POST /api/cli/login            # email + password -> token pair
      POST /api/cli/connect          # one-time code -> token pair (OAuth users)
      GET  /api/cli/me               # account, credits
      GET  /api/cli/recipes?search=  # list
      POST /api/cli/recipes          # create from text / url
    3. 3

      Solve auth with tokens on disk, and a connect code for OAuth users

      Email-and-password users can log in directly: exchange credentials for an access token and a refresh token, store them in a dotfile with owner-only permissions, and refresh silently when the access token expires. OAuth users (Sign in with Google) have no password to type, so give them a one-time code flow: the web app generates a short-lived code on a settings page, the user runs connect <code>, and the server swaps it for a token pair. It is a poor man's device flow, needs no OAuth client registration, and users understand it instantly.

      // ~/.yourapp.json — written with mode 0o600
      {
        "accessToken": "...",
        "refreshToken": "...",
        "expiresAt": 1789000000
      }
    4. 4

      Make it a single file with zero dependencies

      Node 18+ ships fetch, so a CLI can be one plain .js file users download with curl and run with node. No npm install, no packaging, no supply-chain surface, and distribution is just serving a static file from your existing domain. You give up fancy argument parsers and spinner libraries, and a hand-rolled argv loop plus plain console output turns out to be all a v1 needs.

      curl -fsSL https://www.culinarycard.app/cli.js -o cli.js
      node cli.js login [email protected]
      node cli.js list --search "chicken"
    5. 5

      Treat --json and exit codes as first-class features

      This is the difference between a toy and infrastructure. Every command gets a --json flag emitting machine-readable output on stdout, errors go to stderr as structured JSON, and the exit code is non-zero on any failure. Do this and your CLI is instantly usable from shell scripts, cron jobs, CI, and AI agents like Claude Code, with no HTML scraping and no brittle text parsing. Agents in particular are becoming heavy CLI users: a documented command set with JSON output is the easiest agent integration you will ever ship.

      node cli.js list --json | jq '.recipes[].title'
      node cli.js create --url "$URL" --json > new-recipe.json || echo "import failed"
    6. 6

      Rate-limit and price it like the web app

      A CLI multiplies how fast one user can hit your API, especially once agents and loops get involved. Enforce limits server-side on the /api/cli namespace (Culinary Card allows generous reads and caps expensive AI creations per hour) and charge the same credits or quota as the web UI so a script cannot become a free tier. Users respect the symmetry, and a runaway loop cannot drain an account or your inference budget.

    7. 7

      Document it on one page and ship

      The whole manual should fit on one page: install line, auth, each command with one example, the --json contract, and rate limits. Put the same text in the CLI's help output. Then ship it and watch what people (and their agents) actually do with it, because the requests that follow will surprise you and they are your roadmap.

    Common mistakes to avoid

    • Shipping without a version command

      This one costs you nothing on day one and a great deal on day ninety. Users curl the file down once and never think about it again, so within a few months you are supporting half a dozen versions you cannot identify. When somebody reports a bug you have no way to ask which build they are on, and you cannot retire an endpoint without guessing who is still calling it. Print a version, send it as a header, and you keep both options.

    • Storing long-lived secrets carelessly

      Never take a password as a flag, because flags land in shell history and in CI logs. Prompt interactively, write the token file with 0600 permissions, and make logout genuinely delete it rather than clearing a field. Those three habits cover most of the real risk here.

    • Letting the CLI and the web app drift on business rules

      The failure looks like a support ticket rather than an exception, which is why it takes so long to spot. Someone creates a resource through the CLI that the web app would have rejected, or spends credits the UI would have blocked, because the validation lives in a controller the CLI route never touches. Push the rules down into shared code that both paths call, rather than reimplementing them per surface.

    • Changing the JSON shape without treating it as a break

      It is easy to think of --json output as debug convenience and rename a field. But that output is the contract that scripts, cron jobs, and agents are built on, and unlike your REST API it has no version in the URL to protect it. Add fields freely. Renaming or removing one is a breaking change and wants the same care you would give an API.

    Why Culinary Card works for this

    Culinary Card is the case study here: a recipe app that ships a real CLI, built exactly this way, as a single zero-dependency file over a dedicated JSON API. You can log in, search your collection, and turn any text or URL into a structured recipe card without opening a browser, or hand the --json interface to an AI agent and let it manage your recipe book. If you want to poke at a working example of everything above, it is free to try, and the CLI guide covers every command.

    Frequently asked questions

    Should I use a CLI framework like oclif or Commander?

    For a large multi-team CLI, frameworks earn their weight. For a v1 with five commands, a hand-rolled argv parser in a single file is easier to distribute, has no dependencies to audit, and is simpler for contributors to read. You can always graduate later; the API contract is the part that must be right from the start.

    How do AI agents actually use a CLI like this?

    You tell the agent the CLI exists and paste its help text, and it composes commands like any other tool: listing, filtering with --json through jq, creating resources, and checking exit codes to know whether things worked. Deterministic flags and structured errors matter far more to an agent than pretty output.

    Why not just publish an SDK or document the REST API?

    Do document the API. A CLI sits one level up: it handles auth persistence, token refresh, and sensible defaults so users and agents skip the boilerplate. The API is the contract; the CLI is the contract with the annoying parts already solved.

    Is a CLI worth it for a consumer app?

    For a subset of users, massively. Culinary Card is a recipe app and the CLI still earns its keep: developers script their own collections, agents import recipes automatically, and the people who use it are disproportionately the ones who tell friends about the product. Small surface, outsized loyalty.

    See a working example, then build yours

    Try the Culinary Card CLI against a real account: sign up free, run login, and turn text into structured recipes from your terminal.

    Try Culinary Card free

    9 free credits · No credit card needed

    Keep reading