diff --git a/.gitignore b/.gitignore
index 44a3712..71f383d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,6 @@ playwright-report
# Agent skills from npm packages (managed by skills-npm)
**/skills/npm-*
+
+# Copied at publish time from root /skills/ by scripts/copy-skills.mjs
+packages/node-modules-inspector/skills/
diff --git a/README.md b/README.md
index 1edab4f..98fdea4 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,44 @@ Then you can host the `.node-modules-inspector` folder with any static file serv
You can see a build for all Anthony Fu's packages at [everything.antfu.dev](https://everything.antfu.dev).
+## CLI Reports
+
+In addition to the web UI, the inspector exposes three machine-readable reports designed for shell pipelines and AI coding agents:
+
+```bash
+npx node-modules-inspector report duplicates # packages installed in multiple versions
+npx node-modules-inspector report sizes # packages sorted by install size
+npx node-modules-inspector report maintainers # dep-upgrade opportunities + publint, grouped by consumer/author
+```
+
+Each command renders a pretty ANSI table by default. Add `--json` to emit JSON to stdout — progress logs go to stderr, so output is pipe-safe:
+
+```bash
+npx node-modules-inspector report duplicates --json | jq '.[].name'
+npx node-modules-inspector report sizes --json --limit 10
+npx node-modules-inspector report maintainers --json --sort migration --no-latest-only
+```
+
+Common options across all reports: `--root
`, `--config `, `--depth `, `--limit `. Run `node-modules-inspector report --help` for the full per-report flag set.
+
+## MCP Server
+
+The same three reports are exposed as MCP tools for AI coding agents. Start the server over stdio:
+
+```bash
+npx node-modules-inspector mcp
+```
+
+Tools exposed:
+- `nmi:report-duplicates`
+- `nmi:report-sizes`
+- `nmi:report-maintainers`
+
+Input/output JSON Schemas are auto-derived from the underlying valibot definitions and surfaced via `tools/list`. Wire it into Claude Code (or any MCP-compatible client) by adding `node-modules-inspector mcp` as a stdio server in your MCP config.
+
+> [!NOTE]
+> An [agent skill](./skills/node-modules-inspector/SKILL.md) lives at `skills/node-modules-inspector/SKILL.md` (repo root) and is copied into the published tarball at `/skills/` during `prepack`. If your project uses [`skills-npm`](https://github.com/antfu/skills-npm), the skill is automatically symlinked into your agent's skill directory after `pnpm install`.
+
## Screenshots

diff --git a/packages/node-modules-inspector/package.json b/packages/node-modules-inspector/package.json
index cbd9500..01e3bdd 100644
--- a/packages/node-modules-inspector/package.json
+++ b/packages/node-modules-inspector/package.json
@@ -22,7 +22,8 @@
"bin": "bin.mjs",
"files": [
"bin.mjs",
- "dist"
+ "dist",
+ "skills"
],
"scripts": {
"dev": "pnpm run -r stub && (cd src && ROLLDOWN_OPTIONS_VALIDATION=loose nuxi dev)",
@@ -30,13 +31,14 @@
"build": "pnpm run wc:prepare && (cd src && ROLLDOWN_OPTIONS_VALIDATION=loose nuxi build) && unbuild",
"build:debug": "ROLLDOWN_OPTIONS_VALIDATION=loose NUXT_DEBUG_BUILD=true pnpm run build",
"start": "node ./bin.mjs",
- "prepack": "pnpm build",
- "dev:prepare": "(cd src && nuxi prepare) && pnpm wc:prepare",
+ "prepack": "pnpm build && node scripts/copy-skills.mjs",
+ "dev:prepare": "(cd src && nuxi prepare) && pnpm wc:prepare && node scripts/copy-skills.mjs",
"wc:prepare": "rollup -c",
"wc:build": "pnpm run wc:prepare && NMI_BACKEND=webcontainer ROLLDOWN_OPTIONS_VALIDATION=loose nuxi build src && unbuild",
"wc:dev": "pnpm run -r stub && pnpm run wc:prepare && (cd src && NMI_BACKEND=webcontainer ROLLDOWN_OPTIONS_VALIDATION=loose nuxi dev)"
},
"dependencies": {
+ "@modelcontextprotocol/sdk": "catalog:deps",
"ansis": "catalog:deps",
"cac": "catalog:deps",
"devframe": "catalog:deps",
@@ -54,11 +56,13 @@
"structured-clone-es": "catalog:deps",
"tinyglobby": "catalog:deps",
"unconfig": "catalog:deps",
- "unstorage": "catalog:deps"
+ "unstorage": "catalog:deps",
+ "valibot": "catalog:deps"
},
"devDependencies": {
"@types/semver": "catalog:types",
"@unocss/nuxt": "catalog:bundling",
+ "@valibot/to-json-schema": "catalog:testing",
"@vueuse/nuxt": "catalog:bundling",
"@webcontainer/api": "catalog:frontend",
"@xterm/addon-fit": "catalog:frontend",
diff --git a/packages/node-modules-inspector/scripts/copy-skills.mjs b/packages/node-modules-inspector/scripts/copy-skills.mjs
new file mode 100644
index 0000000..4651878
--- /dev/null
+++ b/packages/node-modules-inspector/scripts/copy-skills.mjs
@@ -0,0 +1,25 @@
+// Copies the repo-root `skills/` folder into this package so it ships in the
+// published tarball at `/skills/`. The root location is the source of
+// truth (it's where humans and most agent-skills tooling look directly); this
+// copy is what `skills-npm` and `npm install` consumers see.
+
+import { existsSync } from 'node:fs'
+import { cp, rm } from 'node:fs/promises'
+import { dirname, resolve } from 'node:path'
+import process from 'node:process'
+import { fileURLToPath } from 'node:url'
+
+const here = dirname(fileURLToPath(import.meta.url))
+const src = resolve(here, '../../../skills')
+const dest = resolve(here, '../skills')
+
+if (!existsSync(src)) {
+ console.error(`[copy-skills] source not found: ${src}`)
+ process.exit(1)
+}
+
+if (existsSync(dest))
+ await rm(dest, { recursive: true })
+await cp(src, dest, { recursive: true })
+
+console.log(`[copy-skills] ${src} → ${dest}`)
diff --git a/packages/node-modules-inspector/src/app/state/maintainer-actions.ts b/packages/node-modules-inspector/src/app/state/maintainer-actions.ts
index 6645c55..2a3640a 100644
--- a/packages/node-modules-inspector/src/app/state/maintainer-actions.ts
+++ b/packages/node-modules-inspector/src/app/state/maintainer-actions.ts
@@ -1,231 +1,41 @@
-import type { PackageNode, PublintMessage } from 'node-modules-tools'
-import type { ParsedAuthor } from 'node-modules-tools/utils'
-import semver from 'semver'
+import type { PackageNode } from 'node-modules-tools'
+import type {
+ MaintainerActionGroup,
+ MaintainerActionItem,
+ MaintainerActionSortMode,
+} from '../../shared/reports/maintainers'
import { computed } from 'vue'
-import { compareSemver } from '../utils/semver'
+import {
+ collectMaintainerActionAuthors,
+ computeMaintainerActions,
+ groupMaintainerActions,
+} from '../../shared/reports/maintainers'
import { rawPayload, rawPublintMessages } from './data'
import { getNpmMetaLatest, getPublishTime, payloads } from './payload'
import { query } from './query'
-export function authorKey(author: ParsedAuthor): string {
- return author.type === 'github' ? `@${author.github}` : author.name
-}
-
-interface BaseAction {
- consumer: PackageNode
- depth: number
- key: string
-}
-
-export interface DepUpgradeAction extends BaseAction {
- kind: 'dep-upgrade'
- depName: string
- depType: 'peer' | 'prod'
- /** The effective semver range (after resolving any catalog reference). */
- declaredRange: string
- /** The raw range as written in package.json, when it differs (e.g. `catalog:deps`). */
- rawRange?: string
- /** Catalog name when the raw range was a catalog reference. */
- catalogName?: string
- installedHighestVersion: string
- installedHighest: PackageNode
- installedVersions: string[]
- migratedCount: number
- totalCount: number
- migrationRatio: number
-}
-
-export interface PublintAction extends BaseAction {
- kind: 'publint'
- messages: PublintMessage[]
- counts: { error: number, warning: number, suggestion: number }
-}
-
-export type MaintainerActionItem = DepUpgradeAction | PublintAction
-
-interface DepStats {
- highestVersion: string
- highestPkg: PackageNode
- versions: string[]
- migrated: number
- behind: number
-}
-
-const NON_SEMVER_PREFIXES = ['workspace:', 'link:', 'file:', 'npm:', 'git+', 'git:', 'http:', 'https:', 'github:']
-
-function resolveCatalogRange(
- range: string,
- depName: string,
- catalogs: Record> | undefined,
-): string | undefined {
- if (!range.startsWith('catalog:'))
- return range
- if (!catalogs)
- return undefined
- const name = range.slice('catalog:'.length) || 'default'
- return catalogs[name]?.[depName]
-}
-
-function isPlainSemverRange(range: string | undefined): range is string {
- if (!range || range === '*' || range === 'latest' || range === 'x')
- return false
- return !NON_SEMVER_PREFIXES.some(p => range.startsWith(p))
-}
-
-function safeSatisfies(version: string, range: string) {
- try {
- return semver.satisfies(version, range)
- }
- catch {
- return null
- }
-}
-
-function safeGtr(version: string, range: string) {
- try {
- return semver.gtr(version, range)
- }
- catch {
- return null
- }
-}
-
-function isStable(version: string) {
- return semver.prerelease(version) === null
-}
-
-function getPublintMessagesFor(pkg: PackageNode): PublintMessage[] | null {
- if (pkg.resolved.publint)
- return pkg.resolved.publint
- const fetched = rawPublintMessages.value.get(pkg.spec)
- return fetched ? [...fetched] : null
-}
+export {
+ authorKey,
+} from '../../shared/reports/maintainers'
+export type {
+ DepUpgradeAction,
+ MaintainerActionAuthorEntry,
+ MaintainerActionGroup,
+ MaintainerActionItem,
+ MaintainerActionSortMode,
+ PublintAction,
+} from '../../shared/reports/maintainers'
export const maintainerActions = computed(() => {
- const packages = payloads.filtered.packages
- const versions = payloads.filtered.versions
- const catalogs = rawPayload.value?.catalogs
-
- const stats = new Map()
-
- function getStats(depName: string): DepStats | null {
- if (stats.has(depName))
- return stats.get(depName)!
- const installed = versions.get(depName)
- if (!installed?.length) {
- stats.set(depName, null)
- return null
- }
- const sortedAll = installed.slice().sort((a, b) => compareSemver(a.version, b.version))
- const stable = sortedAll.filter(p => isStable(p.version))
- if (!stable.length) {
- stats.set(depName, null)
- return null
- }
- const highestPkg = stable.at(-1)!
- const entry: DepStats = {
- highestVersion: highestPkg.version,
- highestPkg,
- versions: sortedAll.map(p => p.version),
- migrated: 0,
- behind: 0,
- }
- stats.set(depName, entry)
- return entry
- }
-
- for (const consumer of packages) {
- const pj = consumer.resolved.packageJson
- const blocks = [pj.peerDependencies, pj.dependencies]
- for (const block of blocks) {
- if (!block)
- continue
- for (const [depName, rawRange] of Object.entries(block)) {
- const range = resolveCatalogRange(rawRange, depName, catalogs)
- if (!isPlainSemverRange(range))
- continue
- const entry = getStats(depName)
- if (!entry)
- continue
- if (safeSatisfies(entry.highestVersion, range)) {
- entry.migrated++
- }
- else if (safeGtr(entry.highestVersion, range)) {
- entry.behind++
- }
- // else: declared range is ahead of highest stable — ignore (not part of this cohort)
- }
- }
- }
-
- const items: MaintainerActionItem[] = []
-
- for (const consumer of packages) {
- const pj = consumer.resolved.packageJson
- const blocks: Array<[Record | undefined, 'peer' | 'prod']> = [
- [pj.peerDependencies, 'peer'],
- [pj.dependencies, 'prod'],
- ]
- for (const [block, depType] of blocks) {
- if (!block)
- continue
- for (const [depName, rawRange] of Object.entries(block)) {
- const declaredRange = resolveCatalogRange(rawRange, depName, catalogs)
- if (!isPlainSemverRange(declaredRange))
- continue
- const entry = stats.get(depName)
- if (!entry)
- continue
- if (safeGtr(entry.highestVersion, declaredRange) !== true)
- continue
- // Skip when consumer and dep share the same repository (monorepo siblings).
- const consumerRepo = consumer.resolved.repository?.url
- const depRepo = entry.highestPkg.resolved.repository?.url
- if (consumerRepo && depRepo && consumerRepo === depRepo)
- continue
- const total = entry.migrated + entry.behind
- const catalogName = rawRange.startsWith('catalog:')
- ? (rawRange.slice('catalog:'.length) || 'default')
- : undefined
- items.push({
- kind: 'dep-upgrade',
- consumer,
- depName,
- depType,
- declaredRange,
- rawRange: rawRange === declaredRange ? undefined : rawRange,
- catalogName,
- installedHighestVersion: entry.highestVersion,
- installedHighest: entry.highestPkg,
- installedVersions: entry.versions,
- migratedCount: entry.migrated,
- totalCount: total,
- migrationRatio: total ? entry.migrated / total : 0,
- depth: consumer.depth,
- key: `${consumer.spec}::${depType}::${depName}`,
- })
- }
- }
- }
-
- for (const consumer of packages) {
- const messages = getPublintMessagesFor(consumer)
- if (!messages?.length)
- continue
- const counts = { error: 0, warning: 0, suggestion: 0 }
- for (const m of messages)
- counts[m.type]++
- items.push({
- kind: 'publint',
- consumer,
- depth: consumer.depth,
- key: `${consumer.spec}::publint`,
- messages,
- counts,
- })
- }
-
- return items
+ return computeMaintainerActions({
+ packages: payloads.filtered.packages,
+ versions: payloads.filtered.versions,
+ catalogs: rawPayload.value?.catalogs,
+ publintFallback: (pkg) => {
+ const fetched = rawPublintMessages.value.get(pkg.spec)
+ return fetched ? [...fetched] : null
+ },
+ })
})
export function findMaintainerActionByKey(key: string | undefined): MaintainerActionItem | undefined {
@@ -234,17 +44,6 @@ export function findMaintainerActionByKey(key: string | undefined): MaintainerAc
return maintainerActions.value.find(i => i.key === key)
}
-export interface MaintainerActionGroup {
- consumer: PackageNode
- depth: number
- authors: ParsedAuthor[]
- items: MaintainerActionItem[]
- maxMigrationRatio: number
- latestReleasedAt: number
-}
-
-export type MaintainerActionSortMode = 'depth' | 'migration' | 'latest'
-
export const maintainerActionSortMode = computed({
get: () => {
const v = query.actionSort
@@ -255,84 +54,15 @@ export const maintainerActionSortMode = computed({
},
})
-function getConsumerAuthors(pkg: PackageNode): ParsedAuthor[] {
- const list = pkg.resolved.authors
- if (!list?.length)
- return []
- return list.filter(a => a.type === 'github' || !!a.name?.trim())
-}
-
-function actionSortKey(a: MaintainerActionItem, b: MaintainerActionItem): number {
- if (a.kind !== b.kind)
- return a.kind === 'publint' ? -1 : 1
- if (a.kind === 'dep-upgrade' && b.kind === 'dep-upgrade') {
- return (b.migrationRatio - a.migrationRatio)
- || a.depName.localeCompare(b.depName)
- }
- return 0
-}
-
export const maintainerActionGroups = computed(() => {
- const byConsumer = new Map()
- for (const item of maintainerActions.value) {
- let group = byConsumer.get(item.consumer.spec)
- if (!group) {
- group = {
- consumer: item.consumer,
- depth: item.consumer.depth,
- authors: getConsumerAuthors(item.consumer),
- items: [],
- maxMigrationRatio: 0,
- latestReleasedAt: getPublishTime(item.consumer)?.getTime() ?? 0,
- }
- byConsumer.set(item.consumer.spec, group)
- }
- group.items.push(item)
- if (item.kind === 'dep-upgrade' && item.migrationRatio > group.maxMigrationRatio)
- group.maxMigrationRatio = item.migrationRatio
- }
-
- for (const group of byConsumer.values())
- group.items.sort(actionSortKey)
-
- const byName = new Map()
- for (const g of byConsumer.values()) {
- const cur = byName.get(g.consumer.name)
- if (!cur || compareSemver(cur.consumer.version, g.consumer.version) < 0)
- byName.set(g.consumer.name, g)
- }
- const groups = Array.from(byName.values())
- const mode = maintainerActionSortMode.value
- const nameTie = (a: MaintainerActionGroup, b: MaintainerActionGroup) =>
- a.consumer.name.localeCompare(b.consumer.name)
- const cmp: (a: MaintainerActionGroup, b: MaintainerActionGroup) => number
- = mode === 'migration'
- ? (a, b) => (b.maxMigrationRatio - a.maxMigrationRatio) || (a.depth - b.depth) || nameTie(a, b)
- : mode === 'latest'
- ? (a, b) => (b.latestReleasedAt - a.latestReleasedAt) || (a.depth - b.depth) || nameTie(a, b)
- : (a, b) => (a.depth - b.depth) || (b.maxMigrationRatio - a.maxMigrationRatio) || nameTie(a, b)
- return groups.sort(cmp)
+ return groupMaintainerActions(maintainerActions.value, {
+ sort: maintainerActionSortMode.value,
+ publishTimeOf: pkg => getPublishTime(pkg) ?? undefined,
+ })
})
-export interface MaintainerActionAuthorEntry {
- author: ParsedAuthor
- count: number
-}
-
-export const maintainerActionAuthors = computed(() => {
- const map = new Map()
- for (const group of maintainerActionGroups.value) {
- for (const author of group.authors) {
- const key = authorKey(author)
- const entry = map.get(key)
- if (entry)
- entry.count++
- else
- map.set(key, { author, count: 1 })
- }
- }
- return Array.from(map.values())
- .sort((a, b) => (b.count - a.count) || authorKey(a.author).localeCompare(authorKey(b.author)))
+export const maintainerActionAuthors = computed(() => {
+ return collectMaintainerActionAuthors(maintainerActionGroups.value)
})
export const maintainerFilter = computed({
@@ -364,38 +94,14 @@ export const maintainerActionLatestOnly = computed({
})
export const filteredMaintainerActionGroups = computed(() => {
- let groups = maintainerActionGroups.value
-
- const selected = maintainerFilter.value
- if (selected.length) {
- const set = new Set(selected)
- groups = groups.filter(g => g.authors.some(a => set.has(authorKey(a))))
- }
-
- if (!maintainerActionIncludePublint.value) {
- groups = groups
- .map((g) => {
- const items = g.items.filter(i => i.kind !== 'publint')
- return items.length === g.items.length ? g : { ...g, items }
- })
- .filter(g => g.items.length > 0)
- }
-
- if (maintainerActionLatestOnly.value) {
- groups = groups.filter((g) => {
- const latest = getNpmMetaLatest(g.consumer)
- if (!latest?.version)
- return true
- try {
- return semver.major(g.consumer.version) === semver.major(latest.version)
- }
- catch {
- return true
- }
- })
- }
-
- return groups
+ return groupMaintainerActions(maintainerActions.value, {
+ sort: maintainerActionSortMode.value,
+ authorFilter: maintainerFilter.value,
+ includePublint: maintainerActionIncludePublint.value,
+ latestOnly: maintainerActionLatestOnly.value,
+ latestVersionOf: pkg => getNpmMetaLatest(pkg)?.version,
+ publishTimeOf: pkg => getPublishTime(pkg) ?? undefined,
+ })
})
export function getMaintainerActionsFor(pkg: PackageNode): MaintainerActionItem[] {
diff --git a/packages/node-modules-inspector/src/app/utils/semver.ts b/packages/node-modules-inspector/src/app/utils/semver.ts
index cb03c7b..3f8c39b 100644
--- a/packages/node-modules-inspector/src/app/utils/semver.ts
+++ b/packages/node-modules-inspector/src/app/utils/semver.ts
@@ -1,4 +1,7 @@
import semver from 'semver'
+import { compareSemver } from '../../shared/semver'
+
+export { compareSemver }
export interface ParsedSemver {
valid: boolean
@@ -55,18 +58,6 @@ export function parseSemverRange(range: string) {
return result
}
-export function compareSemver(a: string, b: string) {
- if (a === b)
- return 0
- try {
- return semver.compare(a, b)
- }
- catch (e) {
- console.error('Failed to compare semver ', e)
- return 0
- }
-}
-
export function compareSemverRange(a = '*', b = '*') {
if (a === b)
return 0
diff --git a/packages/node-modules-inspector/src/node/cli-report/format-duplicates.test.ts b/packages/node-modules-inspector/src/node/cli-report/format-duplicates.test.ts
new file mode 100644
index 0000000..db1de5d
--- /dev/null
+++ b/packages/node-modules-inspector/src/node/cli-report/format-duplicates.test.ts
@@ -0,0 +1,49 @@
+import type { DuplicatesEntry } from '../../shared/reports/dto'
+import { describe, expect, it } from 'vitest'
+import { formatDuplicates } from './format-duplicates'
+import { stripAnsi } from './format-util'
+
+describe('formatDuplicates', () => {
+ it('renders empty state', () => {
+ expect(stripAnsi(formatDuplicates([]))).toMatchInlineSnapshot(`
+ "No duplicated packages found.
+ "
+ `)
+ })
+
+ it('renders a populated table', () => {
+ const entries: DuplicatesEntry[] = [
+ {
+ name: '@typescript-eslint/scope-manager',
+ versions: ['8.56.1', '8.59.1', '8.59.2', '8.59.4'],
+ specs: [
+ '@typescript-eslint/scope-manager@8.56.1',
+ '@typescript-eslint/scope-manager@8.59.1',
+ '@typescript-eslint/scope-manager@8.59.2',
+ '@typescript-eslint/scope-manager@8.59.4',
+ ],
+ },
+ {
+ name: 'esbuild',
+ versions: ['0.27.7', '0.28.0'],
+ specs: ['esbuild@0.27.7', 'esbuild@0.28.0'],
+ },
+ ]
+ expect(stripAnsi(formatDuplicates(entries))).toMatchInlineSnapshot(`
+ "Package # Versions
+ ──────────────────────────────── ─ ──────────────────────────────────
+ @typescript-eslint/scope-manager 4 v8.56.1, v8.59.1, v8.59.2, v8.59.4
+ esbuild 2 v0.27.7, v0.28.0
+
+ 2 packages with multiple versions
+ "
+ `)
+ })
+
+ it('renders singular summary for one entry', () => {
+ const entries: DuplicatesEntry[] = [
+ { name: 'foo', versions: ['1.0.0', '2.0.0'], specs: ['foo@1.0.0', 'foo@2.0.0'] },
+ ]
+ expect(stripAnsi(formatDuplicates(entries))).toContain('1 package with multiple versions')
+ })
+})
diff --git a/packages/node-modules-inspector/src/node/cli-report/format-duplicates.ts b/packages/node-modules-inspector/src/node/cli-report/format-duplicates.ts
new file mode 100644
index 0000000..2138b19
--- /dev/null
+++ b/packages/node-modules-inspector/src/node/cli-report/format-duplicates.ts
@@ -0,0 +1,23 @@
+import type { DuplicatesEntry } from '../../shared/reports/dto'
+import c from 'ansis'
+import { renderTable } from './format-util'
+
+export function formatDuplicates(entries: DuplicatesEntry[]): string {
+ if (!entries.length)
+ return c.green('No duplicated packages found.\n')
+
+ const rows = entries.map(e => [
+ e.name,
+ String(e.versions.length),
+ e.versions.map(v => `v${v}`).join(', '),
+ ])
+
+ const table = renderTable([
+ { header: 'Package' },
+ { header: '#', align: 'right' },
+ { header: 'Versions' },
+ ], rows)
+
+ const summary = c.dim(`\n${entries.length} package${entries.length === 1 ? '' : 's'} with multiple versions\n`)
+ return `${table}\n${summary}`
+}
diff --git a/packages/node-modules-inspector/src/node/cli-report/format-maintainers.test.ts b/packages/node-modules-inspector/src/node/cli-report/format-maintainers.test.ts
new file mode 100644
index 0000000..634a00b
--- /dev/null
+++ b/packages/node-modules-inspector/src/node/cli-report/format-maintainers.test.ts
@@ -0,0 +1,78 @@
+import type { MaintainersGroupDto } from '../../shared/reports/dto'
+import { describe, expect, it } from 'vitest'
+import { formatMaintainers } from './format-maintainers'
+import { stripAnsi } from './format-util'
+
+describe('formatMaintainers', () => {
+ it('renders empty state', () => {
+ expect(stripAnsi(formatMaintainers([]))).toMatchInlineSnapshot(`
+ "No maintainer actions found.
+ "
+ `)
+ })
+
+ it('renders dep-upgrade + publint groups', () => {
+ const groups: MaintainersGroupDto[] = [
+ {
+ consumer: { spec: 'rollup-plugin-esbuild@6.2.1', name: 'rollup-plugin-esbuild', version: '6.2.1', depth: 1 },
+ authors: [{ type: 'github', github: 'egoist', avatar: 'https://avatars.githubusercontent.com/egoist' }],
+ items: [
+ {
+ kind: 'publint',
+ messages: [],
+ counts: { error: 0, warning: 1, suggestion: 2 },
+ },
+ {
+ kind: 'dep-upgrade',
+ depName: 'unplugin-utils',
+ depType: 'prod',
+ declaredRange: '^0.2.4',
+ installedHighestVersion: '0.3.1',
+ installedHighestSpec: 'unplugin-utils@0.3.1',
+ installedVersions: ['0.2.4', '0.3.1'],
+ migratedCount: 10,
+ totalCount: 11,
+ migrationRatio: 10 / 11,
+ },
+ ],
+ maxMigrationRatio: 10 / 11,
+ latestReleasedAt: 0,
+ },
+ {
+ consumer: { spec: 'eslint@10.4.0', name: 'eslint', version: '10.4.0', depth: 1 },
+ authors: [{ type: 'github', github: 'eslint', avatar: 'https://avatars.githubusercontent.com/eslint' }],
+ items: [
+ {
+ kind: 'dep-upgrade',
+ depName: 'ajv',
+ depType: 'prod',
+ declaredRange: '^6.14.0',
+ catalogName: 'deps',
+ rawRange: 'catalog:deps',
+ installedHighestVersion: '8.17.1',
+ installedHighestSpec: 'ajv@8.17.1',
+ installedVersions: ['6.14.0', '8.17.1'],
+ migratedCount: 3,
+ totalCount: 5,
+ migrationRatio: 3 / 5,
+ },
+ ],
+ maxMigrationRatio: 3 / 5,
+ latestReleasedAt: 0,
+ },
+ ]
+ expect(stripAnsi(formatMaintainers(groups))).toMatchInlineSnapshot(`
+ "rollup-plugin-esbuild v6.2.1 · depth 1
+ by @egoist
+ publint 1 warning, 2 suggestions
+ prod unplugin-utils ^0.2.4 → v0.3.1 migration 10/11 (91%)
+
+ eslint v10.4.0 · depth 1
+ by @eslint
+ prod ajv ^6.14.0 → v8.17.1 catalog:deps migration 3/5 (60%)
+
+ 2 consumers with actions
+ "
+ `)
+ })
+})
diff --git a/packages/node-modules-inspector/src/node/cli-report/format-maintainers.ts b/packages/node-modules-inspector/src/node/cli-report/format-maintainers.ts
new file mode 100644
index 0000000..de4404a
--- /dev/null
+++ b/packages/node-modules-inspector/src/node/cli-report/format-maintainers.ts
@@ -0,0 +1,49 @@
+import type { MaintainersGroupDto } from '../../shared/reports/dto'
+import c from 'ansis'
+
+function authorLabel(author: MaintainersGroupDto['authors'][number]): string {
+ if (author.type === 'github')
+ return `@${author.github}`
+ return author.name
+}
+
+function formatGroup(group: MaintainersGroupDto): string {
+ const header = `${c.bold(group.consumer.name)} ${c.dim(`v${group.consumer.version} · depth ${group.consumer.depth}`)}`
+ const authors = group.authors.length
+ ? c.dim(` by ${group.authors.map(authorLabel).join(', ')}`)
+ : ''
+
+ const lines: string[] = []
+ for (const item of group.items) {
+ if (item.kind === 'publint') {
+ const counts = item.counts
+ const parts: string[] = []
+ if (counts.error)
+ parts.push(c.red(`${counts.error} error${counts.error === 1 ? '' : 's'}`))
+ if (counts.warning)
+ parts.push(c.yellow(`${counts.warning} warning${counts.warning === 1 ? '' : 's'}`))
+ if (counts.suggestion)
+ parts.push(c.blue(`${counts.suggestion} suggestion${counts.suggestion === 1 ? '' : 's'}`))
+ lines.push(` ${c.magenta('publint')} ${parts.join(', ')}`)
+ }
+ else {
+ const ratio = item.totalCount
+ ? `${item.migratedCount}/${item.totalCount} (${Math.round(item.migrationRatio * 100)}%)`
+ : '—'
+ const type = item.depType === 'peer' ? c.cyan('peer') : c.green('prod')
+ const catalog = item.catalogName ? c.dim(` catalog:${item.catalogName}`) : ''
+ lines.push(` ${type} ${c.bold(item.depName)} ${c.dim(item.declaredRange)} → ${c.green(`v${item.installedHighestVersion}`)}${catalog} ${c.dim(`migration ${ratio}`)}`)
+ }
+ }
+
+ return [header, authors, ...lines].filter(Boolean).join('\n')
+}
+
+export function formatMaintainers(groups: MaintainersGroupDto[]): string {
+ if (!groups.length)
+ return c.green('No maintainer actions found.\n')
+
+ const blocks = groups.map(formatGroup).join('\n\n')
+ const summary = c.dim(`\n${groups.length} consumer${groups.length === 1 ? '' : 's'} with actions\n`)
+ return `${blocks}\n${summary}`
+}
diff --git a/packages/node-modules-inspector/src/node/cli-report/format-sizes.test.ts b/packages/node-modules-inspector/src/node/cli-report/format-sizes.test.ts
new file mode 100644
index 0000000..50c84f6
--- /dev/null
+++ b/packages/node-modules-inspector/src/node/cli-report/format-sizes.test.ts
@@ -0,0 +1,62 @@
+import type { SizesEntry } from '../../shared/reports/dto'
+import { describe, expect, it } from 'vitest'
+import { formatSizes } from './format-sizes'
+import { formatBytes, stripAnsi } from './format-util'
+
+describe('formatBytes', () => {
+ it('formats common ranges', () => {
+ expect(formatBytes(0)).toBe('0 B')
+ expect(formatBytes(512)).toBe('512 B')
+ expect(formatBytes(1024)).toBe('1.00 KB')
+ expect(formatBytes(1536)).toBe('1.50 KB')
+ expect(formatBytes(1024 * 1024)).toBe('1.00 MB')
+ expect(formatBytes(23_237_120)).toBe('22.2 MB')
+ expect(formatBytes(1024 * 1024 * 1024)).toBe('1.00 GB')
+ })
+})
+
+describe('formatSizes', () => {
+ it('renders empty state', () => {
+ expect(stripAnsi(formatSizes([]))).toMatchInlineSnapshot(`
+ "No install-size data available.
+ "
+ `)
+ })
+
+ it('renders a populated table', () => {
+ const entries: SizesEntry[] = [
+ {
+ spec: 'typescript@6.0.3',
+ name: 'typescript',
+ version: '6.0.3',
+ workspace: false,
+ bytes: 24_346_827,
+ categories: {
+ js: { bytes: 15_344_521, count: 200 },
+ dts: { bytes: 7_002_306, count: 150 },
+ other: { bytes: 2_000_000, count: 50 },
+ },
+ },
+ {
+ spec: 'esbuild@0.28.0',
+ name: 'esbuild',
+ version: '0.28.0',
+ workspace: false,
+ bytes: 10_678_299,
+ categories: {
+ bin: { bytes: 10_500_000, count: 1 },
+ js: { bytes: 178_299, count: 10 },
+ },
+ },
+ ]
+ expect(stripAnsi(formatSizes(entries))).toMatchInlineSnapshot(`
+ "Package Size Largest file type
+ ──────────────── ─────── ─────────────────
+ typescript@6.0.3 23.2 MB js (63%)
+ esbuild@0.28.0 10.2 MB bin (98%)
+
+ 2 packages · total 33.4 MB
+ "
+ `)
+ })
+})
diff --git a/packages/node-modules-inspector/src/node/cli-report/format-sizes.ts b/packages/node-modules-inspector/src/node/cli-report/format-sizes.ts
new file mode 100644
index 0000000..36c3adf
--- /dev/null
+++ b/packages/node-modules-inspector/src/node/cli-report/format-sizes.ts
@@ -0,0 +1,42 @@
+import type { SizesEntry } from '../../shared/reports/dto'
+import c from 'ansis'
+import { formatBytes, renderTable } from './format-util'
+
+function topCategory(entry: SizesEntry): string {
+ const categories = entry.categories
+ let bestKey: string | undefined
+ let bestBytes = 0
+ for (const [key, value] of Object.entries(categories)) {
+ if (!value)
+ continue
+ if (value.bytes > bestBytes) {
+ bestBytes = value.bytes
+ bestKey = key
+ }
+ }
+ if (!bestKey)
+ return ''
+ const pct = entry.bytes ? Math.round((bestBytes / entry.bytes) * 100) : 0
+ return `${bestKey} ${c.dim(`(${pct}%)`)}`
+}
+
+export function formatSizes(entries: SizesEntry[]): string {
+ if (!entries.length)
+ return c.dim('No install-size data available.\n')
+
+ const rows = entries.map(e => [
+ e.spec,
+ formatBytes(e.bytes),
+ topCategory(e),
+ ])
+
+ const table = renderTable([
+ { header: 'Package' },
+ { header: 'Size', align: 'right' },
+ { header: 'Largest file type' },
+ ], rows)
+
+ const total = entries.reduce((sum, e) => sum + e.bytes, 0)
+ const summary = c.dim(`\n${entries.length} package${entries.length === 1 ? '' : 's'} · total ${formatBytes(total)}\n`)
+ return `${table}\n${summary}`
+}
diff --git a/packages/node-modules-inspector/src/node/cli-report/format-util.ts b/packages/node-modules-inspector/src/node/cli-report/format-util.ts
new file mode 100644
index 0000000..dc7ed53
--- /dev/null
+++ b/packages/node-modules-inspector/src/node/cli-report/format-util.ts
@@ -0,0 +1,75 @@
+import c from 'ansis'
+
+const UNITS = ['B', 'KB', 'MB', 'GB', 'TB']
+
+export function formatBytes(bytes: number): string {
+ if (bytes === 0)
+ return '0 B'
+ let value = bytes
+ let unit = 0
+ while (value >= 1024 && unit < UNITS.length - 1) {
+ value /= 1024
+ unit++
+ }
+ const precision = value < 10 ? 2 : value < 100 ? 1 : 0
+ return `${value.toFixed(precision)} ${UNITS[unit]}`
+}
+
+const ESC = String.fromCharCode(0x1B)
+const ANSI_RE = new RegExp(`${ESC.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\[[0-9;]*m`, 'g')
+
+export function stripAnsi(s: string): string {
+ return s.replace(ANSI_RE, '')
+}
+
+export function visualWidth(s: string): number {
+ return stripAnsi(s).length
+}
+
+export function padRight(s: string, width: number): string {
+ const diff = width - visualWidth(s)
+ return diff > 0 ? s + ' '.repeat(diff) : s
+}
+
+export function padLeft(s: string, width: number): string {
+ const diff = width - visualWidth(s)
+ return diff > 0 ? ' '.repeat(diff) + s : s
+}
+
+export type Alignment = 'left' | 'right'
+
+export interface Column {
+ header: string
+ align?: Alignment
+}
+
+export function renderTable(columns: Column[], rows: string[][]): string {
+ const widths: number[] = columns.map((col, i) => {
+ let max = visualWidth(col.header)
+ for (const row of rows)
+ max = Math.max(max, visualWidth(row[i] ?? ''))
+ return max
+ })
+
+ const lastIndex = columns.length - 1
+ const pad = (s: string, i: number): string => {
+ const align = columns[i]?.align
+ const width = widths[i] ?? 0
+ if (align === 'right')
+ return padLeft(s, width)
+ // Skip right-padding the last left-aligned column to avoid trailing whitespace.
+ if (i === lastIndex)
+ return s
+ return padRight(s, width)
+ }
+
+ const headerLine = columns.map((col, i) => c.bold(pad(col.header, i))).join(' ')
+ const rule = widths.map(w => c.dim('─'.repeat(w))).join(' ')
+ const rowLines = rows.map(r => r.map((cell, i) => pad(cell ?? '', i)).join(' '))
+
+ return [headerLine, rule, ...rowLines].join('\n')
+}
+
+export function section(title: string, body: string): string {
+ return `${c.bold.cyan(title)}\n${body}\n`
+}
diff --git a/packages/node-modules-inspector/src/node/cli-report/run-report.ts b/packages/node-modules-inspector/src/node/cli-report/run-report.ts
new file mode 100644
index 0000000..5254b6b
--- /dev/null
+++ b/packages/node-modules-inspector/src/node/cli-report/run-report.ts
@@ -0,0 +1,121 @@
+import type { PackageNode } from 'node-modules-tools'
+import process from 'node:process'
+import c from 'ansis'
+import { toMaintainersGroupDto } from '../../shared/reports/dto'
+import { computeDuplicates } from '../../shared/reports/duplicates'
+import {
+ computeMaintainerActions,
+ groupMaintainerActions,
+} from '../../shared/reports/maintainers'
+import { computeInstallSizes } from '../../shared/reports/sizes'
+import { createInspectorRpcHandlers } from '../rpc/handlers'
+import { storageNpmMeta, storageNpmMetaLatest, storagePublint } from '../storage'
+import { formatDuplicates } from './format-duplicates'
+import { formatMaintainers } from './format-maintainers'
+import { formatSizes } from './format-sizes'
+
+export type ReportType = 'maintainers' | 'duplicates' | 'sizes'
+
+export interface RunReportOptions {
+ type: ReportType
+ root: string
+ config?: string
+ depth: number
+ json: boolean
+ /** Common to all */
+ limit?: number
+ /** Maintainers-only */
+ sort?: 'depth' | 'migration' | 'latest'
+ authors?: string[]
+ includePublint?: boolean
+ latestOnly?: boolean
+ /** Duplicates-only */
+ minVersions?: number
+ /** Sizes-only */
+ includeWorkspace?: boolean
+}
+
+function buildVersionsMap(packages: Iterable): Map {
+ const map = new Map()
+ for (const pkg of packages) {
+ let bucket = map.get(pkg.name)
+ if (!bucket) {
+ bucket = []
+ map.set(pkg.name, bucket)
+ }
+ bucket.push(pkg)
+ }
+ return map
+}
+
+export async function runReport(options: RunReportOptions): Promise {
+ const handlers = createInspectorRpcHandlers({
+ cwd: options.root,
+ depth: options.depth,
+ configFile: options.config,
+ mode: 'build',
+ quiet: true,
+ storageNpmMeta,
+ storageNpmMetaLatest,
+ storagePublint,
+ })
+
+ let payload
+ try {
+ payload = await handlers.getPayload()
+ }
+ catch (error: any) {
+ console.error(c.red`✖ ${error.message || error}`)
+ process.exit(1)
+ }
+
+ if (options.type === 'duplicates') {
+ const data = computeDuplicates(payload.packages.values(), {
+ minVersions: options.minVersions,
+ limit: options.limit,
+ })
+ write(options.json ? toJson(data) : formatDuplicates(data))
+ return
+ }
+
+ if (options.type === 'sizes') {
+ const data = computeInstallSizes(payload.packages.values(), {
+ limit: options.limit,
+ includeWorkspace: options.includeWorkspace,
+ })
+ write(options.json ? toJson(data) : formatSizes(data))
+ return
+ }
+
+ if (options.type === 'maintainers') {
+ const packages = Array.from(payload.packages.values())
+ const versions = buildVersionsMap(packages)
+ const items = computeMaintainerActions({
+ packages,
+ versions,
+ catalogs: payload.catalogs,
+ })
+ const groups = groupMaintainerActions(items, {
+ sort: options.sort,
+ authorFilter: options.authors?.length ? options.authors : undefined,
+ includePublint: options.includePublint,
+ latestOnly: options.latestOnly,
+ latestVersionOf: pkg => pkg.resolved.npmMetaLatest?.version,
+ })
+ const limited = options.limit && options.limit > 0 ? groups.slice(0, options.limit) : groups
+ const dto = limited.map(toMaintainersGroupDto)
+ write(options.json ? toJson(dto) : formatMaintainers(dto))
+ return
+ }
+
+ console.error(c.red`✖ Unknown report type "${options.type}". Expected one of: maintainers, duplicates, sizes.`)
+ process.exit(1)
+}
+
+function toJson(data: unknown): string {
+ return `${JSON.stringify(data, null, 2)}\n`
+}
+
+function write(text: string): void {
+ process.stdout.write(text.endsWith('\n') ? text : `${text}\n`)
+}
diff --git a/packages/node-modules-inspector/src/node/cli.ts b/packages/node-modules-inspector/src/node/cli.ts
index efc6674..f8cfbb0 100644
--- a/packages/node-modules-inspector/src/node/cli.ts
+++ b/packages/node-modules-inspector/src/node/cli.ts
@@ -172,5 +172,68 @@ cli
}
})
+cli
+ .command('report ', 'Run an inspector report (maintainers | duplicates | sizes)')
+ .option('--root ', 'Root directory', { default: process.cwd() })
+ .option('--config ', 'Config file')
+ .option('--depth ', 'Max depth to list dependencies', { default: 8 })
+ .option('--json', 'Emit JSON to stdout (machine-readable)')
+ .option('--limit ', 'Cap the number of returned entries')
+ .option('--sort ', '[maintainers] Sort mode: depth | migration | latest', { default: 'depth' })
+ .option('--author ', '[maintainers] Filter by author handle (repeatable)')
+ .option('--no-publint', '[maintainers] Exclude publint actions')
+ .option('--no-latest-only', '[maintainers] Include consumers that are not on the latest major')
+ .option('--min-versions ', '[duplicates] Only include packages installed at this many versions or more', { default: 2 })
+ .option('--include-workspace', '[sizes] Include workspace packages')
+ .action(async (type: string, options: Record) => {
+ const valid = ['maintainers', 'duplicates', 'sizes']
+ if (!valid.includes(type)) {
+ console.error(c.red`✖ Unknown report type "${type}". Expected one of: ${valid.join(', ')}.`)
+ process.exit(1)
+ }
+ const { runReport } = await import('./cli-report/run-report')
+ const authors = Array.isArray(options.author)
+ ? options.author
+ : options.author
+ ? [options.author]
+ : []
+ await runReport({
+ type: type as 'maintainers' | 'duplicates' | 'sizes',
+ root: options.root,
+ config: options.config,
+ depth: Number(options.depth),
+ json: !!options.json,
+ limit: options.limit != null ? Number(options.limit) : undefined,
+ sort: options.sort,
+ authors,
+ includePublint: options.publint !== false,
+ latestOnly: options.latestOnly !== false,
+ minVersions: options.minVersions != null ? Number(options.minVersions) : undefined,
+ includeWorkspace: !!options.includeWorkspace,
+ })
+ })
+
+cli
+ .command('mcp', 'Start an MCP stdio server exposing report tools to coding agents (experimental)')
+ .option('--root ', 'Root directory', { default: process.cwd() })
+ .option('--config ', 'Config file')
+ .option('--depth ', 'Max depth to list dependencies', { default: 8 })
+ .action(async (options) => {
+ if (options.config)
+ process.env.NMI_CLI_CONFIG = options.config
+ process.env.NMI_CLI_DEPTH = String(Number(options.depth))
+ process.env.NMI_CLI_QUIET = '1'
+ if (options.root && options.root !== process.cwd())
+ process.chdir(options.root)
+
+ const { createMcpServer } = await import('devframe/adapters/mcp')
+ await createMcpServer(devframe, {
+ transport: 'stdio',
+ onReady: ({ transport }) => {
+ console.error(c.green`${MARK_CHECK} ${devframe.id} MCP server ready (${transport})`)
+ },
+ })
+ })
+
cli.help()
cli.parse()
diff --git a/packages/node-modules-inspector/src/node/devframe.ts b/packages/node-modules-inspector/src/node/devframe.ts
index 08aee12..ae00db9 100644
--- a/packages/node-modules-inspector/src/node/devframe.ts
+++ b/packages/node-modules-inspector/src/node/devframe.ts
@@ -1,3 +1,4 @@
+import process from 'node:process'
import { defineDevframe } from 'devframe/types'
import { distDir } from '../dirs'
import { getPackagesNpmMetaRpc } from './rpc/get-packages-npm-meta'
@@ -7,12 +8,16 @@ import { getPublintRpc } from './rpc/get-publint'
import { createInspectorRpcHandlers } from './rpc/handlers'
import { openInEditorRpc } from './rpc/open-in-editor'
import { openInFinderRpc } from './rpc/open-in-finder'
+import { reportDuplicatesRpc } from './rpc/report-duplicates'
+import { reportMaintainersRpc } from './rpc/report-maintainers'
+import { reportSizesRpc } from './rpc/report-sizes'
import { storageNpmMeta, storageNpmMetaLatest, storagePublint } from './storage'
export interface InspectorDevframeFlags {
root?: string
config?: string
depth?: number
+ quiet?: boolean
}
export default defineDevframe({
@@ -25,11 +30,14 @@ export default defineDevframe({
},
setup(ctx, info) {
const flags = (info?.flags ?? {}) as InspectorDevframeFlags
+ // MCP adapter calls setup() without flags. CLI mcp subcommand sets these env vars as a bridge.
+ const envDepth = process.env.NMI_CLI_DEPTH ? Number(process.env.NMI_CLI_DEPTH) : undefined
const handlers = createInspectorRpcHandlers({
cwd: flags.root ?? ctx.cwd,
- depth: flags.depth ?? 8,
- configFile: flags.config,
+ depth: flags.depth ?? envDepth ?? 8,
+ configFile: flags.config ?? process.env.NMI_CLI_CONFIG,
mode: ctx.mode,
+ quiet: flags.quiet ?? process.env.NMI_CLI_QUIET === '1',
storageNpmMeta,
storageNpmMetaLatest,
storagePublint,
@@ -41,5 +49,8 @@ export default defineDevframe({
ctx.rpc.register(getPublintRpc(handlers))
ctx.rpc.register(openInEditorRpc(handlers))
ctx.rpc.register(openInFinderRpc(handlers))
+ ctx.rpc.register(reportDuplicatesRpc(handlers))
+ ctx.rpc.register(reportMaintainersRpc(handlers))
+ ctx.rpc.register(reportSizesRpc(handlers))
},
})
diff --git a/packages/node-modules-inspector/src/node/rpc/handlers.ts b/packages/node-modules-inspector/src/node/rpc/handlers.ts
index 73ae373..e4e66df 100644
--- a/packages/node-modules-inspector/src/node/rpc/handlers.ts
+++ b/packages/node-modules-inspector/src/node/rpc/handlers.ts
@@ -21,6 +21,8 @@ export interface CreateInspectorRpcHandlersOptions extends
mode: 'dev' | 'build'
storagePublint?: Storage
configFile?: string
+ /** Route progress logs to stderr (keeps stdout clean for JSON/MCP). */
+ quiet?: boolean
}
export interface InspectorRpcHandlers {
@@ -33,6 +35,10 @@ export interface InspectorRpcHandlers {
}
export function createInspectorRpcHandlers(options: CreateInspectorRpcHandlersOptions): InspectorRpcHandlers {
+ const log = options.quiet
+ ? (msg: string) => process.stderr.write(`${msg}\n`)
+ : (msg: string) => console.log(msg)
+
let _config: Promise | null = null
let _payload: Promise | null = null
@@ -59,39 +65,39 @@ export function createInspectorRpcHandlers(options: CreateInspectorRpcHandlersOp
merge: true,
})
if (result.sources.length)
- console.log(c.green`${MARK_CHECK} Config loaded from ${result.sources.join(', ')}`)
+ log(c.green`${MARK_CHECK} Config loaded from ${result.sources.join(', ')}`)
return result.config
}
- async function getPackagesNpmMeta(specs: string[], log = true) {
+ async function getPackagesNpmMeta(specs: string[], verbose = true) {
const config = await getConfig()
if (!config.fetchNpmMeta)
return new Map()
- if (log)
- console.log(c.cyan`${MARK_NODE} Fetching npm meta for ${specs.length} packages...`)
+ if (verbose)
+ log(c.cyan`${MARK_NODE} Fetching npm meta for ${specs.length} packages...`)
const result = await _getPackagesNpmMeta(specs, { storageNpmMeta: options.storageNpmMeta })
- if (log)
- console.log(c.green`${MARK_CHECK} npm meta fetched for ${specs.length} packages`)
+ if (verbose)
+ log(c.green`${MARK_CHECK} npm meta fetched for ${specs.length} packages`)
return result
}
- async function getPackagesNpmMetaLatest(pkgNames: string[], log = true) {
+ async function getPackagesNpmMetaLatest(pkgNames: string[], verbose = true) {
const config = await getConfig()
if (!config.fetchNpmMeta)
return new Map()
- if (log)
- console.log(c.cyan`${MARK_NODE} Fetching npm meta latest for ${pkgNames.length} packages...`)
+ if (verbose)
+ log(c.cyan`${MARK_NODE} Fetching npm meta latest for ${pkgNames.length} packages...`)
const result = await _getPackagesNpmMetaLatest(pkgNames, { storageNpmMetaLatest: options.storageNpmMetaLatest })
- if (log)
- console.log(c.green`${MARK_CHECK} npm meta latest fetched for ${pkgNames.length} packages`)
+ if (verbose)
+ log(c.green`${MARK_CHECK} npm meta latest fetched for ${pkgNames.length} packages`)
return result
}
- async function getPublint(pkg: Pick, log = true) {
+ async function getPublint(pkg: Pick, verbose = true) {
if (pkg.workspace || pkg.private || !pkg.filepath)
return null
- if (log)
- console.log(c.cyan`${MARK_NODE} Running publint for ${pkg.spec}...`)
+ if (verbose)
+ log(c.cyan`${MARK_NODE} Running publint for ${pkg.spec}...`)
try {
let result = await options.storagePublint?.getItem(pkg.spec) || undefined
const { publint } = await import('publint')
@@ -103,8 +109,8 @@ export function createInspectorRpcHandlers(options: CreateInspectorRpcHandlersOp
}).then(r => r.messages) || []
await options.storagePublint?.setItem(pkg.spec, result)
}
- if (log)
- console.log(c.green`${MARK_CHECK} Publint for ${pkg.spec} finished with ${result.length} messages`)
+ if (verbose)
+ log(c.green`${MARK_CHECK} Publint for ${pkg.spec} finished with ${result.length} messages`)
return result
}
catch (e) {
@@ -128,7 +134,7 @@ export function createInspectorRpcHandlers(options: CreateInspectorRpcHandlersOp
const config = await getConfig()
const excludeFilter = constructPackageFilters(config.excludePackages || [], 'some')
const depsFilter = constructPackageFilters(config.excludeDependenciesOf || [], 'some')
- console.log(c.cyan`${MARK_NODE} Reading node_modules...`)
+ log(c.cyan`${MARK_NODE} Reading node_modules...`)
const result = await listPackageDependencies({
cwd: process.cwd(),
depth: 8,
@@ -148,19 +154,19 @@ export function createInspectorRpcHandlers(options: CreateInspectorRpcHandlersOp
if (options.mode === 'build' && config.publint) {
buildTasks.push((async () => {
- console.log(c.cyan`${MARK_NODE} Running publint...`)
+ log(c.cyan`${MARK_NODE} Running publint...`)
const limit = pLimit(20)
await Promise.all([...result.packages.values()]
.map(pkg => limit(async () => {
pkg.resolved.publint ||= await getPublint(pkg, false)
})))
- console.log(c.green`${MARK_CHECK} Publint finished`)
+ log(c.green`${MARK_CHECK} Publint finished`)
})())
}
if (options.mode === 'build' && config.fetchNpmMeta) {
buildTasks.push((async () => {
- console.log(c.cyan`${MARK_NODE} Fetching npm meta...`)
+ log(c.cyan`${MARK_NODE} Fetching npm meta...`)
try {
await Promise.allSettled([
getPackagesNpmMeta(Array.from(result.packages.keys()), false),
@@ -171,7 +177,7 @@ export function createInspectorRpcHandlers(options: CreateInspectorRpcHandlersOp
console.error(c.red`${MARK_NODE} Failed to fetch npm meta`)
console.error(e)
}
- console.log(c.green`${MARK_CHECK} npm meta fetched`)
+ log(c.green`${MARK_CHECK} npm meta fetched`)
})())
}
@@ -188,7 +194,7 @@ export function createInspectorRpcHandlers(options: CreateInspectorRpcHandlersOp
pkg.resolved.npmMetaLatest = metaLatest
}))
- console.log(c.green`${MARK_CHECK} node_modules read finished`)
+ log(c.green`${MARK_CHECK} node_modules read finished`)
const payload: NodeModulesInspectorPayload = {
hash,
@@ -197,9 +203,9 @@ export function createInspectorRpcHandlers(options: CreateInspectorRpcHandlersOp
config,
}
if (config.onPayloadReady) {
- console.log(c.cyan`${MARK_NODE} Running config hook...`)
+ log(c.cyan`${MARK_NODE} Running config hook...`)
await config.onPayloadReady(payload)
- console.log(c.green`${MARK_CHECK} Config hook finished`)
+ log(c.green`${MARK_CHECK} Config hook finished`)
}
return payload
}
diff --git a/packages/node-modules-inspector/src/node/rpc/report-duplicates.ts b/packages/node-modules-inspector/src/node/rpc/report-duplicates.ts
new file mode 100644
index 0000000..81f8cfd
--- /dev/null
+++ b/packages/node-modules-inspector/src/node/rpc/report-duplicates.ts
@@ -0,0 +1,33 @@
+import type { InspectorRpcHandlers } from './handlers'
+import { defineRpcFunction } from 'devframe'
+import * as v from 'valibot'
+import { computeDuplicates } from '../../shared/reports/duplicates'
+
+const argsSchema = v.optional(v.object({
+ minVersions: v.optional(v.pipe(v.number(), v.integer(), v.minValue(2)), 2),
+ limit: v.optional(v.number()),
+}), {})
+
+const returnsSchema = v.array(v.object({
+ name: v.string(),
+ versions: v.array(v.string()),
+ specs: v.array(v.string()),
+}))
+
+export function reportDuplicatesRpc(handlers: InspectorRpcHandlers) {
+ return defineRpcFunction({
+ name: 'nmi:report-duplicates',
+ type: 'query',
+ jsonSerializable: true,
+ args: [argsSchema],
+ returns: returnsSchema,
+ agent: {
+ description: 'List packages that are installed in multiple versions across the project. Read-only.',
+ },
+ // Cast: devframe's handler type expects sync RETURN, but async is allowed at runtime (devframe awaits).
+ handler: (async (opts = {}) => {
+ const payload = await handlers.getPayload()
+ return computeDuplicates(payload.packages.values(), opts)
+ }) as any,
+ })
+}
diff --git a/packages/node-modules-inspector/src/node/rpc/report-maintainers.ts b/packages/node-modules-inspector/src/node/rpc/report-maintainers.ts
new file mode 100644
index 0000000..69c7ac3
--- /dev/null
+++ b/packages/node-modules-inspector/src/node/rpc/report-maintainers.ts
@@ -0,0 +1,92 @@
+import type { PackageNode } from 'node-modules-tools'
+import type { InspectorRpcHandlers } from './handlers'
+import { defineRpcFunction } from 'devframe'
+import * as v from 'valibot'
+import { toMaintainersGroupDto } from '../../shared/reports/dto'
+import {
+ computeMaintainerActions,
+ groupMaintainerActions,
+} from '../../shared/reports/maintainers'
+
+const argsSchema = v.optional(v.object({
+ sort: v.optional(v.picklist(['depth', 'migration', 'latest']), 'depth'),
+ authors: v.optional(v.array(v.string()), []),
+ includePublint: v.optional(v.boolean(), true),
+ latestOnly: v.optional(v.boolean(), true),
+ limit: v.optional(v.number()),
+}), {})
+
+const returnsSchema = v.array(v.object({
+ consumer: v.object({
+ spec: v.string(),
+ name: v.string(),
+ version: v.string(),
+ depth: v.number(),
+ }),
+ authors: v.array(v.unknown()),
+ items: v.array(v.unknown()),
+ maxMigrationRatio: v.number(),
+ latestReleasedAt: v.number(),
+}))
+
+function buildVersionsMap(packages: Iterable): Map {
+ const map = new Map()
+ for (const pkg of packages) {
+ let bucket = map.get(pkg.name)
+ if (!bucket) {
+ bucket = []
+ map.set(pkg.name, bucket)
+ }
+ bucket.push(pkg)
+ }
+ return map
+}
+
+export function reportMaintainersRpc(handlers: InspectorRpcHandlers) {
+ return defineRpcFunction({
+ name: 'nmi:report-maintainers',
+ type: 'query',
+ jsonSerializable: true,
+ args: [argsSchema],
+ returns: returnsSchema,
+ agent: {
+ description: 'Maintenance actions per consumer package: dep-upgrade opportunities (when an installed dependency has a newer version that the consumer\'s range does not satisfy) and publint findings. Grouped by consumer, sorted by depth/migration/latest. Read-only.',
+ },
+ handler: (async (raw: {
+ sort?: 'depth' | 'migration' | 'latest'
+ authors?: string[]
+ includePublint?: boolean
+ latestOnly?: boolean
+ limit?: number
+ } = {}) => {
+ const opts = {
+ sort: 'depth' as 'depth' | 'migration' | 'latest',
+ authors: [] as string[],
+ includePublint: true,
+ latestOnly: true,
+ limit: undefined as number | undefined,
+ ...raw,
+ }
+ const payload = await handlers.getPayload()
+ const packages = Array.from(payload.packages.values())
+ const versions = buildVersionsMap(packages)
+
+ const items = computeMaintainerActions({
+ packages,
+ versions,
+ catalogs: payload.catalogs,
+ })
+
+ const groups = groupMaintainerActions(items, {
+ sort: opts.sort,
+ authorFilter: opts.authors.length ? opts.authors : undefined,
+ includePublint: opts.includePublint,
+ latestOnly: opts.latestOnly,
+ latestVersionOf: pkg => pkg.resolved.npmMetaLatest?.version,
+ })
+
+ const limited = opts.limit && opts.limit > 0 ? groups.slice(0, opts.limit) : groups
+ return limited.map(toMaintainersGroupDto)
+ }) as any,
+ })
+}
diff --git a/packages/node-modules-inspector/src/node/rpc/report-schemas.test.ts b/packages/node-modules-inspector/src/node/rpc/report-schemas.test.ts
new file mode 100644
index 0000000..0baadce
--- /dev/null
+++ b/packages/node-modules-inspector/src/node/rpc/report-schemas.test.ts
@@ -0,0 +1,246 @@
+import { toJsonSchema } from '@valibot/to-json-schema'
+import { describe, expect, it } from 'vitest'
+import { reportDuplicatesRpc } from './report-duplicates'
+import { reportMaintainersRpc } from './report-maintainers'
+import { reportSizesRpc } from './report-sizes'
+
+/**
+ * Locks down the MCP-facing JSON Schemas for each report tool. devframe runs
+ * the same conversion via @valibot/to-json-schema before exposing tools via
+ * `tools/list`, so these snapshots are the contract agents see. Any drift here
+ * is a public-API change.
+ */
+
+const stubHandlers = {} as any
+
+function schemasOf(def: { args?: readonly unknown[], returns?: unknown }) {
+ return {
+ input: def.args?.[0] ? toJsonSchema(def.args[0] as any) : null,
+ output: def.returns ? toJsonSchema(def.returns as any) : null,
+ }
+}
+
+describe('report RPC schemas', () => {
+ it('duplicates', () => {
+ expect(schemasOf(reportDuplicatesRpc(stubHandlers))).toMatchInlineSnapshot(`
+ {
+ "input": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "default": {},
+ "properties": {
+ "limit": {
+ "type": "number",
+ },
+ "minVersions": {
+ "default": 2,
+ "minimum": 2,
+ "type": "integer",
+ },
+ },
+ "required": [],
+ "type": "object",
+ },
+ "output": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "items": {
+ "properties": {
+ "name": {
+ "type": "string",
+ },
+ "specs": {
+ "items": {
+ "type": "string",
+ },
+ "type": "array",
+ },
+ "versions": {
+ "items": {
+ "type": "string",
+ },
+ "type": "array",
+ },
+ },
+ "required": [
+ "name",
+ "versions",
+ "specs",
+ ],
+ "type": "object",
+ },
+ "type": "array",
+ },
+ }
+ `)
+ })
+
+ it('sizes', () => {
+ expect(schemasOf(reportSizesRpc(stubHandlers))).toMatchInlineSnapshot(`
+ {
+ "input": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "default": {},
+ "properties": {
+ "includeWorkspace": {
+ "default": false,
+ "type": "boolean",
+ },
+ "limit": {
+ "default": 50,
+ "type": "integer",
+ },
+ },
+ "required": [],
+ "type": "object",
+ },
+ "output": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "items": {
+ "properties": {
+ "bytes": {
+ "type": "number",
+ },
+ "categories": {
+ "additionalProperties": {
+ "properties": {
+ "bytes": {
+ "type": "number",
+ },
+ "count": {
+ "type": "number",
+ },
+ },
+ "required": [
+ "bytes",
+ "count",
+ ],
+ "type": "object",
+ },
+ "propertyNames": {
+ "type": "string",
+ },
+ "type": "object",
+ },
+ "name": {
+ "type": "string",
+ },
+ "spec": {
+ "type": "string",
+ },
+ "version": {
+ "type": "string",
+ },
+ "workspace": {
+ "type": "boolean",
+ },
+ },
+ "required": [
+ "spec",
+ "name",
+ "version",
+ "workspace",
+ "bytes",
+ "categories",
+ ],
+ "type": "object",
+ },
+ "type": "array",
+ },
+ }
+ `)
+ })
+
+ it('maintainers', () => {
+ expect(schemasOf(reportMaintainersRpc(stubHandlers))).toMatchInlineSnapshot(`
+ {
+ "input": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "default": {},
+ "properties": {
+ "authors": {
+ "default": [],
+ "items": {
+ "type": "string",
+ },
+ "type": "array",
+ },
+ "includePublint": {
+ "default": true,
+ "type": "boolean",
+ },
+ "latestOnly": {
+ "default": true,
+ "type": "boolean",
+ },
+ "limit": {
+ "type": "number",
+ },
+ "sort": {
+ "default": "depth",
+ "enum": [
+ "depth",
+ "migration",
+ "latest",
+ ],
+ "type": "string",
+ },
+ },
+ "required": [],
+ "type": "object",
+ },
+ "output": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "items": {
+ "properties": {
+ "authors": {
+ "items": {},
+ "type": "array",
+ },
+ "consumer": {
+ "properties": {
+ "depth": {
+ "type": "number",
+ },
+ "name": {
+ "type": "string",
+ },
+ "spec": {
+ "type": "string",
+ },
+ "version": {
+ "type": "string",
+ },
+ },
+ "required": [
+ "spec",
+ "name",
+ "version",
+ "depth",
+ ],
+ "type": "object",
+ },
+ "items": {
+ "items": {},
+ "type": "array",
+ },
+ "latestReleasedAt": {
+ "type": "number",
+ },
+ "maxMigrationRatio": {
+ "type": "number",
+ },
+ },
+ "required": [
+ "consumer",
+ "authors",
+ "items",
+ "maxMigrationRatio",
+ "latestReleasedAt",
+ ],
+ "type": "object",
+ },
+ "type": "array",
+ },
+ }
+ `)
+ })
+})
diff --git a/packages/node-modules-inspector/src/node/rpc/report-sizes.ts b/packages/node-modules-inspector/src/node/rpc/report-sizes.ts
new file mode 100644
index 0000000..2dea408
--- /dev/null
+++ b/packages/node-modules-inspector/src/node/rpc/report-sizes.ts
@@ -0,0 +1,38 @@
+import type { InspectorRpcHandlers } from './handlers'
+import { defineRpcFunction } from 'devframe'
+import * as v from 'valibot'
+import { computeInstallSizes } from '../../shared/reports/sizes'
+
+const argsSchema = v.optional(v.object({
+ limit: v.optional(v.pipe(v.number(), v.integer()), 50),
+ includeWorkspace: v.optional(v.boolean(), false),
+}), {})
+
+const returnsSchema = v.array(v.object({
+ spec: v.string(),
+ name: v.string(),
+ version: v.string(),
+ workspace: v.boolean(),
+ bytes: v.number(),
+ categories: v.record(v.string(), v.object({
+ bytes: v.number(),
+ count: v.number(),
+ })),
+}))
+
+export function reportSizesRpc(handlers: InspectorRpcHandlers) {
+ return defineRpcFunction({
+ name: 'nmi:report-sizes',
+ type: 'query',
+ jsonSerializable: true,
+ args: [argsSchema],
+ returns: returnsSchema,
+ agent: {
+ description: 'List packages sorted by install size (largest first). Read-only.',
+ },
+ handler: (async (opts = {}) => {
+ const payload = await handlers.getPayload()
+ return computeInstallSizes(payload.packages.values(), opts)
+ }) as any,
+ })
+}
diff --git a/packages/node-modules-inspector/src/shared/reports/dto.ts b/packages/node-modules-inspector/src/shared/reports/dto.ts
new file mode 100644
index 0000000..4454b38
--- /dev/null
+++ b/packages/node-modules-inspector/src/shared/reports/dto.ts
@@ -0,0 +1,100 @@
+import type { PackageInstallSizeInfo, PublintMessage } from 'node-modules-tools'
+import type { ParsedAuthor } from 'node-modules-tools/utils'
+import type {
+ MaintainerActionGroup,
+ MaintainerActionItem,
+} from './maintainers'
+
+export interface ReportConsumerDto {
+ spec: string
+ name: string
+ version: string
+ depth: number
+}
+
+export type MaintainerActionItemDto
+ = | MaintainerActionDepUpgradeDto
+ | MaintainerActionPublintDto
+
+export interface MaintainerActionDepUpgradeDto {
+ kind: 'dep-upgrade'
+ depName: string
+ depType: 'peer' | 'prod'
+ declaredRange: string
+ rawRange?: string
+ catalogName?: string
+ installedHighestVersion: string
+ installedHighestSpec: string
+ installedVersions: string[]
+ migratedCount: number
+ totalCount: number
+ migrationRatio: number
+}
+
+export interface MaintainerActionPublintDto {
+ kind: 'publint'
+ messages: PublintMessage[]
+ counts: { error: number, warning: number, suggestion: number }
+}
+
+export interface MaintainersGroupDto {
+ consumer: ReportConsumerDto
+ authors: ParsedAuthor[]
+ items: MaintainerActionItemDto[]
+ maxMigrationRatio: number
+ latestReleasedAt: number
+}
+
+export function toMaintainerItemDto(item: MaintainerActionItem): MaintainerActionItemDto {
+ if (item.kind === 'publint') {
+ return {
+ kind: 'publint',
+ messages: item.messages,
+ counts: item.counts,
+ }
+ }
+ return {
+ kind: 'dep-upgrade',
+ depName: item.depName,
+ depType: item.depType,
+ declaredRange: item.declaredRange,
+ rawRange: item.rawRange,
+ catalogName: item.catalogName,
+ installedHighestVersion: item.installedHighestVersion,
+ installedHighestSpec: item.installedHighest.spec,
+ installedVersions: item.installedVersions,
+ migratedCount: item.migratedCount,
+ totalCount: item.totalCount,
+ migrationRatio: item.migrationRatio,
+ }
+}
+
+export function toMaintainersGroupDto(group: MaintainerActionGroup): MaintainersGroupDto {
+ return {
+ consumer: {
+ spec: group.consumer.spec,
+ name: group.consumer.name,
+ version: group.consumer.version,
+ depth: group.depth,
+ },
+ authors: group.authors,
+ items: group.items.map(toMaintainerItemDto),
+ maxMigrationRatio: group.maxMigrationRatio,
+ latestReleasedAt: group.latestReleasedAt,
+ }
+}
+
+export interface DuplicatesEntry {
+ name: string
+ versions: string[]
+ specs: string[]
+}
+
+export interface SizesEntry {
+ spec: string
+ name: string
+ version: string
+ workspace: boolean
+ bytes: number
+ categories: PackageInstallSizeInfo['categories']
+}
diff --git a/packages/node-modules-inspector/src/shared/reports/duplicates.test.ts b/packages/node-modules-inspector/src/shared/reports/duplicates.test.ts
new file mode 100644
index 0000000..4388822
--- /dev/null
+++ b/packages/node-modules-inspector/src/shared/reports/duplicates.test.ts
@@ -0,0 +1,64 @@
+import type { PackageNode } from 'node-modules-tools'
+import { describe, expect, it } from 'vitest'
+import { computeDuplicates } from './duplicates'
+
+function pkg(name: string, version: string): PackageNode {
+ return {
+ name,
+ version,
+ spec: `${name}@${version}`,
+ } as unknown as PackageNode
+}
+
+describe('computeDuplicates', () => {
+ it('returns empty when no duplicates', () => {
+ expect(computeDuplicates([pkg('a', '1.0.0'), pkg('b', '1.0.0')])).toEqual([])
+ })
+
+ it('groups by name and sorts versions ascending', () => {
+ const result = computeDuplicates([
+ pkg('a', '2.0.0'),
+ pkg('a', '1.0.0'),
+ pkg('b', '1.0.0'),
+ ])
+ expect(result).toEqual([
+ { name: 'a', versions: ['1.0.0', '2.0.0'], specs: ['a@1.0.0', 'a@2.0.0'] },
+ ])
+ })
+
+ it('sorts entries by version count descending', () => {
+ const result = computeDuplicates([
+ pkg('a', '1.0.0'),
+ pkg('a', '2.0.0'),
+ pkg('b', '1.0.0'),
+ pkg('b', '2.0.0'),
+ pkg('b', '3.0.0'),
+ ])
+ expect(result.map(e => e.name)).toEqual(['b', 'a'])
+ })
+
+ it('respects minVersions threshold', () => {
+ const packages = [
+ pkg('a', '1.0.0'),
+ pkg('a', '2.0.0'),
+ pkg('b', '1.0.0'),
+ pkg('b', '2.0.0'),
+ pkg('b', '3.0.0'),
+ ]
+ expect(computeDuplicates(packages, { minVersions: 3 })).toEqual([
+ { name: 'b', versions: ['1.0.0', '2.0.0', '3.0.0'], specs: ['b@1.0.0', 'b@2.0.0', 'b@3.0.0'] },
+ ])
+ })
+
+ it('respects limit', () => {
+ const packages = [
+ pkg('a', '1.0.0'),
+ pkg('a', '2.0.0'),
+ pkg('b', '1.0.0'),
+ pkg('b', '2.0.0'),
+ pkg('c', '1.0.0'),
+ pkg('c', '2.0.0'),
+ ]
+ expect(computeDuplicates(packages, { limit: 2 })).toHaveLength(2)
+ })
+})
diff --git a/packages/node-modules-inspector/src/shared/reports/duplicates.ts b/packages/node-modules-inspector/src/shared/reports/duplicates.ts
new file mode 100644
index 0000000..0cf6380
--- /dev/null
+++ b/packages/node-modules-inspector/src/shared/reports/duplicates.ts
@@ -0,0 +1,43 @@
+import type { PackageNode } from 'node-modules-tools'
+import type { DuplicatesEntry } from './dto'
+import { compareSemver } from '../semver'
+
+export interface ComputeDuplicatesOptions {
+ /** Only include packages installed at this many versions or more. Default `2`. */
+ minVersions?: number
+ /** Cap the number of returned entries. */
+ limit?: number
+}
+
+export function computeDuplicates(
+ packages: Iterable,
+ options: ComputeDuplicatesOptions = {},
+): DuplicatesEntry[] {
+ const minVersions = options.minVersions ?? 2
+
+ const byName = new Map()
+ for (const pkg of packages) {
+ let bucket = byName.get(pkg.name)
+ if (!bucket) {
+ bucket = []
+ byName.set(pkg.name, bucket)
+ }
+ bucket.push(pkg)
+ }
+
+ const entries: DuplicatesEntry[] = []
+ for (const [name, pkgs] of byName) {
+ if (pkgs.length < minVersions)
+ continue
+ const sorted = pkgs.slice().sort((a, b) => compareSemver(a.version, b.version))
+ entries.push({
+ name,
+ versions: sorted.map(p => p.version),
+ specs: sorted.map(p => p.spec),
+ })
+ }
+
+ entries.sort((a, b) => (b.versions.length - a.versions.length) || a.name.localeCompare(b.name))
+
+ return options.limit ? entries.slice(0, options.limit) : entries
+}
diff --git a/packages/node-modules-inspector/src/shared/reports/maintainers.test.ts b/packages/node-modules-inspector/src/shared/reports/maintainers.test.ts
new file mode 100644
index 0000000..1ebbe40
--- /dev/null
+++ b/packages/node-modules-inspector/src/shared/reports/maintainers.test.ts
@@ -0,0 +1,170 @@
+import type { PackageNode } from 'node-modules-tools'
+import { describe, expect, it } from 'vitest'
+import {
+ computeMaintainerActions,
+ groupMaintainerActions,
+} from './maintainers'
+
+interface PkgInput {
+ name: string
+ version: string
+ depth?: number
+ dependencies?: Record
+ peerDependencies?: Record
+}
+
+function pkg(input: PkgInput): PackageNode {
+ return {
+ name: input.name,
+ version: input.version,
+ spec: `${input.name}@${input.version}`,
+ depth: input.depth ?? 1,
+ resolved: {
+ packageJson: {
+ dependencies: input.dependencies,
+ peerDependencies: input.peerDependencies,
+ },
+ },
+ } as unknown as PackageNode
+}
+
+function buildVersions(packages: PackageNode[]): Map {
+ const map = new Map()
+ for (const p of packages) {
+ let bucket = map.get(p.name)
+ if (!bucket) {
+ bucket = []
+ map.set(p.name, bucket)
+ }
+ bucket.push(p)
+ }
+ return map
+}
+
+describe('computeMaintainerActions', () => {
+ it('emits dep-upgrade when installed highest is gtr than declared range', () => {
+ const dep1 = pkg({ name: 'dep', version: '1.0.0' })
+ const dep2 = pkg({ name: 'dep', version: '2.0.0' })
+ const consumer = pkg({
+ name: 'consumer',
+ version: '1.0.0',
+ dependencies: { dep: '^1.0.0' },
+ })
+ const all = [consumer, dep1, dep2]
+ const items = computeMaintainerActions({
+ packages: all,
+ versions: buildVersions(all),
+ })
+ const upgrades = items.filter(i => i.kind === 'dep-upgrade')
+ expect(upgrades).toHaveLength(1)
+ expect(upgrades[0]).toMatchObject({
+ kind: 'dep-upgrade',
+ depName: 'dep',
+ depType: 'prod',
+ declaredRange: '^1.0.0',
+ installedHighestVersion: '2.0.0',
+ })
+ })
+
+ it('does not emit when range is satisfied', () => {
+ const dep = pkg({ name: 'dep', version: '1.5.0' })
+ const consumer = pkg({
+ name: 'consumer',
+ version: '1.0.0',
+ dependencies: { dep: '^1.0.0' },
+ })
+ const all = [consumer, dep]
+ const items = computeMaintainerActions({
+ packages: all,
+ versions: buildVersions(all),
+ })
+ expect(items).toEqual([])
+ })
+
+ it('resolves catalog: ranges', () => {
+ const dep1 = pkg({ name: 'dep', version: '1.0.0' })
+ const dep2 = pkg({ name: 'dep', version: '2.0.0' })
+ const consumer = pkg({
+ name: 'consumer',
+ version: '1.0.0',
+ dependencies: { dep: 'catalog:deps' },
+ })
+ const all = [consumer, dep1, dep2]
+ const items = computeMaintainerActions({
+ packages: all,
+ versions: buildVersions(all),
+ catalogs: { deps: { dep: '^1.0.0' } },
+ })
+ expect(items).toHaveLength(1)
+ expect(items[0]).toMatchObject({
+ kind: 'dep-upgrade',
+ declaredRange: '^1.0.0',
+ rawRange: 'catalog:deps',
+ catalogName: 'deps',
+ })
+ })
+
+ it('emits publint action when publint messages are present', () => {
+ const consumer = pkg({ name: 'consumer', version: '1.0.0' })
+ ;(consumer as any).resolved.publint = [
+ { type: 'error', code: 'X', message: 'm', args: {} },
+ { type: 'warning', code: 'Y', message: 'm', args: {} },
+ ]
+ const items = computeMaintainerActions({
+ packages: [consumer],
+ versions: buildVersions([consumer]),
+ })
+ expect(items).toHaveLength(1)
+ expect(items[0]).toMatchObject({
+ kind: 'publint',
+ counts: { error: 1, warning: 1, suggestion: 0 },
+ })
+ })
+})
+
+describe('groupMaintainerActions', () => {
+ it('groups items by consumer and applies includePublint filter', () => {
+ const dep2 = pkg({ name: 'dep', version: '2.0.0' })
+ const consumer = pkg({
+ name: 'consumer',
+ version: '1.0.0',
+ dependencies: { dep: '^1.0.0' },
+ })
+ ;(consumer as any).resolved.publint = [{ type: 'error', code: 'X', message: 'm', args: {} }]
+ const all = [consumer, dep2]
+ const items = computeMaintainerActions({
+ packages: all,
+ versions: buildVersions(all),
+ })
+
+ const withPublint = groupMaintainerActions(items, { includePublint: true })
+ expect(withPublint).toHaveLength(1)
+ expect(withPublint[0]!.items.map(i => i.kind).sort()).toEqual(['dep-upgrade', 'publint'])
+
+ const withoutPublint = groupMaintainerActions(items, { includePublint: false })
+ expect(withoutPublint).toHaveLength(1)
+ expect(withoutPublint[0]!.items.map(i => i.kind)).toEqual(['dep-upgrade'])
+ })
+
+ it('deduplicates by name keeping highest version', () => {
+ const dep2 = pkg({ name: 'dep', version: '2.0.0' })
+ const consumerV1 = pkg({
+ name: 'consumer',
+ version: '1.0.0',
+ dependencies: { dep: '^1.0.0' },
+ })
+ const consumerV2 = pkg({
+ name: 'consumer',
+ version: '2.0.0',
+ dependencies: { dep: '^1.0.0' },
+ })
+ const all = [consumerV1, consumerV2, dep2]
+ const items = computeMaintainerActions({
+ packages: all,
+ versions: buildVersions(all),
+ })
+ const groups = groupMaintainerActions(items)
+ expect(groups).toHaveLength(1)
+ expect(groups[0]!.consumer.version).toBe('2.0.0')
+ })
+})
diff --git a/packages/node-modules-inspector/src/shared/reports/maintainers.ts b/packages/node-modules-inspector/src/shared/reports/maintainers.ts
new file mode 100644
index 0000000..27f666e
--- /dev/null
+++ b/packages/node-modules-inspector/src/shared/reports/maintainers.ts
@@ -0,0 +1,376 @@
+import type { PackageNode, PublintMessage } from 'node-modules-tools'
+import type { ParsedAuthor } from 'node-modules-tools/utils'
+import semver from 'semver'
+import { compareSemver } from '../semver'
+
+export function authorKey(author: ParsedAuthor): string {
+ return author.type === 'github' ? `@${author.github}` : author.name
+}
+
+interface BaseAction {
+ consumer: PackageNode
+ depth: number
+ key: string
+}
+
+export interface DepUpgradeAction extends BaseAction {
+ kind: 'dep-upgrade'
+ depName: string
+ depType: 'peer' | 'prod'
+ /** The effective semver range (after resolving any catalog reference). */
+ declaredRange: string
+ /** The raw range as written in package.json, when it differs (e.g. `catalog:deps`). */
+ rawRange?: string
+ /** Catalog name when the raw range was a catalog reference. */
+ catalogName?: string
+ installedHighestVersion: string
+ installedHighest: PackageNode
+ installedVersions: string[]
+ migratedCount: number
+ totalCount: number
+ migrationRatio: number
+}
+
+export interface PublintAction extends BaseAction {
+ kind: 'publint'
+ messages: PublintMessage[]
+ counts: { error: number, warning: number, suggestion: number }
+}
+
+export type MaintainerActionItem = DepUpgradeAction | PublintAction
+
+export type MaintainerActionSortMode = 'depth' | 'migration' | 'latest'
+
+export interface MaintainerActionGroup {
+ consumer: PackageNode
+ depth: number
+ authors: ParsedAuthor[]
+ items: MaintainerActionItem[]
+ maxMigrationRatio: number
+ latestReleasedAt: number
+}
+
+export interface MaintainerActionAuthorEntry {
+ author: ParsedAuthor
+ count: number
+}
+
+interface DepStats {
+ highestVersion: string
+ highestPkg: PackageNode
+ versions: string[]
+ migrated: number
+ behind: number
+}
+
+const NON_SEMVER_PREFIXES = ['workspace:', 'link:', 'file:', 'npm:', 'git+', 'git:', 'http:', 'https:', 'github:']
+
+function resolveCatalogRange(
+ range: string,
+ depName: string,
+ catalogs: Record> | undefined,
+): string | undefined {
+ if (!range.startsWith('catalog:'))
+ return range
+ if (!catalogs)
+ return undefined
+ const name = range.slice('catalog:'.length) || 'default'
+ return catalogs[name]?.[depName]
+}
+
+function isPlainSemverRange(range: string | undefined): range is string {
+ if (!range || range === '*' || range === 'latest' || range === 'x')
+ return false
+ return !NON_SEMVER_PREFIXES.some(p => range.startsWith(p))
+}
+
+function safeSatisfies(version: string, range: string) {
+ try {
+ return semver.satisfies(version, range)
+ }
+ catch {
+ return null
+ }
+}
+
+function safeGtr(version: string, range: string) {
+ try {
+ return semver.gtr(version, range)
+ }
+ catch {
+ return null
+ }
+}
+
+function isStable(version: string) {
+ return semver.prerelease(version) === null
+}
+
+function getPublintMessagesFor(
+ pkg: PackageNode,
+ fallback?: (pkg: PackageNode) => PublintMessage[] | null,
+): PublintMessage[] | null {
+ if (pkg.resolved.publint)
+ return pkg.resolved.publint
+ return fallback?.(pkg) ?? null
+}
+
+export interface MaintainerActionsInput {
+ packages: Iterable
+ versions: Map
+ catalogs?: Record>
+ publintFallback?: (pkg: PackageNode) => PublintMessage[] | null
+}
+
+export function computeMaintainerActions(input: MaintainerActionsInput): MaintainerActionItem[] {
+ const { catalogs, publintFallback, versions } = input
+ const packages = Array.from(input.packages)
+ const stats = new Map()
+
+ function getStats(depName: string): DepStats | null {
+ if (stats.has(depName))
+ return stats.get(depName)!
+ const installed = versions.get(depName)
+ if (!installed?.length) {
+ stats.set(depName, null)
+ return null
+ }
+ const sortedAll = installed.slice().sort((a, b) => compareSemver(a.version, b.version))
+ const stable = sortedAll.filter(p => isStable(p.version))
+ if (!stable.length) {
+ stats.set(depName, null)
+ return null
+ }
+ const highestPkg = stable.at(-1)!
+ const entry: DepStats = {
+ highestVersion: highestPkg.version,
+ highestPkg,
+ versions: sortedAll.map(p => p.version),
+ migrated: 0,
+ behind: 0,
+ }
+ stats.set(depName, entry)
+ return entry
+ }
+
+ for (const consumer of packages) {
+ const pj = consumer.resolved.packageJson
+ const blocks = [pj.peerDependencies, pj.dependencies]
+ for (const block of blocks) {
+ if (!block)
+ continue
+ for (const [depName, rawRange] of Object.entries(block)) {
+ const range = resolveCatalogRange(rawRange, depName, catalogs)
+ if (!isPlainSemverRange(range))
+ continue
+ const entry = getStats(depName)
+ if (!entry)
+ continue
+ if (safeSatisfies(entry.highestVersion, range)) {
+ entry.migrated++
+ }
+ else if (safeGtr(entry.highestVersion, range)) {
+ entry.behind++
+ }
+ // else: declared range is ahead of highest stable — ignore (not part of this cohort)
+ }
+ }
+ }
+
+ const items: MaintainerActionItem[] = []
+
+ for (const consumer of packages) {
+ const pj = consumer.resolved.packageJson
+ const blocks: Array<[Record | undefined, 'peer' | 'prod']> = [
+ [pj.peerDependencies, 'peer'],
+ [pj.dependencies, 'prod'],
+ ]
+ for (const [block, depType] of blocks) {
+ if (!block)
+ continue
+ for (const [depName, rawRange] of Object.entries(block)) {
+ const declaredRange = resolveCatalogRange(rawRange, depName, catalogs)
+ if (!isPlainSemverRange(declaredRange))
+ continue
+ const entry = stats.get(depName)
+ if (!entry)
+ continue
+ if (safeGtr(entry.highestVersion, declaredRange) !== true)
+ continue
+ // Skip when consumer and dep share the same repository (monorepo siblings).
+ const consumerRepo = consumer.resolved.repository?.url
+ const depRepo = entry.highestPkg.resolved.repository?.url
+ if (consumerRepo && depRepo && consumerRepo === depRepo)
+ continue
+ const total = entry.migrated + entry.behind
+ const catalogName = rawRange.startsWith('catalog:')
+ ? (rawRange.slice('catalog:'.length) || 'default')
+ : undefined
+ items.push({
+ kind: 'dep-upgrade',
+ consumer,
+ depName,
+ depType,
+ declaredRange,
+ rawRange: rawRange === declaredRange ? undefined : rawRange,
+ catalogName,
+ installedHighestVersion: entry.highestVersion,
+ installedHighest: entry.highestPkg,
+ installedVersions: entry.versions,
+ migratedCount: entry.migrated,
+ totalCount: total,
+ migrationRatio: total ? entry.migrated / total : 0,
+ depth: consumer.depth,
+ key: `${consumer.spec}::${depType}::${depName}`,
+ })
+ }
+ }
+ }
+
+ for (const consumer of packages) {
+ const messages = getPublintMessagesFor(consumer, publintFallback)
+ if (!messages?.length)
+ continue
+ const counts = { error: 0, warning: 0, suggestion: 0 }
+ for (const m of messages)
+ counts[m.type]++
+ items.push({
+ kind: 'publint',
+ consumer,
+ depth: consumer.depth,
+ key: `${consumer.spec}::publint`,
+ messages,
+ counts,
+ })
+ }
+
+ return items
+}
+
+function getConsumerAuthors(pkg: PackageNode): ParsedAuthor[] {
+ const list = pkg.resolved.authors
+ if (!list?.length)
+ return []
+ return list.filter(a => a.type === 'github' || !!a.name?.trim())
+}
+
+function actionSortKey(a: MaintainerActionItem, b: MaintainerActionItem): number {
+ if (a.kind !== b.kind)
+ return a.kind === 'publint' ? -1 : 1
+ if (a.kind === 'dep-upgrade' && b.kind === 'dep-upgrade') {
+ return (b.migrationRatio - a.migrationRatio)
+ || a.depName.localeCompare(b.depName)
+ }
+ return 0
+}
+
+function defaultPublishTimeOf(pkg: PackageNode): Date | undefined {
+ const t = pkg.resolved.npmMeta?.publishedAt
+ return t ? new Date(t) : undefined
+}
+
+export interface MaintainerGroupOptions {
+ sort?: MaintainerActionSortMode
+ authorFilter?: string[]
+ includePublint?: boolean
+ latestOnly?: boolean
+ /** Resolve the latest published version for the package; required for `latestOnly` filter. */
+ latestVersionOf?: (pkg: PackageNode) => string | undefined
+ /** Resolve the publish time for the package; defaults to `pkg.resolved.npmMeta.publishedAt`. */
+ publishTimeOf?: (pkg: PackageNode) => Date | undefined | null
+}
+
+export function groupMaintainerActions(
+ items: MaintainerActionItem[],
+ options: MaintainerGroupOptions = {},
+): MaintainerActionGroup[] {
+ const publishTimeOf = options.publishTimeOf ?? defaultPublishTimeOf
+ const byConsumer = new Map()
+ for (const item of items) {
+ let group = byConsumer.get(item.consumer.spec)
+ if (!group) {
+ group = {
+ consumer: item.consumer,
+ depth: item.consumer.depth,
+ authors: getConsumerAuthors(item.consumer),
+ items: [],
+ maxMigrationRatio: 0,
+ latestReleasedAt: publishTimeOf(item.consumer)?.getTime() ?? 0,
+ }
+ byConsumer.set(item.consumer.spec, group)
+ }
+ group.items.push(item)
+ if (item.kind === 'dep-upgrade' && item.migrationRatio > group.maxMigrationRatio)
+ group.maxMigrationRatio = item.migrationRatio
+ }
+
+ for (const group of byConsumer.values())
+ group.items.sort(actionSortKey)
+
+ const byName = new Map()
+ for (const g of byConsumer.values()) {
+ const cur = byName.get(g.consumer.name)
+ if (!cur || compareSemver(cur.consumer.version, g.consumer.version) < 0)
+ byName.set(g.consumer.name, g)
+ }
+ let groups = Array.from(byName.values())
+
+ const selected = options.authorFilter
+ if (selected?.length) {
+ const set = new Set(selected)
+ groups = groups.filter(g => g.authors.some(a => set.has(authorKey(a))))
+ }
+
+ if (options.includePublint === false) {
+ groups = groups
+ .map((g) => {
+ const items = g.items.filter(i => i.kind !== 'publint')
+ return items.length === g.items.length ? g : { ...g, items }
+ })
+ .filter(g => g.items.length > 0)
+ }
+
+ if (options.latestOnly && options.latestVersionOf) {
+ const latestVersionOf = options.latestVersionOf
+ groups = groups.filter((g) => {
+ const latest = latestVersionOf(g.consumer)
+ if (!latest)
+ return true
+ try {
+ return semver.major(g.consumer.version) === semver.major(latest)
+ }
+ catch {
+ return true
+ }
+ })
+ }
+
+ const mode = options.sort ?? 'depth'
+ const nameTie = (a: MaintainerActionGroup, b: MaintainerActionGroup) =>
+ a.consumer.name.localeCompare(b.consumer.name)
+ const cmp: (a: MaintainerActionGroup, b: MaintainerActionGroup) => number
+ = mode === 'migration'
+ ? (a, b) => (b.maxMigrationRatio - a.maxMigrationRatio) || (a.depth - b.depth) || nameTie(a, b)
+ : mode === 'latest'
+ ? (a, b) => (b.latestReleasedAt - a.latestReleasedAt) || (a.depth - b.depth) || nameTie(a, b)
+ : (a, b) => (a.depth - b.depth) || (b.maxMigrationRatio - a.maxMigrationRatio) || nameTie(a, b)
+ return groups.sort(cmp)
+}
+
+export function collectMaintainerActionAuthors(
+ groups: MaintainerActionGroup[],
+): MaintainerActionAuthorEntry[] {
+ const map = new Map()
+ for (const group of groups) {
+ for (const author of group.authors) {
+ const key = authorKey(author)
+ const entry = map.get(key)
+ if (entry)
+ entry.count++
+ else
+ map.set(key, { author, count: 1 })
+ }
+ }
+ return Array.from(map.values())
+ .sort((a, b) => (b.count - a.count) || authorKey(a.author).localeCompare(authorKey(b.author)))
+}
diff --git a/packages/node-modules-inspector/src/shared/reports/sizes.test.ts b/packages/node-modules-inspector/src/shared/reports/sizes.test.ts
new file mode 100644
index 0000000..c583d4e
--- /dev/null
+++ b/packages/node-modules-inspector/src/shared/reports/sizes.test.ts
@@ -0,0 +1,71 @@
+import type { PackageNode } from 'node-modules-tools'
+import { describe, expect, it } from 'vitest'
+import { computeInstallSizes } from './sizes'
+
+interface PkgInput {
+ name: string
+ version: string
+ bytes?: number
+ workspace?: boolean
+}
+
+function pkg(input: PkgInput): PackageNode {
+ return {
+ name: input.name,
+ version: input.version,
+ spec: `${input.name}@${input.version}`,
+ workspace: input.workspace,
+ resolved: {
+ installSize: input.bytes != null
+ ? { bytes: input.bytes, categories: { js: { bytes: input.bytes, count: 1 } } }
+ : undefined,
+ },
+ } as unknown as PackageNode
+}
+
+describe('computeInstallSizes', () => {
+ it('filters out packages without installSize', () => {
+ const result = computeInstallSizes([
+ pkg({ name: 'a', version: '1.0.0', bytes: 100 }),
+ pkg({ name: 'b', version: '1.0.0' }),
+ ])
+ expect(result.map(e => e.name)).toEqual(['a'])
+ })
+
+ it('sorts by bytes descending', () => {
+ const result = computeInstallSizes([
+ pkg({ name: 'a', version: '1.0.0', bytes: 100 }),
+ pkg({ name: 'b', version: '1.0.0', bytes: 500 }),
+ pkg({ name: 'c', version: '1.0.0', bytes: 200 }),
+ ])
+ expect(result.map(e => e.name)).toEqual(['b', 'c', 'a'])
+ })
+
+ it('excludes workspace packages by default', () => {
+ const result = computeInstallSizes([
+ pkg({ name: 'a', version: '1.0.0', bytes: 100 }),
+ pkg({ name: 'ws', version: '1.0.0', bytes: 100, workspace: true }),
+ ])
+ expect(result.map(e => e.name)).toEqual(['a'])
+ })
+
+ it('includes workspace packages when includeWorkspace=true', () => {
+ const result = computeInstallSizes([
+ pkg({ name: 'a', version: '1.0.0', bytes: 100 }),
+ pkg({ name: 'ws', version: '1.0.0', bytes: 100, workspace: true }),
+ ], { includeWorkspace: true })
+ expect(result.map(e => e.name).sort()).toEqual(['a', 'ws'])
+ })
+
+ it('respects limit', () => {
+ const result = computeInstallSizes(
+ [
+ pkg({ name: 'a', version: '1.0.0', bytes: 100 }),
+ pkg({ name: 'b', version: '1.0.0', bytes: 200 }),
+ pkg({ name: 'c', version: '1.0.0', bytes: 300 }),
+ ],
+ { limit: 2 },
+ )
+ expect(result).toHaveLength(2)
+ })
+})
diff --git a/packages/node-modules-inspector/src/shared/reports/sizes.ts b/packages/node-modules-inspector/src/shared/reports/sizes.ts
new file mode 100644
index 0000000..6737ca6
--- /dev/null
+++ b/packages/node-modules-inspector/src/shared/reports/sizes.ts
@@ -0,0 +1,37 @@
+import type { PackageNode } from 'node-modules-tools'
+import type { SizesEntry } from './dto'
+
+export interface ComputeSizesOptions {
+ /** Cap the number of returned entries. Default `50`. */
+ limit?: number
+ /** Include workspace packages (default: false — workspace packages have no meaningful install size). */
+ includeWorkspace?: boolean
+}
+
+export function computeInstallSizes(
+ packages: Iterable,
+ options: ComputeSizesOptions = {},
+): SizesEntry[] {
+ const includeWorkspace = options.includeWorkspace ?? false
+ const limit = options.limit ?? 50
+
+ const entries: SizesEntry[] = []
+ for (const pkg of packages) {
+ const info = pkg.resolved.installSize
+ if (!info?.bytes)
+ continue
+ if (!includeWorkspace && pkg.workspace)
+ continue
+ entries.push({
+ spec: pkg.spec,
+ name: pkg.name,
+ version: pkg.version,
+ workspace: pkg.workspace === true,
+ bytes: info.bytes,
+ categories: info.categories,
+ })
+ }
+
+ entries.sort((a, b) => b.bytes - a.bytes)
+ return limit > 0 ? entries.slice(0, limit) : entries
+}
diff --git a/packages/node-modules-inspector/src/shared/semver.ts b/packages/node-modules-inspector/src/shared/semver.ts
new file mode 100644
index 0000000..48a79fc
--- /dev/null
+++ b/packages/node-modules-inspector/src/shared/semver.ts
@@ -0,0 +1,13 @@
+import semver from 'semver'
+
+export function compareSemver(a: string, b: string): number {
+ if (a === b)
+ return 0
+ try {
+ return semver.compare(a, b)
+ }
+ catch (e) {
+ console.error('Failed to compare semver ', e)
+ return 0
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 72858ab..9d14bc9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -40,6 +40,9 @@ catalogs:
specifier: ^8.0.13
version: 8.0.13
deps:
+ '@modelcontextprotocol/sdk':
+ specifier: ^1.29.0
+ version: 1.29.0
ansis:
specifier: ^4.3.0
version: 4.3.0
@@ -100,6 +103,9 @@ catalogs:
unstorage:
specifier: ^1.17.5
version: 1.17.5
+ valibot:
+ specifier: ^1.4.1
+ version: 1.4.1
dev:
'@antfu/ni':
specifier: ^30.1.0
@@ -217,6 +223,9 @@ catalogs:
'@playwright/test':
specifier: ^1.60.0
version: 1.60.0
+ '@valibot/to-json-schema':
+ specifier: ^1.7.0
+ version: 1.7.0
mlly:
specifier: ^1.8.2
version: 1.8.2
@@ -328,7 +337,7 @@ importers:
version: 11.1.0
devframe:
specifier: catalog:deps
- version: 0.5.2(typescript@6.0.3)
+ version: 0.5.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(typescript@6.0.3)
esbuild:
specifier: catalog:bundling
version: 0.28.0
@@ -395,6 +404,9 @@ importers:
packages/node-modules-inspector:
dependencies:
+ '@modelcontextprotocol/sdk':
+ specifier: catalog:deps
+ version: 1.29.0(zod@4.4.3)
ansis:
specifier: catalog:deps
version: 4.3.0
@@ -403,7 +415,7 @@ importers:
version: 7.0.0
devframe:
specifier: catalog:deps
- version: 0.5.2(typescript@6.0.3)
+ version: 0.5.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(typescript@6.0.3)
fast-npm-meta:
specifier: catalog:deps
version: 1.5.1
@@ -449,6 +461,9 @@ importers:
unstorage:
specifier: catalog:deps
version: 1.17.5(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1)
+ valibot:
+ specifier: catalog:deps
+ version: 1.4.1(typescript@6.0.3)
devDependencies:
'@types/semver':
specifier: catalog:types
@@ -456,6 +471,9 @@ importers:
'@unocss/nuxt':
specifier: catalog:bundling
version: 66.6.8(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(magicast@0.5.2)(vite@8.0.13(@types/node@22.13.9)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.4))(webpack@5.98.0(esbuild@0.28.0))
+ '@valibot/to-json-schema':
+ specifier: catalog:testing
+ version: 1.7.0(valibot@1.4.1(typescript@6.0.3))
'@vueuse/nuxt':
specifier: catalog:bundling
version: 14.3.0(magicast@0.5.2)(nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.13.9)(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(eslint@10.4.0(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.1)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.1)(rollup@4.60.4))(rollup@4.60.4)(terser@5.39.0)(typescript@6.0.3)(vite@8.0.13(@types/node@22.13.9)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.4))(vue-tsc@3.3.0(typescript@6.0.3))(yaml@2.8.4))(vue@3.5.33(typescript@6.0.3))
@@ -1372,6 +1390,12 @@ packages:
resolution: {integrity: sha512-7bQW+gkKa2kKZPeJf6+c6gFK9ARxQfn+FKy9ScTBppyKRWH2KzsmweXUoklqeEiHiNVWaeP5csIdsNq6w7QhzA==}
engines: {node: '>=12.20'}
+ '@hono/node-server@1.19.14':
+ resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
+ engines: {node: '>=18.14.1'}
+ peerDependencies:
+ hono: ^4
+
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -1457,6 +1481,16 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ '@modelcontextprotocol/sdk@1.29.0':
+ resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@cfworker/json-schema': ^4.1.1
+ zod: ^3.25 || ^4.0
+ peerDependenciesMeta:
+ '@cfworker/json-schema':
+ optional: true
+
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -3680,6 +3714,10 @@ packages:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
+ accepts@2.0.0:
+ resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
+ engines: {node: '>= 0.6'}
+
acorn-import-attributes@1.9.5:
resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==}
peerDependencies:
@@ -3707,6 +3745,14 @@ packages:
ajv:
optional: true
+ ajv-formats@3.0.1:
+ resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+
ajv-keywords@5.1.0:
resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==}
peerDependencies:
@@ -3825,6 +3871,10 @@ packages:
birpc@4.0.0:
resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==}
+ body-parser@2.2.2:
+ resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
+ engines: {node: '>=18'}
+
bole@5.0.17:
resolution: {integrity: sha512-q6F82qEcUQTP178ZEY4WI1zdVzxy+fOnSF1dOMyC16u1fc0c24YrDPbgxA6N5wGHayCUdSBWsF8Oy7r2AKtQdA==}
@@ -3879,6 +3929,10 @@ packages:
peerDependencies:
esbuild: '>=0.18'
+ bytes@3.1.2:
+ resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
+ engines: {node: '>= 0.8'}
+
c12@3.3.4:
resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==}
peerDependencies:
@@ -3895,6 +3949,14 @@ packages:
resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==}
engines: {node: '>=20.19.0'}
+ call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bound@1.0.4:
+ resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+ engines: {node: '>= 0.4'}
+
caniuse-api@3.0.0:
resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
@@ -4009,6 +4071,18 @@ packages:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
+ content-disposition@1.1.0:
+ resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
+ engines: {node: '>=18'}
+
+ content-type@1.0.5:
+ resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+ engines: {node: '>= 0.6'}
+
+ content-type@2.0.0:
+ resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
+ engines: {node: '>=18'}
+
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -4021,12 +4095,24 @@ packages:
cookie-es@3.1.1:
resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
+ cookie-signature@1.2.2:
+ resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
+ engines: {node: '>=6.6.0'}
+
+ cookie@0.7.2:
+ resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+ engines: {node: '>= 0.6'}
+
core-js-compat@3.49.0:
resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
+ cors@2.8.6:
+ resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
+ engines: {node: '>= 0.10'}
+
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
@@ -4347,6 +4433,10 @@ packages:
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'}
+ dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+
duplexer@0.1.2:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
@@ -4397,12 +4487,24 @@ packages:
errx@0.1.0:
resolution: {integrity: sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==}
+ es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-module-lexer@2.0.0:
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
+ es-object-atoms@1.1.2:
+ resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
+ engines: {node: '>= 0.4'}
+
esbuild@0.25.12:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'}
@@ -4724,6 +4826,14 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
+ eventsource-parser@3.1.0:
+ resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
+ engines: {node: '>=18.0.0'}
+
+ eventsource@3.0.7:
+ resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
+ engines: {node: '>=18.0.0'}
+
execa@5.1.1:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
@@ -4736,6 +4846,16 @@ packages:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
+ express-rate-limit@8.5.2:
+ resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==}
+ engines: {node: '>= 16'}
+ peerDependencies:
+ express: '>= 4.11'
+
+ express@5.2.1:
+ resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
+ engines: {node: '>= 18'}
+
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
@@ -4804,6 +4924,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
+ finalhandler@2.1.1:
+ resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
+ engines: {node: '>= 18.0.0'}
+
find-up-simple@1.0.1:
resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==}
engines: {node: '>=18'}
@@ -4843,6 +4967,10 @@ packages:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
+ forwarded@0.2.0:
+ resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
+ engines: {node: '>= 0.6'}
+
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
@@ -4886,6 +5014,10 @@ packages:
resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==}
engines: {node: '>=18'}
+ get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+
get-npm-tarball-url@2.1.0:
resolution: {integrity: sha512-ro+DiMu5DXgRBabqXupW38h7WPZ9+Ad8UjwhvsmmN8w1sU7ab0nzAXvVZ4kqYg57OrqomRtJvepX5/xvFKNtjA==}
engines: {node: '>=12.17'}
@@ -4893,6 +5025,10 @@ packages:
get-port-please@3.2.0:
resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==}
+ get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+
get-stream@6.0.1:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'}
@@ -4958,6 +5094,10 @@ packages:
globrex@0.1.2:
resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
+ gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -4990,10 +5130,18 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
+ has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
+ hono@4.12.23:
+ resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==}
+ engines: {node: '>=16.9.0'}
+
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
@@ -5015,6 +5163,10 @@ packages:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
+ http-errors@2.0.1:
+ resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
+ engines: {node: '>= 0.8'}
+
http-shutdown@1.2.2:
resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
@@ -5038,6 +5190,10 @@ packages:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
+ iconv-lite@0.7.2:
+ resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
+ engines: {node: '>=0.10.0'}
+
idb-keyval@6.2.2:
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
@@ -5084,6 +5240,14 @@ packages:
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
engines: {node: '>=12.22.0'}
+ ip-address@10.2.0:
+ resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
+ engines: {node: '>= 12'}
+
+ ipaddr.js@1.9.1:
+ resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
+ engines: {node: '>= 0.10'}
+
iron-webcrypto@1.2.1:
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
@@ -5151,6 +5315,9 @@ packages:
resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==}
engines: {node: '>=8'}
+ is-promise@4.0.0:
+ resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
+
is-reference@1.2.1:
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
@@ -5199,6 +5366,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
+ jose@6.2.3:
+ resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -5241,6 +5411,9 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+ json-schema-typed@8.0.2:
+ resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
+
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -5455,6 +5628,10 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
+ math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+
mdast-util-find-and-replace@3.0.2:
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
@@ -5500,6 +5677,14 @@ packages:
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+ media-typer@1.1.0:
+ resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
+ engines: {node: '>= 0.8'}
+
+ merge-descriptors@2.0.0:
+ resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
+ engines: {node: '>=18'}
+
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -5738,6 +5923,10 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ negotiator@1.0.0:
+ resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
+ engines: {node: '>= 0.6'}
+
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
@@ -5835,9 +6024,17 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+ engines: {node: '>=0.10.0'}
+
object-deep-merge@2.0.0:
resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==}
+ object-inspect@1.13.4:
+ resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+ engines: {node: '>= 0.4'}
+
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
@@ -5858,6 +6055,9 @@ packages:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
+ once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
onetime@5.1.2:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
@@ -5976,6 +6176,9 @@ packages:
resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
engines: {node: 20 || >=22}
+ path-to-regexp@8.4.2:
+ resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
+
pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
@@ -5996,6 +6199,10 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
+ pkce-challenge@5.0.1:
+ resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
+ engines: {node: '>=16.20.0'}
+
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
@@ -6221,6 +6428,10 @@ packages:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
+ proxy-addr@2.0.7:
+ resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
+ engines: {node: '>= 0.10'}
+
publint@0.3.21:
resolution: {integrity: sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==}
engines: {node: '>=18'}
@@ -6230,6 +6441,10 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ qs@6.15.2:
+ resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
+ engines: {node: '>=0.6'}
+
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -6249,6 +6464,10 @@ packages:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
+ raw-body@3.0.2:
+ resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
+ engines: {node: '>= 0.10'}
+
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
@@ -6380,6 +6599,10 @@ packages:
rou3@0.8.1:
resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==}
+ router@2.2.0:
+ resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
+ engines: {node: '>= 18'}
+
run-applescript@7.0.0:
resolution: {integrity: sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==}
engines: {node: '>=18'}
@@ -6468,6 +6691,22 @@ packages:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'}
+ side-channel-list@1.0.1:
+ resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-map@1.0.1:
+ resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-weakmap@1.0.2:
+ resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+ engines: {node: '>= 0.4'}
+
+ side-channel@1.1.0:
+ resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
+ engines: {node: '>= 0.4'}
+
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@@ -6566,6 +6805,10 @@ packages:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
+ statuses@2.0.2:
+ resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+ engines: {node: '>= 0.8'}
+
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
@@ -6787,6 +7030,10 @@ packages:
resolution: {integrity: sha512-wQ531tuWvB6oK+pchHIu5lHe5f5wpSCqB8Kf4dWQRbOYc9HTge7JL0G4Qd44bh6QuJCccIzL3bugb8GI0MwHrg==}
engines: {node: '>=20'}
+ type-is@2.1.0:
+ resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
+ engines: {node: '>= 18'}
+
type-level-regexp@0.1.17:
resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==}
@@ -6889,6 +7136,10 @@ packages:
'@unocss/webpack':
optional: true
+ unpipe@1.0.0:
+ resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
+ engines: {node: '>= 0.8'}
+
unplugin-utils@0.2.4:
resolution: {integrity: sha512-8U/MtpkPkkk3Atewj1+RcKIjb5WBimZ/WSLhhR3w6SsIj8XJuKTacSP8g+2JhfSGw0Cb125Y+2zA/IzJZDVbhA==}
engines: {node: '>=18.12.0'}
@@ -7018,6 +7269,10 @@ packages:
resolution: {integrity: sha512-mzR0L8ZDktZjpX4OB46KT+56MAhl4EIazWP/+G/HPGuvfdaqg4YsCdtOm6U9+LOFyYDoh4dpnpxZRB9MQQns5Q==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+ vary@1.1.2:
+ resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+ engines: {node: '>= 0.8'}
+
vite-dev-rpc@1.1.0:
resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==}
peerDependencies:
@@ -7317,6 +7572,9 @@ packages:
resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==}
engines: {node: '>=18'}
+ wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
write-file-atomic@5.0.1:
resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -7414,6 +7672,14 @@ packages:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
+ zod-to-json-schema@3.25.2:
+ resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
+ peerDependencies:
+ zod: ^3.25.28 || ^4
+
+ zod@4.4.3:
+ resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
+
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -8075,6 +8341,10 @@ snapshots:
'@gwhitney/detect-indent@7.0.1': {}
+ '@hono/node-server@1.19.14(hono@4.12.23)':
+ dependencies:
+ hono: 4.12.23
+
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.6':
@@ -8180,6 +8450,28 @@ snapshots:
- encoding
- supports-color
+ '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)':
+ dependencies:
+ '@hono/node-server': 1.19.14(hono@4.12.23)
+ ajv: 8.17.1
+ ajv-formats: 3.0.1(ajv@8.17.1)
+ content-type: 1.0.5
+ cors: 2.8.6
+ cross-spawn: 7.0.6
+ eventsource: 3.0.7
+ eventsource-parser: 3.1.0
+ express: 5.2.1
+ express-rate-limit: 8.5.2(express@5.2.1)
+ hono: 4.12.23
+ jose: 6.2.3
+ json-schema-typed: 8.0.2
+ pkce-challenge: 5.0.1
+ raw-body: 3.0.2
+ zod: 4.4.3
+ zod-to-json-schema: 3.25.2(zod@4.4.3)
+ transitivePeerDependencies:
+ - supports-color
+
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.10.0
@@ -10751,6 +11043,11 @@ snapshots:
dependencies:
event-target-shim: 5.0.1
+ accepts@2.0.0:
+ dependencies:
+ mime-types: 3.0.1
+ negotiator: 1.0.0
+
acorn-import-attributes@1.9.5(acorn@8.16.0):
dependencies:
acorn: 8.16.0
@@ -10767,6 +11064,10 @@ snapshots:
optionalDependencies:
ajv: 8.17.1
+ ajv-formats@3.0.1(ajv@8.17.1):
+ optionalDependencies:
+ ajv: 8.17.1
+
ajv-keywords@5.1.0(ajv@8.17.1):
dependencies:
ajv: 8.17.1
@@ -10894,6 +11195,20 @@ snapshots:
birpc@4.0.0: {}
+ body-parser@2.2.2:
+ dependencies:
+ bytes: 3.1.2
+ content-type: 1.0.5
+ debug: 4.4.3
+ http-errors: 2.0.0
+ iconv-lite: 0.7.2
+ on-finished: 2.4.1
+ qs: 6.15.2
+ raw-body: 3.0.2
+ type-is: 2.1.0
+ transitivePeerDependencies:
+ - supports-color
+
bole@5.0.17:
dependencies:
fast-safe-stringify: 2.1.1
@@ -10957,6 +11272,8 @@ snapshots:
esbuild: 0.27.7
load-tsconfig: 0.2.5
+ bytes@3.1.2: {}
+
c12@3.3.4(magicast@0.5.2):
dependencies:
chokidar: 5.0.0
@@ -10978,6 +11295,16 @@ snapshots:
cac@7.0.0: {}
+ call-bind-apply-helpers@1.0.2:
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+
+ call-bound@1.0.4:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ get-intrinsic: 1.3.0
+
caniuse-api@3.0.0:
dependencies:
browserslist: 4.28.2
@@ -11068,6 +11395,12 @@ snapshots:
consola@3.4.2: {}
+ content-disposition@1.1.0: {}
+
+ content-type@1.0.5: {}
+
+ content-type@2.0.0: {}
+
convert-source-map@2.0.0: {}
cookie-es@1.2.3: {}
@@ -11076,12 +11409,21 @@ snapshots:
cookie-es@3.1.1: {}
+ cookie-signature@1.2.2: {}
+
+ cookie@0.7.2: {}
+
core-js-compat@3.49.0:
dependencies:
browserslist: 4.28.2
core-util-is@1.0.3: {}
+ cors@2.8.6:
+ dependencies:
+ object-assign: 4.1.1
+ vary: 1.1.2
+
crc-32@1.2.2: {}
crc32-stream@6.0.0:
@@ -11418,7 +11760,7 @@ snapshots:
devalue@5.7.1: {}
- devframe@0.5.2(typescript@6.0.3):
+ devframe@0.5.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(typescript@6.0.3):
dependencies:
'@valibot/to-json-schema': 1.7.0(valibot@1.4.1(typescript@6.0.3))
birpc: 4.0.0
@@ -11429,6 +11771,8 @@ snapshots:
pathe: 2.0.3
valibot: 1.4.1(typescript@6.0.3)
ws: 8.21.0
+ optionalDependencies:
+ '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
transitivePeerDependencies:
- bufferutil
- crossws
@@ -11467,6 +11811,12 @@ snapshots:
dotenv@17.4.2: {}
+ dunder-proto@1.0.1:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
duplexer@0.1.2: {}
eastasianwidth@0.2.0: {}
@@ -11502,10 +11852,18 @@ snapshots:
errx@0.1.0: {}
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
es-module-lexer@1.7.0: {}
es-module-lexer@2.0.0: {}
+ es-object-atoms@1.1.2:
+ dependencies:
+ es-errors: 1.3.0
+
esbuild@0.25.12:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12
@@ -11976,6 +12334,12 @@ snapshots:
events@3.3.0: {}
+ eventsource-parser@3.1.0: {}
+
+ eventsource@3.0.7:
+ dependencies:
+ eventsource-parser: 3.1.0
+
execa@5.1.1:
dependencies:
cross-spawn: 7.0.6
@@ -12002,6 +12366,44 @@ snapshots:
expect-type@1.3.0: {}
+ express-rate-limit@8.5.2(express@5.2.1):
+ dependencies:
+ express: 5.2.1
+ ip-address: 10.2.0
+
+ express@5.2.1:
+ dependencies:
+ accepts: 2.0.0
+ body-parser: 2.2.2
+ content-disposition: 1.1.0
+ content-type: 1.0.5
+ cookie: 0.7.2
+ cookie-signature: 1.2.2
+ debug: 4.4.3
+ depd: 2.0.0
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ finalhandler: 2.1.1
+ fresh: 2.0.0
+ http-errors: 2.0.0
+ merge-descriptors: 2.0.0
+ mime-types: 3.0.1
+ on-finished: 2.4.1
+ once: 1.4.0
+ parseurl: 1.3.3
+ proxy-addr: 2.0.7
+ qs: 6.15.2
+ range-parser: 1.2.1
+ router: 2.2.0
+ send: 1.2.0
+ serve-static: 2.2.1
+ statuses: 2.0.1
+ type-is: 2.1.0
+ vary: 1.1.2
+ transitivePeerDependencies:
+ - supports-color
+
exsolve@1.0.8: {}
extend-shallow@2.0.1:
@@ -12062,6 +12464,17 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
+ finalhandler@2.1.1:
+ dependencies:
+ debug: 4.4.3
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ on-finished: 2.4.1
+ parseurl: 1.3.3
+ statuses: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
+
find-up-simple@1.0.1: {}
find-up@5.0.0:
@@ -12102,6 +12515,8 @@ snapshots:
format@0.2.2: {}
+ forwarded@0.2.0: {}
+
fraction.js@5.3.4: {}
fresh@2.0.0: {}
@@ -12130,10 +12545,28 @@ snapshots:
get-east-asian-width@1.5.0: {}
+ get-intrinsic@1.3.0:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.2
+ math-intrinsics: 1.1.0
+
get-npm-tarball-url@2.1.0: {}
get-port-please@3.2.0: {}
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.2
+
get-stream@6.0.1: {}
get-stream@8.0.1: {}
@@ -12194,6 +12627,8 @@ snapshots:
globrex@0.1.2: {}
+ gopd@1.2.0: {}
+
graceful-fs@4.2.11: {}
gray-matter@4.0.3:
@@ -12230,10 +12665,14 @@ snapshots:
has-flag@4.0.0: {}
+ has-symbols@1.1.0: {}
+
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
+ hono@4.12.23: {}
+
hookable@5.5.3: {}
hookable@6.1.1: {}
@@ -12256,6 +12695,14 @@ snapshots:
statuses: 2.0.1
toidentifier: 1.0.1
+ http-errors@2.0.1:
+ dependencies:
+ depd: 2.0.0
+ inherits: 2.0.4
+ setprototypeof: 1.2.0
+ statuses: 2.0.2
+ toidentifier: 1.0.1
+
http-shutdown@1.2.2: {}
https-proxy-agent@7.0.6:
@@ -12275,6 +12722,10 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
+ iconv-lite@0.7.2:
+ dependencies:
+ safer-buffer: 2.1.2
+
idb-keyval@6.2.2: {}
ieee754@1.2.1: {}
@@ -12319,6 +12770,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ ip-address@10.2.0: {}
+
+ ipaddr.js@1.9.1: {}
+
iron-webcrypto@1.2.1: {}
is-arrayish@0.2.1: {}
@@ -12364,6 +12819,8 @@ snapshots:
is-plain-obj@2.1.0: {}
+ is-promise@4.0.0: {}
+
is-reference@1.2.1:
dependencies:
'@types/estree': 1.0.8
@@ -12404,6 +12861,8 @@ snapshots:
jiti@2.6.1: {}
+ jose@6.2.3: {}
+
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@@ -12436,6 +12895,8 @@ snapshots:
json-schema-traverse@1.0.0: {}
+ json-schema-typed@8.0.2: {}
+
json-stable-stringify-without-jsonify@1.0.1: {}
json-stringify-safe@5.0.1: {}
@@ -12637,6 +13098,8 @@ snapshots:
markdown-table@3.0.4: {}
+ math-intrinsics@1.1.0: {}
+
mdast-util-find-and-replace@3.0.2:
dependencies:
'@types/mdast': 4.0.4
@@ -12766,6 +13229,10 @@ snapshots:
mdn-data@2.27.1: {}
+ media-typer@1.1.0: {}
+
+ merge-descriptors@2.0.0: {}
+
merge-stream@2.0.0: {}
merge2@1.4.1: {}
@@ -13086,6 +13553,8 @@ snapshots:
split2: 3.2.2
through2: 4.0.2
+ negotiator@1.0.0: {}
+
neo-async@2.6.2: {}
nitropack@2.13.4(idb-keyval@6.2.2)(oxc-parser@0.117.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rolldown@1.0.1):
@@ -13517,8 +13986,12 @@ snapshots:
pathe: 2.0.3
tinyexec: 1.1.2
+ object-assign@4.1.1: {}
+
object-deep-merge@2.0.0: {}
+ object-inspect@1.13.4: {}
+
obug@2.1.1: {}
ofetch@1.5.1:
@@ -13537,6 +14010,10 @@ snapshots:
dependencies:
ee-first: 1.1.1
+ once@1.4.0:
+ dependencies:
+ wrappy: 1.0.2
+
onetime@5.1.2:
dependencies:
mimic-fn: 2.1.0
@@ -13776,6 +14253,8 @@ snapshots:
lru-cache: 11.3.5
minipass: 7.1.2
+ path-to-regexp@8.4.2: {}
+
pathe@1.1.2: {}
pathe@2.0.3: {}
@@ -13788,6 +14267,8 @@ snapshots:
picomatch@4.0.4: {}
+ pkce-challenge@5.0.1: {}
+
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
@@ -14150,6 +14631,11 @@ snapshots:
process@0.11.10: {}
+ proxy-addr@2.0.7:
+ dependencies:
+ forwarded: 0.2.0
+ ipaddr.js: 1.9.1
+
publint@0.3.21:
dependencies:
'@publint/pack': 0.1.4
@@ -14159,6 +14645,10 @@ snapshots:
punycode@2.3.1: {}
+ qs@6.15.2:
+ dependencies:
+ side-channel: 1.1.0
+
quansync@0.2.11: {}
quansync@1.0.0: {}
@@ -14173,6 +14663,13 @@ snapshots:
range-parser@1.2.1: {}
+ raw-body@3.0.2:
+ dependencies:
+ bytes: 3.1.2
+ http-errors: 2.0.1
+ iconv-lite: 0.7.2
+ unpipe: 1.0.0
+
rc9@2.1.2:
dependencies:
defu: 6.1.7
@@ -14349,6 +14846,16 @@ snapshots:
rou3@0.8.1: {}
+ router@2.2.0:
+ dependencies:
+ debug: 4.4.3
+ depd: 2.0.0
+ is-promise: 4.0.0
+ parseurl: 1.3.3
+ path-to-regexp: 8.4.2
+ transitivePeerDependencies:
+ - supports-color
+
run-applescript@7.0.0: {}
run-parallel@1.2.0:
@@ -14444,6 +14951,34 @@ snapshots:
shell-quote@1.8.3: {}
+ side-channel-list@1.0.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-map@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-weakmap@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-map: 1.0.1
+
+ side-channel@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-list: 1.0.1
+ side-channel-map: 1.0.1
+ side-channel-weakmap: 1.0.2
+
siginfo@2.0.0: {}
signal-exit@3.0.7: {}
@@ -14539,6 +15074,8 @@ snapshots:
statuses@2.0.1: {}
+ statuses@2.0.2: {}
+
std-env@3.10.0: {}
std-env@4.1.0: {}
@@ -14750,6 +15287,12 @@ snapshots:
dependencies:
tagged-tag: 1.0.0
+ type-is@2.1.0:
+ dependencies:
+ content-type: 2.0.0
+ media-typer: 1.1.0
+ mime-types: 3.0.1
+
type-level-regexp@0.1.17: {}
typescript@6.0.3: {}
@@ -14935,6 +15478,8 @@ snapshots:
- '@emnapi/runtime'
- vite
+ unpipe@1.0.0: {}
+
unplugin-utils@0.2.4:
dependencies:
pathe: 2.0.3
@@ -15057,6 +15602,8 @@ snapshots:
dependencies:
builtins: 5.1.0
+ vary@1.1.2: {}
+
vite-dev-rpc@1.1.0(vite@8.0.13(@types/node@22.13.9)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.4)):
dependencies:
birpc: 2.9.0
@@ -15348,6 +15895,8 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.2.0
+ wrappy@1.0.2: {}
+
write-file-atomic@5.0.1:
dependencies:
imurmurhash: 0.1.4
@@ -15424,4 +15973,10 @@ snapshots:
compress-commons: 6.0.2
readable-stream: 4.7.0
+ zod-to-json-schema@3.25.2(zod@4.4.3):
+ dependencies:
+ zod: 4.4.3
+
+ zod@4.4.3: {}
+
zwitch@2.0.4: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index e6404ed..bf891b4 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -31,6 +31,7 @@ catalogs:
unbuild: ^3.6.1
vite: ^8.0.13
deps:
+ '@modelcontextprotocol/sdk': ^1.29.0
ansis: ^4.3.0
cac: ^7.0.0
devframe: ^0.5.2
@@ -52,6 +53,7 @@ catalogs:
tinyglobby: ^0.2.16
unconfig: ^7.5.0
unstorage: ^1.17.5
+ valibot: ^1.4.1
dev:
'@antfu/ni': ^30.1.0
'@nuxt/devtools': ^3.2.4
@@ -96,6 +98,7 @@ catalogs:
testing:
'@axe-core/playwright': ^4.11.3
'@playwright/test': ^1.60.0
+ '@valibot/to-json-schema': ^1.7.0
mlly: ^1.8.2
type-fest: 4.41.0
vitest: ^4.1.6
diff --git a/skills/node-modules-inspector/SKILL.md b/skills/node-modules-inspector/SKILL.md
new file mode 100644
index 0000000..7e90544
--- /dev/null
+++ b/skills/node-modules-inspector/SKILL.md
@@ -0,0 +1,196 @@
+---
+name: node-modules-inspector
+description: >
+ Inspects a project's installed node_modules and produces three reports:
+ duplicated packages (installed in multiple versions), packages sorted by
+ install size, and maintenance actions (dep-upgrade opportunities + publint
+ findings, grouped by consumer/author). Use when the user wants to audit
+ dependencies, find duplicate packages, check what's taking up disk space in
+ node_modules, identify outdated peer/prod dependencies that newer dependents
+ could upgrade past, or list publint problems. Available as a CLI
+ (`npx node-modules-inspector report [--json]`)
+ or an MCP stdio server (`npx node-modules-inspector mcp`) exposing the same
+ three reports as agent tools. Works with pnpm, npm, and bun.
+---
+
+# node-modules-inspector
+
+`node-modules-inspector` is a CLI + MCP server that inspects the installed `node_modules` of the current project and produces structured reports. Three reports, same underlying analysis pipeline:
+
+| Report | Answers |
+|---|---|
+| `duplicates` | Which packages are installed in multiple versions? |
+| `sizes` | Which packages take up the most disk space? |
+| `maintainers` | Which consumers have dep-upgrade opportunities or publint issues, grouped by package and author? |
+
+Reports run against the real on-disk `node_modules` — no registry calls are required for the basic shape; npm metadata is fetched only to enrich the maintainers report (gated by config).
+
+Works with pnpm, npm, and bun. The default `npx node-modules-inspector` (with no subcommand) opens a Vue web UI for humans; agents should use the `report` and `mcp` subcommands below.
+
+## When to reach for this
+
+Trigger on any of:
+- "audit my dependencies", "find duplicate packages", "node_modules cleanup"
+- "what's taking up disk space in node_modules"
+- "which deps are outdated" / "what dep-upgrade opportunities are there"
+- "show me publint issues across my deps"
+- "who maintains my dependencies"
+
+Don't reach for it for: registry-only questions (use `fast-npm-meta`), bundle-size analysis of a single package (use a bundler-specific tool), security audits (use `npm audit` / `osv-scanner`).
+
+## CLI mode
+
+All subcommands share these options:
+- `--root ` — project root (default: cwd)
+- `--config ` — config file (default: `node-modules-inspector.config.{ts,js,json}`)
+- `--depth ` — max dependency depth to traverse (default: `8`)
+- `--json` — emit JSON to stdout; pretty ANSI table otherwise
+
+Progress logs always go to stderr, so `... --json` is pipe-safe.
+
+### duplicates
+
+```sh
+npx node-modules-inspector report duplicates --json
+```
+
+Options:
+- `--min-versions ` — only include packages installed at this many versions or more (default: `2`)
+- `--limit ` — cap result count
+
+Output shape:
+```json
+[
+ {
+ "name": "@typescript-eslint/scope-manager",
+ "versions": ["8.56.1", "8.59.1", "8.59.2", "8.59.4"],
+ "specs": ["@typescript-eslint/scope-manager@8.56.1", "..."]
+ }
+]
+```
+
+Versions are sorted ascending by semver. Entries are sorted by version-count descending. Use this to find dedupe targets — `pnpm dedupe` / `npm dedupe` resolves these where ranges overlap.
+
+### sizes
+
+```sh
+npx node-modules-inspector report sizes --json --limit 20
+```
+
+Options:
+- `--limit ` — cap result count (default: `50`)
+- `--include-workspace` — include workspace packages (default: excluded; they have no meaningful install size)
+
+Output shape:
+```json
+[
+ {
+ "spec": "typescript@6.0.3",
+ "name": "typescript",
+ "version": "6.0.3",
+ "workspace": false,
+ "bytes": 24346827,
+ "categories": {
+ "js": { "bytes": 15344521, "count": 200 },
+ "dts": { "bytes": 7002306, "count": 150 }
+ }
+ }
+]
+```
+
+`categories` keys come from a fixed set: `js`, `ts`, `dts`, `json`, `bin`, `wasm`, `map`, `image`, `css`, `html`, `comp`, `doc`, `test`, `flow`, `other`. Entries are sorted by `bytes` descending.
+
+### maintainers
+
+```sh
+npx node-modules-inspector report maintainers --json
+```
+
+Options:
+- `--sort ` — sort by consumer depth, max migration ratio, or latest release time (default: `depth`)
+- `--author ` — filter to consumers maintained by this author; repeatable
+- `--no-publint` — exclude publint findings
+- `--no-latest-only` — include consumer packages that are not on their latest major
+- `--limit ` — cap result count
+
+Output shape:
+```json
+[
+ {
+ "consumer": { "spec": "rollup-plugin-esbuild@6.2.1", "name": "rollup-plugin-esbuild", "version": "6.2.1", "depth": 1 },
+ "authors": [{ "type": "github", "github": "egoist", "avatar": "..." }],
+ "items": [
+ {
+ "kind": "dep-upgrade",
+ "depName": "unplugin-utils",
+ "depType": "prod",
+ "declaredRange": "^0.2.4",
+ "rawRange": "catalog:deps",
+ "catalogName": "deps",
+ "installedHighestVersion": "0.3.1",
+ "installedHighestSpec": "unplugin-utils@0.3.1",
+ "installedVersions": ["0.2.4", "0.3.1"],
+ "migratedCount": 10,
+ "totalCount": 11,
+ "migrationRatio": 0.909
+ },
+ {
+ "kind": "publint",
+ "messages": [/* publint Message objects */],
+ "counts": { "error": 0, "warning": 1, "suggestion": 2 }
+ }
+ ],
+ "maxMigrationRatio": 0.909,
+ "latestReleasedAt": 1739000000000
+ }
+]
+```
+
+How to read this:
+- A `dep-upgrade` item means: this consumer declares `depName` at `declaredRange`, but there's a newer installed version (`installedHighestVersion`) that the range does not satisfy. `migrationRatio` is the fraction of consumers in the same cohort that already migrated — a high ratio (e.g. 0.9) means most other consumers already moved on, so this one is lagging.
+- `rawRange` differs from `declaredRange` only when the consumer used a pnpm catalog reference (`catalog:deps`); `declaredRange` is the resolved range.
+- A `publint` item carries the raw publint messages, partitioned by severity in `counts`.
+- `authors` come from the consumer's `package.json` author/maintainers fields, with GitHub-handle detection.
+
+Publint findings only appear when `pkg.resolved.publint` was populated. Enable that by adding `publint: true` to `node-modules-inspector.config.ts` (or by using the project's web UI which runs publint async).
+
+## MCP mode
+
+```sh
+npx node-modules-inspector mcp
+```
+
+Starts an MCP stdio server. Exposes three tools, identical surface to the CLI:
+
+- `nmi:report-duplicates`
+- `nmi:report-sizes`
+- `nmi:report-maintainers`
+
+When configured in an MCP client (e.g. Claude Code) under server name `node-modules-inspector`, address them as `node-modules-inspector:nmi:report-duplicates`, etc.
+
+Tool input schemas mirror the CLI options. Tool output is JSON in the exact shape shown above for each report.
+
+Prefer MCP when:
+- Multiple queries are expected in one session — the dependency tree is read once and cached across tool calls.
+- The agent needs structured output schemas to drive validation.
+
+Prefer the CLI (`report ... --json`) when shell-pipelining (`jq`, redirect, etc.) is more convenient.
+
+## Flags the agent should know
+
+- The first run reads `node_modules` end-to-end and caches npm metadata on disk (under `~/.node-modules-inspector` or similar). Subsequent runs are much faster.
+- Workspace packages are excluded from `sizes` by default — pass `--include-workspace` if you actually want them.
+- `--depth 8` is enough for almost all real projects. Increase only if the user explicitly asks about deeply-nested transitive dependencies.
+- For very large monorepos, running against a single workspace package via `--root packages/` is faster than the whole repo.
+
+## Failure modes
+
+- "No package manager detected" — the project has no `node_modules` directory, or none of pnpm/npm/bun lockfiles. Suggest the user run install first.
+- Empty `duplicates` result — fine, it means everything is deduped (mention `pnpm dedupe` etc. only if user wants to verify).
+- Empty `maintainers` result — usually means there are no dep-upgrade opportunities AND `publint: true` is not set in the config; if the user expected publint output, point them at the config.
+
+## Web UI (skip for agent tasks)
+
+`npx node-modules-inspector` (no subcommand) starts a Vue dev server on port `9999` with a full visual explorer (graph view, filters, multi-version compare, maintainer-action dashboard). It's for humans; don't suggest it for an agent task. The `report` CLI and `mcp` server above cover the same data programmatically.
+
+`npx node-modules-inspector build` produces a static SPA of the analysis into `dist/__node-modules-inspector/` — useful for CI artifacts but not for agent consumption.