diff --git a/README.md b/README.md
index d8ad408..ef1f8e2 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,13 @@ ni -g eslint
# this uses default agent, regardless your current working directory
```
+```bash
+ni -i
+
+# interactively select the dependency to install
+# search for packages by name
+```
+
### `nr` - run
@@ -139,6 +146,20 @@ nun webpack
# bun remove webpack
```
+```bash
+nun
+
+# interactively select
+# the dependency to remove
+```
+
+```bash
+nun -m
+
+# interactive select,
+# but with multiple dependencies
+```
+
```bash
nun -g silent
diff --git a/src/commands/ni.ts b/src/commands/ni.ts
index 2483fd4..0822c19 100644
--- a/src/commands/ni.ts
+++ b/src/commands/ni.ts
@@ -1,4 +1,100 @@
+import process from 'node:process'
+import type { Choice } from '@posva/prompts'
+import prompts from '@posva/prompts'
+import { Fzf } from 'fzf'
+import c from 'kleur'
import { parseNi } from '../parse'
import { runCli } from '../runner'
+import { exclude } from '../utils'
+import { fetchNpmPackages } from '../fetch'
-runCli(parseNi)
+runCli(async (agent, args, ctx) => {
+ const isInteractive = args[0] === '-i'
+
+ if (isInteractive) {
+ let fetchPattern: string
+
+ if (args[1] && !args[1].startsWith('-')) {
+ fetchPattern = args[1]
+ }
+ else {
+ const { pattern } = await prompts({
+ type: 'text',
+ name: 'pattern',
+ message: 'search for package',
+ })
+
+ fetchPattern = pattern
+ }
+
+ if (!fetchPattern) {
+ process.exitCode = 1
+ return
+ }
+
+ const packages = await fetchNpmPackages(fetchPattern)
+
+ if (!packages.length) {
+ console.error('No results found')
+ process.exitCode = 1
+ return
+ }
+
+ const fzf = new Fzf(packages, {
+ selector: (item: Choice) => item.title,
+ casing: 'case-insensitive',
+ })
+
+ const { dependency } = await prompts({
+ type: 'autocomplete',
+ name: 'dependency',
+ choices: packages,
+ instructions: false,
+ message: 'choose a package to install',
+ limit: 15,
+ async suggest(input: string, choices: Choice[]) {
+ const results = fzf.find(input)
+ return results.map(r => choices.find((c: any) => c.value === r.item.value))
+ },
+ })
+
+ if (!dependency) {
+ process.exitCode = 1
+ return
+ }
+
+ args = exclude(args, '-d', '-p', '-i')
+
+ /**
+ * yarn and bun do not support
+ * the installation of peers programmatically
+ */
+ const canInstallPeers = ['npm', 'pnpm'].includes(agent)
+
+ const { mode } = await prompts({
+ type: 'select',
+ name: 'mode',
+ message: `install ${c.yellow(dependency.name)} as`,
+ choices: [
+ {
+ title: 'prod',
+ value: '',
+ selected: true,
+ },
+ {
+ title: 'dev',
+ value: '-D',
+ },
+ {
+ title: `peer`,
+ value: '--save-peer',
+ disabled: !canInstallPeers,
+ },
+ ],
+ })
+
+ args.push(dependency.name, mode)
+ }
+
+ return parseNi(agent, args, ctx)
+})
diff --git a/src/commands/nr.ts b/src/commands/nr.ts
index a62d65c..da0fc8a 100644
--- a/src/commands/nr.ts
+++ b/src/commands/nr.ts
@@ -1,12 +1,12 @@
import process from 'node:process'
import type { Choice } from '@posva/prompts'
import prompts from '@posva/prompts'
-import c from 'kleur'
import { Fzf } from 'fzf'
import { dump, load } from '../storage'
import { parseNr } from '../parse'
import { getPackageJSON } from '../fs'
import { runCli } from '../runner'
+import { limitText } from '../utils'
runCli(async (agent, args, ctx) => {
const storage = await load()
@@ -44,11 +44,6 @@ runCli(async (agent, args, ctx) => {
const terminalColumns = process.stdout?.columns || 80
- function limitText(text: string, maxWidth: number) {
- if (text.length <= maxWidth)
- return text
- return `${text.slice(0, maxWidth)}${c.dim('…')}`
- }
const choices: Choice[] = raw
.map(({ key, description }) => ({
title: key,
diff --git a/src/commands/nun.ts b/src/commands/nun.ts
index 204b7cb..9399ee6 100644
--- a/src/commands/nun.ts
+++ b/src/commands/nun.ts
@@ -1,4 +1,75 @@
+import process from 'node:process'
+import type { Choice, PromptType } from '@posva/prompts'
+import prompts from '@posva/prompts'
+import { Fzf } from 'fzf'
import { parseNun } from '../parse'
import { runCli } from '../runner'
+import { getPackageJSON } from '../fs'
+import { exclude } from '../utils'
-runCli(parseNun)
+runCli(async (agent, args, ctx) => {
+ const isInteractive = !args.length && !ctx?.programmatic
+
+ if (isInteractive || args[0] === '-m') {
+ const pkg = getPackageJSON(ctx)
+
+ const allDependencies = { ...pkg.dependencies, ...pkg.devDependencies }
+
+ const raw = Object.entries(allDependencies) as [string, string][]
+
+ if (!raw.length) {
+ console.error('No dependencies found')
+ return
+ }
+
+ const fzf = new Fzf(raw, {
+ selector: ([dep, version]) => `${dep} ${version}`,
+ casing: 'case-insensitive',
+ })
+
+ const choices: Choice[] = raw.map(([dependency, version]) => ({
+ title: dependency,
+ value: dependency,
+ description: version,
+ }))
+
+ const isMultiple = args[0] === '-m'
+
+ const type: PromptType = isMultiple
+ ? 'autocompleteMultiselect'
+ : 'autocomplete'
+
+ if (isMultiple)
+ args = exclude(args, '-m')
+
+ try {
+ const { depsToRemove } = await prompts({
+ type,
+ name: 'depsToRemove',
+ choices,
+ instructions: false,
+ message: `remove ${isMultiple ? 'dependencies' : 'dependency'}`,
+ async suggest(input: string, choices: Choice[]) {
+ const results = fzf.find(input)
+ return results.map(r => choices.find(c => c.value === r.item[0]))
+ },
+ })
+
+ if (!depsToRemove) {
+ process.exitCode = 1
+ return
+ }
+
+ const isSingleDependency = typeof depsToRemove === 'string'
+
+ if (isSingleDependency)
+ args.push(depsToRemove)
+ else args.push(...depsToRemove)
+ }
+ catch {
+ process.exit(1)
+ }
+ }
+
+ return parseNun(agent, args, ctx)
+})
diff --git a/src/fetch.ts b/src/fetch.ts
new file mode 100644
index 0000000..29d67a3
--- /dev/null
+++ b/src/fetch.ts
@@ -0,0 +1,46 @@
+import process from 'node:process'
+import type { Choice } from '@posva/prompts'
+import c from 'kleur'
+import { formatPackageWithUrl } from './utils'
+
+export interface NpmPackage {
+ name: string
+ description: string
+ version: string
+ keywords: string[]
+ date: string
+ links: {
+ npm: string
+ homepage: string
+ repository: string
+ }
+}
+
+interface NpmRegistryResponse {
+ objects: { package: NpmPackage }[]
+}
+
+export async function fetchNpmPackages(pattern: string): Promise {
+ const registryLink = (pattern: string) =>
+ `https://registry.npmjs.com/-/v1/search?text=${pattern}&size=35`
+
+ const terminalColumns = process.stdout?.columns || 80
+
+ try {
+ const result = await fetch(registryLink(pattern))
+ .then(res => res.json()) as NpmRegistryResponse
+
+ return result.objects.map(({ package: pkg }) => ({
+ title: formatPackageWithUrl(
+ `${pkg.name.padEnd(30, ' ')} ${c.blue(`v${pkg.version}`)}`,
+ pkg.links.repository ?? pkg.links.npm,
+ terminalColumns,
+ ),
+ value: pkg,
+ }))
+ }
+ catch {
+ console.error('Error when fetching npm registry')
+ process.exit(1)
+ }
+}
diff --git a/src/utils.ts b/src/utils.ts
index e0ab4e1..cd309c8 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -4,6 +4,8 @@ import { existsSync, promises as fs } from 'node:fs'
import type { Buffer } from 'node:buffer'
import process from 'node:process'
import which from 'which'
+import c from 'kleur'
+import terminalLink from 'terminal-link'
export const CLI_TEMP_DIR = join(os.tmpdir(), 'antfu-ni')
@@ -15,8 +17,8 @@ export function remove(arr: T[], v: T) {
return arr
}
-export function exclude(arr: T[], v: T) {
- return arr.slice().filter(item => item !== v)
+export function exclude(arr: T[], ...v: T[]) {
+ return arr.slice().filter(item => !v.includes(item))
}
export function cmdExists(cmd: string) {
@@ -91,3 +93,23 @@ export async function writeFileSafe(
return false
}
+
+export function limitText(text: string, maxWidth: number) {
+ if (text.length <= maxWidth)
+ return text
+ return `${text.slice(0, maxWidth)}${c.dim('…')}`
+}
+
+export function formatPackageWithUrl(pkg: string, url?: string, limits = 80) {
+ return url
+ ? terminalLink(
+ pkg,
+ url,
+ {
+ fallback: (_, url) => (pkg.length + url.length > limits)
+ ? pkg
+ : pkg + c.dim(` - ${url}`),
+ },
+ )
+ : pkg
+}