Install
Requires Node.js 22 or newer. Zero runtime dependencies — the published package uses only Node built-ins.
# global — recommended if you'll use the hook npm install -g ghostimport # one-off, no install npx ghostimport # as a dev dependency npm install --save-dev ghostimport
If you're wiring up the hook, install globally. Hooks run on
every matching tool call, and npx pays a startup cost each time.
Quick start
Point it at a project. One mode, no flags to discover — the supply-chain analysis always runs.
$ ghostimport Scanned 142 files · 38 packages ✗ react-server-fetch does not exist on npm ↳ src/data/loader.ts ↳ unregistered — anyone could claim this name with a malicious postinstall ✗ axois high risk ↳ src/api/client.ts ↳ 1-2 chars from 'axios' — likely a typo ↳ single version published created 2019-08-29 · 1245/week · 1 version ⚠ not in package.json: zod 2 problems found.
It exits 1 when an imported package doesn't exist and 0 otherwise,
so it works as a CI gate with no extra configuration.
Agent hooks
The enforcing integration. Hooks run on every matching tool call whether or not the model cooperates — unlike MCP tools, which the model has to choose to call.
Add to .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "ghostimport hook" }]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [{ "type": "command", "command": "ghostimport hook" }]
}
]
}
}
What each one does
-
PreToolUse on Bash — parses any
npm,pnpm,yarnorbuninstall command and denies the tool call if it would fetch a package that doesn't exist, is a typosquat, or ships an install script. This is the one that stops a real attack, because it fires before the postinstall could run. - PostToolUse on edits — reads the file the agent just wrote, extracts its imports, and reports any that don't resolve.
What the agent sees when it's blocked
ghostimport blocked this: the install command below would fetch packages
that are unsafe or do not exist.
• 'axois' exists but is high risk: name is 1-2 chars from 'axios';
single version published.
Do not retry this command as written. Remove the flagged packages, correct
the name if it was a typo, and tell the user what was flagged and why.
It fails open. Unreachable registry, malformed payload, or a check that
exceeds its 20-second budget → it exits 0 and gets out of the way. A security
tool that wedges your agent when you're offline gets uninstalled by Friday.
Results are cached for 24 hours in ~/.ghostimport/, so the steady state is
zero network calls.
MCP server
Gives the model tools to check a name before it commits to writing the import. Use it alongside hooks, not instead of them — this one depends on the model choosing to call it.
claude mcp add ghostimport -- npx -y ghostimport mcp
For Cursor, Windsurf, Zed or any other MCP client, add to its config:
{
"mcpServers": {
"ghostimport": {
"command": "npx",
"args": ["-y", "ghostimport", "mcp"]
}
}
}
Tools it exposes
| Tool | Arguments | What it does |
|---|---|---|
| check_packages | packages, deep |
Verify names exist on npm. deep adds the risk heuristics. |
| check_install_command | command |
Extract and vet every package a shell command would install. |
| scan_project | path |
Audit a whole directory. |
The server speaks JSON-RPC over stdio and is written directly against the wire format — no MCP SDK — so installing ghostimport still pulls in zero runtime dependencies. A supply-chain tool with its own dependency tree undercuts its own argument.
CLI
ghostimport [dir] [options] ghostimport mcp | hook
Options
| Flag | Effect |
|---|---|
| --quiet, -q | Only show problems |
| --json | Output results as JSON |
| --watch, -w | Re-scan on file changes |
| --badge | Print a README badge after scanning |
| --fast | Skip the deep supply-chain check on undeclared packages |
| --no-undeclared | Hide "imported but not in package.json" warnings |
| --no-cache | Bypass the 24-hour registry cache |
| --version, -v | Print the version |
| --help, -h | Show help |
--fast only skips the deep check on packages that exist. Names that
don't exist are always reported as squattable — that check costs no extra requests, so
there's no way to accidentally turn off the important part.
Commands
ghostimport mcp— run as an MCP server over stdioghostimport hook— run as an agent hook, reading a hook payload on stdin
JSON output
{
"scanned": 142,
"packages": 38,
"missing": [{ "pkg": "react-server-fetch", "files": [...] }],
"undeclared": [{ "pkg": "zod", "files": [...] }],
"risks": [{ "pkg": "axois", "type": "suspicious", "risk": "high", ... }],
"errors": [],
"cacheHits": 33
}
README badge
Run a scan with --badge and it prints ready-to-paste Markdown for your
project's README, reflecting the result of that scan.
$ ghostimport --badge README badge: [](https://github.com/FGuerreir0/ghostimport)
The two states
Copy the clean badge
[](https://github.com/FGuerreir0/ghostimport)
The badge is static. It captures the scan you ran when you generated it — paste it and it stays that way, even if a later commit introduces a package that doesn't exist. Treat it as a signal that you check imports, not as live status. A self-updating badge is planned for a future release; until then, pair it with the CI check, which is what actually fails the build.
Config file
Optional. Drop a .ghostimportrc.json in your project root.
{
"ignore": ["@company/*", "internal-lib"],
"includeUndeclared": true
}
| Field | Default | Description |
|---|---|---|
| ignore | [] | Names or scope patterns to skip. @scope/* is supported. |
| includeUndeclared | true | Warn on packages that exist but aren't in package.json. |
Workspace packages in a monorepo and paths aliases from your tsconfig are
detected automatically — you don't need to list them.
JavaScript API
Ships with TypeScript declarations. Seven exports, each with one job.
import { verifyPackages, scan } from 'ghostimport' // check names directly await verifyPackages(['axios', 'axois'], { deep: true }) // [ { pkg: 'axios', status: 'ok', typosquatOf: null }, // { pkg: 'axois', status: 'suspicious', risk: 'high', typosquatOf: 'axios', ... } ] // scan a directory const { missing, undeclared, risks } = await scan('./src')
Verdict statuses
Exports
| Export | Purpose |
|---|---|
| scan(dir, opts?) | Scan a directory. Returns ScanResult. |
| verifyPackages(names, opts?) | Check a list of names. Returns PackageVerdict[]. |
| checkNpm(name) | Does this one package exist? |
| checkPackageRisk(name) | Full supply-chain check for one package. |
| detectTyposquat(name) | The popular package it's 1–2 chars from, or null. |
| extractImports(code) | Package names from a source string. |
| extractInstallTargets(cmd) | Packages a shell command would install. |
What gets scanned
Detected: import, require(), dynamic
import(), export … from, scoped packages, and subpath imports
(pkg/utils resolves to pkg).
Extensions: .js .jsx .ts
.tsx .mjs .cjs .vue
.svelte .astro
For .vue, .svelte and .astro, only
<script> blocks and Astro frontmatter are read — markup is ignored, so a
package name that appears in template text is never flagged.
Ignored:
- Node built-ins, including
node:prefixed ones - Relative imports (
./,../) - Path aliases —
@/,~/,$lib/, and tsconfigpaths - URL and protocol imports, and virtual modules (
virtual:, Vite internals) - Workspace packages in a monorepo
node_modules/,dist/,build/,.git/
Risk signals
A name that resolves isn't automatically safe. These signals are checked against packages
that exist but aren't declared in your package.json.
| Signal | Weight | Why it matters |
|---|---|---|
| postinstall / preinstall / install script | Critical | Runs arbitrary code the moment the package lands |
| 1–2 chars from a popular name | Critical | The classic typosquat shape |
| Created < 30 days ago | Medium | No track record to judge it by |
| < 50 weekly downloads | Medium | Nobody else is depending on it |
| Single version published | Medium | Abandoned, or published once for a reason |
| Single maintainer | Amplifier | Only counts alongside another signal — plenty of good packages have one |
Rated high if any critical signal fires or two medium ones do; only
medium and high are reported.
CI
The CLI already exits non-zero on a missing package, so the simplest form is one line.
- name: Check for hallucinated packages run: npx ghostimport --quiet
Or use the action, which also writes a job summary and exposes counts as outputs:
- uses: FGuerreir0/ghostimport@v0.5.0 with: path: '.'
For pre-commit, in .pre-commit-config.yaml:
repos: - repo: https://github.com/FGuerreir0/ghostimport rev: v0.5.0 hooks: - id: ghostimport
CI is a backstop, not the main event. By the time a bad package reaches CI it's already in your repo — hooks catch it while it's being written.
