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
Decide what the CLI is for before writing code
Don't try to put your whole 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. Five commands that work well are worth more than forty that mirror your routes badly.
- 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
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 simple version of a device flow, needs no OAuth client registration, and users get it right away.
// ~/.yourapp.json (written with mode 0o600) { "accessToken": "...", "refreshToken": "...", "expiresAt": 1789000000 } - 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
Treat --json and exit codes as first-class features
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. With that, your CLI works from shell scripts, cron jobs, CI, and AI agents like Claude Code, with no HTML scraping and no brittle text parsing. Agents use CLIs heavily, and a documented command set with JSON output is about the easiest agent integration there is.
node cli.js list --json | jq '.recipes[].title' node cli.js create --url "$URL" --json > new-recipe.json || echo "import failed" - 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. That way a runaway loop cannot drain an account or your inference budget.
- 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) do with it. The requests that follow make a good roadmap.
Common mistakes to avoid
- ✕
Shipping without a version command
Skipping a version number costs nothing on day one and a lot 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 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
This shows up as a support ticket rather than an exception, so it takes a while 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, and the CLI saves everyone from writing the same login and refresh code.
Is a CLI worth it for a consumer app?
For some users, yes. Culinary Card is a recipe app and the CLI still gets used: developers script their own collections and agents import recipes automatically. It is a small amount of code to maintain for that.