Packaging a CLI Agent for Distribution
A broken first npm publish leaks keys, ships 40MB, and installs no command. Here's how to package a CLI agent npm users can install and trust — plus secure publishing with provenance in 2026.
{
"name": "my-agent",
"version": "1.0.0",
"main": "index.js"
}The first time I helped a team publish a CLI agent to npm, the launch went sideways in three separate ways within an hour. Users who ran the install got command not found. The ones who got it working downloaded a 40-megabyte package stuffed with the entire test suite and a node_modules snapshot. And buried in that bloat was a .env file with a live API key. Every one of those failures is avoidable, and fixing them is what it means to actually package a CLI agent npm users can install and trust. This teardown walks the broken version to a clean one.
Publishing feels like the easy last step. It's where a surprising number of agents faceplant, because the defaults are wrong for a CLI tool and the security rules changed recently.
Before: The Broken First Publish
Here's the package.json that produced all three failures. It looks reasonable and is quietly wrong in several ways.
[object Object],
,[object Object],[object Object], ,[object Object],[object Object],
,[object Object],[object Object], ,[object Object],[object Object],
,[object Object],[object Object], ,[object Object],
,[object Object],What this does: Declares a package with a main entry point and nothing else. There's no bin field, so no terminal command gets installed; no files allowlist, so npm ships everything in the directory; and no engines, so it installs on Node versions where it won't run. Every gap here became an incident.
Why It Fails
The command not found came from the missing bin field. main tells Node what to import when your package is required by other code — it does nothing for the command line. Without a bin entry, npm install -g installs your files and creates no executable, so users have nothing to type. The tool is on their disk and unreachable.
The 40-megabyte install came from the missing files allowlist. By default, npm publish includes almost everything in the folder that isn't explicitly ignored — tests, fixtures, build artifacts, stray local files. Users pay for all of it on every install, and it's slow and suspicious.
The leaked key came from that same default. With no files allowlist and an incomplete .npmignore, a local .env got swept into the published tarball and shipped to the public registry. Deleting it later doesn't help — a published version is immutable and the key is compromised the instant it's live.
⚠️ Common mistake: Trusting npm's default publish contents. Publishing without an explicit files allowlist ships whatever happens to be in your directory — tests, build junk, and worst of all any secret that snuck in. Always declare exactly what goes in the package, and run a dry run before you ever publish.
After: How Do You Package a CLI Agent npm Users Can Actually Install?
The rebuilt package.json fixes all three failures and adds the modern security posture. Every field here earns its place.
[object Object],
,[object Object],[object Object], ,[object Object],[object Object],
,[object Object],[object Object], ,[object Object],[object Object],
,[object Object],[object Object], ,[object Object],[object Object],
,[object Object],[object Object], ,[object Object], ,[object Object],[object Object], ,[object Object], ,[object Object],[object Object],
,[object Object],[object Object], ,[object Object],[object Object],[object Object], ,[object Object],[object Object], ,[object Object],[object Object],[object Object],
,[object Object],[object Object], ,[object Object], ,[object Object],[object Object], ,[object Object], ,[object Object],[object Object],
,[object Object],[object Object], ,[object Object], ,[object Object],[object Object], ,[object Object],[object Object], ,[object Object],[object Object], ,[object Object], ,[object Object],[object Object],
,[object Object],[object Object], ,[object Object], ,[object Object],[object Object], ,[object Object],[object Object], ,[object Object],[object Object], ,[object Object], ,[object Object],
,[object Object],What this does: Registers my-agent as a terminal command mapped to your CLI entry file, restricts the published package to exactly three paths, requires a Node version where your code runs, and turns on public access with build provenance. The bloat, the missing command, and the leaked secret are all structurally impossible now.
The executable itself needs one more thing the config can't provide — a shebang as its literal first line.
[object Object],
,[object Object],
,[object Object], { main } ,[object Object], ,[object Object],;
,[object Object],(process.,[object Object],.,[object Object],(,[object Object],));What this does: Tells the shell to run the file with Node when a user types the command. Without this exact first line, the terminal tries to execute your JavaScript as a shell script and fails with a syntax error that looks like your tool is broken.
Breaking Down Each Element
The bin field is what creates the command. Its key is the command name users type; its value is the file to run. When someone installs your package, npm creates a symlink from that command to your file, and the shebang makes the file executable. Those two pieces together — bin plus shebang — are the entire mechanism behind a working CLI, and missing either produces command not found or a syntax error.
The files allowlist inverts npm's dangerous default. Instead of "ship everything except what I ignored," you declare "ship only these paths." A secret can't leak into a package that only includes bin/ and src/, because your .env isn't on the list. This is a safer default than .npmignore, which fails open — forget to add a file and it ships.
The engines field turns a confusing runtime crash into a clear install-time warning. Someone on an old Node version gets told their version is unsupported before installing, instead of hitting a cryptic syntax error when your code uses a newer feature.
⚡ Pro tip: Run npm pack --dry-run before every publish and read the file list it prints. This shows you exactly what would ship without actually publishing — the single fastest way to catch a stray secret or a bloated tarball. If you see anything you don't recognize, stop and fix your files list before the mistake becomes permanent.
⚡ Pro tip: Add a prepublishOnly script that runs your tests and a build check. It fires automatically right before publish, so a broken build or failing test blocks the release instead of shipping to users. A publish is immutable — catching problems in prepublishOnly is your last cheap chance.
Publishing Securely in 2026
The mechanics above get a working package onto npm. The part that changed recently is how you authenticate the publish, and it matters more than it used to because npm's supply chain has been under active attack. The old habit of a long-lived npm token pasted into CI is now the thing to avoid.
The current recommendation is Trusted Publishing: your CI provider (GitHub Actions or GitLab) authenticates to npm over OIDC with no stored token at all, and npm automatically attaches provenance attestations that link the package to the exact commit and workflow that built it. Long-lived write tokens have also been capped to short lifespans and require regular rotation, and as of August 2026, tokens that bypass 2FA can no longer perform account-governance actions.
# GitHub Actions: token-free publish with provenance
permissions:
id-token: write # required for Trusted Publishing over OIDC
contents: read
steps:
- run: npm publish # no NPM_TOKEN needed; provenance is automaticWhat this does: Publishes from CI using an OIDC identity instead of a stored secret, with the id-token: write permission letting the workflow prove who it is to npm. There's no token to leak, and npm signs the release with provenance so consumers can verify where it came from.
For the strongest posture, teams enable staged publishing, where CI stages a release and a human approves it with a hardware 2FA key before it goes public — automation plus a final human check. Even if you publish manually, prefer WebAuthn 2FA over SMS, and log out and decommission any temporary token when you're done. Provenance proves origin, not safety — it answers "did this come from the right pipeline," which is exactly the question a supply-chain attack tries to fake.
⚡ Pro tip: Set publishConfig.provenance to true in package.json rather than remembering the --provenance flag each time. Baking it into config means every publish is verifiable by default, and you never ship an unsigned release because you forgot a flag on a late-night hotfix.
Versioning and Update Discipline
One more thing the broken first publish got wrong by omission: it had no plan for version two. Users install a CLI once and forget it, so an agent with a bug fix sits unused on their machines unless you make updates visible. Semantic versioning plus a gentle update nudge closes that gap.
Follow semver honestly — patch for fixes, minor for new capabilities, major for anything that changes existing behavior or command syntax. An agent is a tool people script around, so a breaking change to a command's flags in a minor release will quietly break their automation and burn trust. When you must break something, bump the major version and say so in the release notes.
[object Object],
,[object Object], (,[object Object],.,[object Object],() - lastCheck > ,[object Object],) {
,[object Object], latest = ,[object Object], ,[object Object],(,[object Object],); ,[object Object],
,[object Object], (latest !== pkg.,[object Object],)
,[object Object],.,[object Object],(,[object Object],);
}What this does: Checks npm for a newer version at most once a day, off the critical path, and prints a quiet upgrade hint to stderr when one exists. Users learn about your fixes without the check ever slowing down or blocking their actual command.
⚡ Pro tip: Write the update notice to stderr, not stdout, and never block on the check. Anyone piping your agent's output into another program will have their data corrupted if an update banner lands in stdout — keep notices on stderr so they're visible to humans and invisible to pipes.
Variations for Different Contexts
A solo maintainer shipping a personal tool publishes locally with WebAuthn 2FA and a short-lived token they revoke immediately after, which is simpler than CI and perfectly secure for one person.
A platform team distributing an internal agent publishes to a private registry scoped to their org, using the same bin, files, and engines discipline but skipping public provenance in favor of internal access controls.
A Python-shop developer packaging a Python-based agent applies the identical principles through pyproject.toml — an entry-points script instead of bin, an explicit include list instead of files — because the failure modes are universal even when the tooling isn't npm.
The through-line: declare your entry point, allowlist your contents, pin your runtime, and authenticate the publish without a leakable secret.
Save and Reuse This
The package.json skeleton, the shebang'd entry file, and the CI publish workflow are pure boilerplate you'll want identical on every tool — there's no reason to rebuild them from memory and reintroduce the same three failures each time. The security details especially reward being written down once, because they shift and the stakes are high.
Keeping your packaging template and publish checklist alongside your prompts in a library like PromptABCD means the next time you package a CLI agent npm distribution is a copy, tweak, and publish — not a re-run of the launch-day incident where the key leaked and the command didn't work.
Continue Reading
Save the prompts from this post
PromptABCD is a free prompt manager. Paste, organize, and reuse your best AI prompts — no more hunting through chat history.
