From 6676659abd66594844f005a74156044d2c8ef26e Mon Sep 17 00:00:00 2001 From: onmax Date: Tue, 27 Jan 2026 12:53:50 +0100 Subject: [PATCH 01/10] feat(module): add agent skills installation after module add --- packages/nuxi/src/commands/module/_skills.ts | 85 ++++++++++++++++++++ packages/nuxi/src/commands/module/add.ts | 24 +++++- 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 packages/nuxi/src/commands/module/_skills.ts diff --git a/packages/nuxi/src/commands/module/_skills.ts b/packages/nuxi/src/commands/module/_skills.ts new file mode 100644 index 000000000..222a64bf1 --- /dev/null +++ b/packages/nuxi/src/commands/module/_skills.ts @@ -0,0 +1,85 @@ +import { spinner } from '@clack/prompts' +import { join } from 'pathe' +import { x } from 'tinyexec' + +// Types from @nuxt/schema (PR 1) - defined locally until schema is updated +interface ModuleAgentSkillsConfig { + url: string + skills?: string[] +} + +interface ModuleAgentsConfig { + skills?: ModuleAgentSkillsConfig +} + +interface ModuleMeta { + name?: string + agents?: ModuleAgentsConfig +} + +export interface ModuleSkillInfo { + url: string + skills?: string[] + moduleName: string +} + +export async function detectModuleSkills(moduleNames: string[], cwd: string): Promise { + const result: ModuleSkillInfo[] = [] + + for (const pkgName of moduleNames) { + const meta = await getModuleMeta(pkgName, cwd) + if (meta?.agents?.skills?.url) { + result.push({ + url: meta.agents.skills.url, + skills: meta.agents.skills.skills, + moduleName: pkgName, + }) + } + } + return result +} + +async function getModuleMeta(pkgName: string, cwd: string): Promise { + try { + const modulePath = join(cwd, 'node_modules', pkgName) + const mod = await import(modulePath) + return await mod?.default?.getMeta?.() + } + catch { + return null + } +} + +export async function installSkills(infos: ModuleSkillInfo[], cwd: string): Promise { + for (const info of infos) { + const skills = info.skills ?? [] + const label = skills.length > 0 ? `Installing ${skills.join(', ')}...` : `Installing skills from ${info.url}...` + + const s = spinner() + s.start(label) + + try { + const args = ['skills', 'add', info.url, '-y'] + if (skills.length > 0) { + args.push('--skill', ...skills) + } + + await x('npx', args, { + nodeOptions: { cwd, stdio: 'pipe' }, + }) + + s.stop('Installed to detected agents') + } + catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error) + s.stop('Failed to install skills') + console.warn(`Skill installation failed: ${msg}`) + } + } +} + +export function getSkillNames(infos: ModuleSkillInfo[]): string { + return infos + .flatMap(i => i.skills?.length ? i.skills : ['all']) + .join(', ') +} diff --git a/packages/nuxi/src/commands/module/add.ts b/packages/nuxi/src/commands/module/add.ts index b4286535f..7594f3e29 100644 --- a/packages/nuxi/src/commands/module/add.ts +++ b/packages/nuxi/src/commands/module/add.ts @@ -8,7 +8,7 @@ import { homedir } from 'node:os' import { join } from 'node:path' import process from 'node:process' -import { cancel, confirm, isCancel, select } from '@clack/prompts' +import { cancel, confirm, isCancel, select, spinner } from '@clack/prompts' import { updateConfig } from 'c12/update' import { defineCommand } from 'citty' import { colors } from 'consola/utils' @@ -25,6 +25,7 @@ import { relativeToProcess } from '../../utils/paths' import { getNuxtVersion } from '../../utils/versions' import { cwdArgs, logLevelArgs } from '../_shared' import prepareCommand from '../prepare' +import { detectModuleSkills, getSkillNames, installSkills } from './_skills' import { checkNuxtCompatibility, fetchModules, getRegistryFromContent } from './_utils' interface RegistryMeta { @@ -101,6 +102,27 @@ export default defineCommand({ await addModules(resolvedModules, { ...ctx.args, cwd }, projectPkg) + // Check for agent skills + if (!ctx.args.skipInstall) { + const moduleNames = resolvedModules.map(m => m.pkgName) + const checkSpinner = spinner() + checkSpinner.start('Checking for agent skills...') + const skillInfos = await detectModuleSkills(moduleNames, cwd) + checkSpinner.stop(skillInfos.length > 0 ? `Found ${skillInfos.length} skill(s)` : 'No skills found') + + if (skillInfos.length > 0) { + const skillNames = getSkillNames(skillInfos) + const shouldInstall = await confirm({ + message: `Install agent skill(s): ${skillNames}?`, + initialValue: true, + }) + + if (!isCancel(shouldInstall) && shouldInstall) { + await installSkills(skillInfos, cwd) + } + } + } + // Run prepare command if install is not skipped if (!ctx.args.skipInstall) { const args = Object.entries(ctx.args).filter(([k]) => k in cwdArgs || k in logLevelArgs).map(([k, v]) => `--${k}=${v}`) From 33cc99a4aa140f8908319e90687732bd53792c4e Mon Sep 17 00:00:00 2001 From: onmax Date: Tue, 3 Feb 2026 20:42:53 +0100 Subject: [PATCH 02/10] feat(nuxi): add module skills command --- packages/nuxi/package.json | 1 + packages/nuxi/src/commands/module/_skills.ts | 115 +++++++----- packages/nuxi/src/commands/module/add.ts | 50 +++-- packages/nuxi/src/commands/module/index.ts | 1 + packages/nuxi/src/commands/module/skills.ts | 172 ++++++++++++++++++ .../test/unit/commands/module/add.spec.ts | 72 +++++++- .../test/unit/commands/module/skills.spec.ts | 168 +++++++++++++++++ pnpm-lock.yaml | 27 +++ 8 files changed, 541 insertions(+), 65 deletions(-) create mode 100644 packages/nuxi/src/commands/module/skills.ts create mode 100644 packages/nuxi/test/unit/commands/module/skills.spec.ts diff --git a/packages/nuxi/package.json b/packages/nuxi/package.json index 613d6e81a..b23a4ae7b 100644 --- a/packages/nuxi/package.json +++ b/packages/nuxi/package.json @@ -74,6 +74,7 @@ "tsdown": "^0.19.0", "typescript": "^5.9.3", "ufo": "^1.6.3", + "unagent": "^0.0.5", "unplugin-purge-polyfills": "^0.1.0", "vitest": "^3.2.4", "youch": "^4.1.0-beta.13" diff --git a/packages/nuxi/src/commands/module/_skills.ts b/packages/nuxi/src/commands/module/_skills.ts index 222a64bf1..b5bf1d54c 100644 --- a/packages/nuxi/src/commands/module/_skills.ts +++ b/packages/nuxi/src/commands/module/_skills.ts @@ -1,38 +1,36 @@ +import type { BatchInstallCallbacks, InstallSkillResult, SkillSource } from 'unagent' +import { createRequire } from 'node:module' import { spinner } from '@clack/prompts' -import { join } from 'pathe' -import { x } from 'tinyexec' +import { detectInstalledAgents, formatDetectedAgentIds, installSkillBatch } from 'unagent' -// Types from @nuxt/schema (PR 1) - defined locally until schema is updated -interface ModuleAgentSkillsConfig { - url: string - skills?: string[] -} - -interface ModuleAgentsConfig { - skills?: ModuleAgentSkillsConfig -} +import { logger } from '../../utils/logger' -interface ModuleMeta { - name?: string - agents?: ModuleAgentsConfig -} +// TODO: Import from @nuxt/schema when nuxt/nuxt#34187 is merged +interface ModuleAgentSkillsConfig { url: string, skills?: string[] } +interface ModuleAgentsConfig { skills?: ModuleAgentSkillsConfig } +interface ModuleMeta { name?: string, agents?: ModuleAgentsConfig } -export interface ModuleSkillInfo { - url: string - skills?: string[] +export interface ModuleSkillSource extends SkillSource { moduleName: string + isLocal: boolean } -export async function detectModuleSkills(moduleNames: string[], cwd: string): Promise { - const result: ModuleSkillInfo[] = [] +/** + * Detect skills from module meta (meta.agents.skills.url) + */ +export async function detectModuleSkills(moduleNames: string[], cwd: string): Promise { + const result: ModuleSkillSource[] = [] for (const pkgName of moduleNames) { const meta = await getModuleMeta(pkgName, cwd) if (meta?.agents?.skills?.url) { result.push({ - url: meta.agents.skills.url, + source: meta.agents.skills.url, skills: meta.agents.skills.skills, + label: pkgName, moduleName: pkgName, + isLocal: false, + mode: 'copy', }) } } @@ -41,45 +39,62 @@ export async function detectModuleSkills(moduleNames: string[], cwd: string): Pr async function getModuleMeta(pkgName: string, cwd: string): Promise { try { - const modulePath = join(cwd, 'node_modules', pkgName) + const require = createRequire(`${cwd}/`) + const modulePath = require.resolve(pkgName) const mod = await import(modulePath) - return await mod?.default?.getMeta?.() + const meta: unknown = await mod?.default?.getMeta?.() + if (meta && typeof meta === 'object') + return meta as ModuleMeta + return null } catch { return null } } -export async function installSkills(infos: ModuleSkillInfo[], cwd: string): Promise { - for (const info of infos) { - const skills = info.skills ?? [] - const label = skills.length > 0 ? `Installing ${skills.join(', ')}...` : `Installing skills from ${info.url}...` +export async function installModuleSkills(sources: ModuleSkillSource[]): Promise { + const installedAgents = detectInstalledAgents() + if (installedAgents.length === 0) { + logger.warn('No AI coding agents detected') + return + } - const s = spinner() - s.start(label) + const agentNames = formatDetectedAgentIds() - try { - const args = ['skills', 'add', info.url, '-y'] - if (skills.length > 0) { - args.push('--skill', ...skills) + const callbacks: BatchInstallCallbacks = { + onStart: (source: SkillSource) => { + const info = source as ModuleSkillSource + const skills = info.skills ?? [] + const label = skills.length > 0 + ? `Installing ${skills.join(', ')} from ${info.moduleName}...` + : `Installing skills from ${info.moduleName}...` + const s = spinner() + s.start(label) + ;(source as ModuleSkillSource & { _spinner: typeof s })._spinner = s + }, + onSuccess: (source: SkillSource, result: InstallSkillResult) => { + const info = source as ModuleSkillSource & { _spinner: ReturnType } + if (result.installed.length > 0) { + const skillNames = [...new Set(result.installed.map((i: { skill: string }) => i.skill))].join(', ') + const mode = info.isLocal ? 'linked' : 'installed' + info._spinner?.stop(`${mode} ${skillNames} → ${agentNames}`) } - - await x('npx', args, { - nodeOptions: { cwd, stdio: 'pipe' }, - }) - - s.stop('Installed to detected agents') - } - catch (error: unknown) { - const msg = error instanceof Error ? error.message : String(error) - s.stop('Failed to install skills') - console.warn(`Skill installation failed: ${msg}`) - } + else { + info._spinner?.stop('No skills found') + } + }, + onError: (source: SkillSource, error: string) => { + const info = source as ModuleSkillSource & { _spinner: ReturnType } + info._spinner?.stop('Failed to install skills') + logger.warn(`Skill installation failed for ${info.moduleName}: ${error}`) + }, } -} -export function getSkillNames(infos: ModuleSkillInfo[]): string { - return infos - .flatMap(i => i.skills?.length ? i.skills : ['all']) - .join(', ') + try { + await installSkillBatch(sources, callbacks) + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.warn(`Failed to install agent skills: ${message}`) + } } diff --git a/packages/nuxi/src/commands/module/add.ts b/packages/nuxi/src/commands/module/add.ts index 7594f3e29..bb231e922 100644 --- a/packages/nuxi/src/commands/module/add.ts +++ b/packages/nuxi/src/commands/module/add.ts @@ -1,5 +1,7 @@ import type { FileHandle } from 'node:fs/promises' import type { PackageJson } from 'pkg-types' +import type { BundledSkillSource } from 'unagent' +import type { ModuleSkillSource } from './_skills' import type { NuxtModule } from './_utils' import * as fs from 'node:fs' @@ -18,6 +20,7 @@ import { resolve } from 'pathe' import { readPackageJSON } from 'pkg-types' import { satisfies } from 'semver' import { joinURL } from 'ufo' +import { formatSkillNames, getBundledSkillSources } from 'unagent' import { runCommand } from '../../run' import { logger } from '../../utils/logger' @@ -25,7 +28,7 @@ import { relativeToProcess } from '../../utils/paths' import { getNuxtVersion } from '../../utils/versions' import { cwdArgs, logLevelArgs } from '../_shared' import prepareCommand from '../prepare' -import { detectModuleSkills, getSkillNames, installSkills } from './_skills' +import { detectModuleSkills, installModuleSkills } from './_skills' import { checkNuxtCompatibility, fetchModules, getRegistryFromContent } from './_utils' interface RegistryMeta { @@ -102,31 +105,56 @@ export default defineCommand({ await addModules(resolvedModules, { ...ctx.args, cwd }, projectPkg) - // Check for agent skills if (!ctx.args.skipInstall) { + let skillInfos: ModuleSkillSource[] = [] const moduleNames = resolvedModules.map(m => m.pkgName) const checkSpinner = spinner() checkSpinner.start('Checking for agent skills...') - const skillInfos = await detectModuleSkills(moduleNames, cwd) - checkSpinner.stop(skillInfos.length > 0 ? `Found ${skillInfos.length} skill(s)` : 'No skills found') + try { + // Check for agent skills (bundled in node_modules or via module meta) + const bundledSources: BundledSkillSource[] = getBundledSkillSources(cwd).filter((s: BundledSkillSource) => moduleNames.includes(s.packageName)) + const bundledSkills: ModuleSkillSource[] = bundledSources.map((s: BundledSkillSource) => ({ + source: s.source, + skills: s.skills, + label: s.packageName, + moduleName: s.packageName, + isLocal: true, + mode: 'symlink' as const, + })) + const metaSkills = await detectModuleSkills(moduleNames, cwd) + + // Prefer bundled over remote + const bundledModules = new Set(bundledSkills.map(s => s.moduleName)) + const remoteOnly = metaSkills.filter(s => !bundledModules.has(s.moduleName)) + skillInfos = [...bundledSkills, ...remoteOnly] + + checkSpinner.stop(skillInfos.length > 0 ? `Found ${skillInfos.length} skill(s)` : 'No skills found') + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + checkSpinner.stop('Skipped agent skills check') + logger.warn(`Failed to check agent skills: ${message}`) + } if (skillInfos.length > 0) { - const skillNames = getSkillNames(skillInfos) const shouldInstall = await confirm({ - message: `Install agent skill(s): ${skillNames}?`, + message: `Install agent skill(s): ${formatSkillNames(skillInfos)}?`, initialValue: true, }) if (!isCancel(shouldInstall) && shouldInstall) { - await installSkills(skillInfos, cwd) + try { + await installModuleSkills(skillInfos) + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.warn(`Failed to install agent skills: ${message}`) + } } } - } - // Run prepare command if install is not skipped - if (!ctx.args.skipInstall) { + // Run prepare command const args = Object.entries(ctx.args).filter(([k]) => k in cwdArgs || k in logLevelArgs).map(([k, v]) => `--${k}=${v}`) - await runCommand(prepareCommand, args) } }, diff --git a/packages/nuxi/src/commands/module/index.ts b/packages/nuxi/src/commands/module/index.ts index a7da58d23..cb2e08128 100644 --- a/packages/nuxi/src/commands/module/index.ts +++ b/packages/nuxi/src/commands/module/index.ts @@ -9,5 +9,6 @@ export default defineCommand({ subCommands: { add: () => import('./add').then(r => r.default || r), search: () => import('./search').then(r => r.default || r), + skills: () => import('./skills').then(r => r.default || r), }, }) diff --git a/packages/nuxi/src/commands/module/skills.ts b/packages/nuxi/src/commands/module/skills.ts new file mode 100644 index 000000000..05241525e --- /dev/null +++ b/packages/nuxi/src/commands/module/skills.ts @@ -0,0 +1,172 @@ +import type { BundledSkillSource, InstalledSkill, UninstallSkillResult } from 'unagent' +import type { ModuleSkillSource } from './_skills' +import process from 'node:process' +import { confirm, isCancel, spinner } from '@clack/prompts' +import { defineCommand } from 'citty' +import { colors } from 'consola/utils' +import { resolve } from 'pathe' +import { readPackageJSON } from 'pkg-types' +import { formatSkillNames, getAgentDisplayNames, getBundledSkillSources, listInstalledSkills, uninstallSkill } from 'unagent' + +import { logger } from '../../utils/logger' +import { cwdArgs, logLevelArgs } from '../_shared' +import { detectModuleSkills, installModuleSkills } from './_skills' +import { fetchModules } from './_utils' + +export default defineCommand({ + meta: { + name: 'skills', + description: 'Manage agent skills from installed modules', + }, + args: { + ...cwdArgs, + ...logLevelArgs, + install: { type: 'boolean', alias: 'i', description: 'Install skills without prompting' }, + list: { type: 'boolean', alias: 'l', description: 'List installed skills' }, + remove: { type: 'string', alias: 'r', description: 'Remove a skill by name' }, + }, + async setup(ctx) { + const cwd = resolve(ctx.args.cwd) + + // Show detected agents + const agents = getAgentDisplayNames() + if (agents.length === 0) { + logger.warn('No AI coding agents detected') + return + } + logger.info(`Detected agents: ${colors.cyan(agents.join(', '))}`) + + // --list: Show installed skills + if (ctx.args.list) { + let installed: InstalledSkill[] = [] + try { + installed = listInstalledSkills() + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.error(`Failed to list installed skills: ${message}`) + process.exit(1) + } + + if (installed.length === 0) { + logger.info('No skills installed') + return + } + + const byAgent = new Map() + for (const skill of installed) { + const list = byAgent.get(skill.agent) || [] + list.push(skill) + byAgent.set(skill.agent, list) + } + + for (const [agent, skills] of byAgent) { + logger.info(`\n${colors.bold(agent)}:`) + for (const skill of skills) + logger.info(` ${colors.cyan(skill.name)} ${colors.dim(skill.path)}`) + } + return + } + + // --remove: Remove a skill + if (ctx.args.remove) { + const skillName = ctx.args.remove + logger.info(`Removing skill: ${colors.cyan(skillName)}`) + let result: UninstallSkillResult + try { + result = await uninstallSkill({ skill: skillName }) + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.error(`Failed to remove skill: ${skillName} (${message})`) + process.exit(1) + } + + if (result.success && result.removed.length > 0) { + const removedAgents = result.removed.map((r: { agent: string }) => r.agent).join(', ') + logger.success(`Removed ${skillName} from ${removedAgents}`) + } + else { + for (const err of result.errors) + logger.warn(`${err.agent}: ${err.error}`) + logger.error(`Failed to remove skill: ${skillName}`) + process.exit(1) + } + return + } + + // Default: Scan and install skills + const pkg = await readPackageJSON(cwd).catch(() => null) + if (!pkg) { + logger.error('No package.json found') + process.exit(1) + } + + const checkSpinner = spinner() + checkSpinner.start('Scanning for agent skills...') + + // 1. Scan node_modules for bundled skills + let bundledSkills: ModuleSkillSource[] = [] + try { + const bundledSources: BundledSkillSource[] = getBundledSkillSources(cwd) + bundledSkills = bundledSources.map((s: BundledSkillSource) => ({ + source: s.source, + skills: s.skills, + label: s.packageName, + moduleName: s.packageName, + isLocal: true, + mode: 'symlink' as const, + })) + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.warn(`Failed to scan bundled skills: ${message}`) + } + + // 2. Check module meta for remote skills + const allDeps = { ...pkg.dependencies, ...pkg.devDependencies } + const depNames = Object.keys(allDeps) + const knownModules = await fetchModules().catch(() => []) + const knownModuleNames = new Set(knownModules.map(m => m.npm)) + const moduleNames = depNames.filter(name => + knownModuleNames.has(name) || name.startsWith('@nuxt/') || name.startsWith('nuxt-'), + ) + let metaSkills: ModuleSkillSource[] = [] + try { + metaSkills = await detectModuleSkills(moduleNames, cwd) + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.warn(`Failed to scan module meta skills: ${message}`) + } + + // Combine results, preferring bundled (local) over remote + const bundledModules = new Set(bundledSkills.map(s => s.moduleName)) + const remoteOnly = metaSkills.filter(s => !bundledModules.has(s.moduleName)) + const allSkills = [...bundledSkills, ...remoteOnly] + + checkSpinner.stop(allSkills.length > 0 + ? `Found skills in ${allSkills.length} package(s)` + : 'No agent skills found') + + if (allSkills.length === 0) + return + + // Show what was found + for (const info of allSkills) { + const skills = info.skills?.length ? info.skills.join(', ') : 'all' + const source = info.isLocal ? colors.dim('(bundled)') : colors.dim('(remote)') + logger.info(` ${colors.cyan(info.moduleName)}: ${skills} ${source}`) + } + + const shouldInstall = ctx.args.install || await confirm({ + message: `Install agent skill(s): ${formatSkillNames(allSkills)}?`, + initialValue: true, + }) + + if (isCancel(shouldInstall) || !shouldInstall) + return + + await installModuleSkills(allSkills) + }, +}) diff --git a/packages/nuxi/test/unit/commands/module/add.spec.ts b/packages/nuxi/test/unit/commands/module/add.spec.ts index 0806672fa..960877055 100644 --- a/packages/nuxi/test/unit/commands/module/add.spec.ts +++ b/packages/nuxi/test/unit/commands/module/add.spec.ts @@ -1,6 +1,7 @@ -import { beforeAll, describe, expect, it, vi } from 'vitest' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import commands from '../../../../src/commands/module' +import * as moduleSkills from '../../../../src/commands/module/_skills' import * as utils from '../../../../src/commands/module/_utils' import * as runCommands from '../../../../src/run' import * as versions from '../../../../src/utils/versions' @@ -82,9 +83,10 @@ describe('module add', () => { v3 = json['dist-tags'].latest }) applyMocks() - vi.spyOn(runCommands, 'runCommand').mockImplementation(vi.fn()) - vi.spyOn(versions, 'getNuxtVersion').mockResolvedValue('3.0.0') - vi.spyOn(utils, 'fetchModules').mockResolvedValue([ + const runCommandSpy = vi.spyOn(runCommands, 'runCommand') + const getNuxtVersionSpy = vi.spyOn(versions, 'getNuxtVersion') + const fetchModulesSpy = vi.spyOn(utils, 'fetchModules') + fetchModulesSpy.mockResolvedValue([ { name: 'content', npm: '@nuxt/content', @@ -111,6 +113,38 @@ describe('module add', () => { }, ]) + beforeEach(() => { + vi.clearAllMocks() + getNuxtVersionSpy.mockResolvedValue('3.0.0') + runCommandSpy.mockImplementation(vi.fn()) + fetchModulesSpy.mockResolvedValue([ + { + name: 'content', + npm: '@nuxt/content', + compatibility: { + nuxt: '3.0.0', + requires: {}, + versionMap: {}, + }, + description: '', + repo: '', + github: '', + website: '', + learn_more: '', + category: '', + type: 'community', + maintainers: [], + stats: { + downloads: 0, + stars: 0, + maintainers: 0, + contributors: 0, + modules: 0, + }, + }, + ]) + }) + it('should install Nuxt module', async () => { const addCommand = await (commands as CommandsType).subCommands.add() await addCommand.setup({ @@ -190,4 +224,34 @@ describe('module add', () => { workspace: false, }) }) + + it('should continue module add when skill discovery fails', async () => { + vi.spyOn(moduleSkills, 'detectModuleSkills').mockRejectedValueOnce(new Error('broken skill scanner')) + + const addCommand = await (commands as CommandsType).subCommands.add() + await addCommand.setup({ + args: { + cwd: '/fake-dir', + _: ['content'], + }, + }) + + expect(addDependency).toHaveBeenCalled() + expect(runCommands.runCommand).toHaveBeenCalledTimes(1) + }) + + it('should not install skills when no skills are detected', async () => { + const installSpy = vi.spyOn(moduleSkills, 'installModuleSkills') + + const addCommand = await (commands as CommandsType).subCommands.add() + await addCommand.setup({ + args: { + cwd: '/fake-dir', + _: ['content'], + }, + }) + + expect(installSpy).not.toHaveBeenCalled() + expect(runCommands.runCommand).toHaveBeenCalledTimes(1) + }) }) diff --git a/packages/nuxi/test/unit/commands/module/skills.spec.ts b/packages/nuxi/test/unit/commands/module/skills.spec.ts new file mode 100644 index 000000000..3cb2a02db --- /dev/null +++ b/packages/nuxi/test/unit/commands/module/skills.spec.ts @@ -0,0 +1,168 @@ +import type { UninstallSkillResult } from 'unagent' +import process from 'node:process' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import skillsCommand from '../../../../src/commands/module/skills' +import { logger } from '../../../../src/utils/logger' + +const { + confirm, + detectModuleSkills, + fetchModules, + formatSkillNames, + getAgentDisplayNames, + getBundledSkillSources, + installModuleSkills, + isCancel, + listInstalledSkills, + readPackageJSON, + spinnerStart, + spinnerStop, + uninstallSkill, + log, +} = vi.hoisted(() => { + return { + confirm: vi.fn(async () => true), + detectModuleSkills: vi.fn(async () => []), + fetchModules: vi.fn(async () => []), + formatSkillNames: vi.fn(() => 'all'), + getAgentDisplayNames: vi.fn(() => ['OpenAI Codex CLI (codex)']), + getBundledSkillSources: vi.fn(() => []), + installModuleSkills: vi.fn(async () => undefined), + isCancel: vi.fn(() => false), + listInstalledSkills: vi.fn(() => []), + readPackageJSON: vi.fn(async () => ({ dependencies: { nuxt: '^3.0.0' } })), + spinnerStart: vi.fn(), + spinnerStop: vi.fn(), + uninstallSkill: vi.fn(async () => ({ success: true, removed: [{ skill: 'foo', agent: 'codex', path: '/tmp/foo' }], errors: [] })), + log: { + error: vi.fn(), + info: vi.fn(), + message: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + }, + } +}) + +vi.mock('@clack/prompts', async () => { + return { + confirm, + isCancel, + log, + spinner: () => ({ + start: spinnerStart, + stop: spinnerStop, + }), + } +}) + +vi.mock('pkg-types', async () => { + return { + readPackageJSON, + } +}) + +vi.mock('unagent', async () => { + return { + formatSkillNames, + getAgentDisplayNames, + getBundledSkillSources, + listInstalledSkills, + uninstallSkill, + } +}) + +vi.mock('../../../../src/commands/module/_skills', async () => { + return { + detectModuleSkills, + installModuleSkills, + } +}) + +vi.mock('../../../../src/commands/module/_utils', async () => { + return { + fetchModules, + } +}) + +function runSkillsCommand(args: Record) { + return (skillsCommand as { setup: (ctx: { args: Record }) => Promise }).setup({ args }) +} + +describe('module skills', () => { + beforeEach(() => { + vi.clearAllMocks() + getAgentDisplayNames.mockReturnValue(['OpenAI Codex CLI (codex)']) + listInstalledSkills.mockReturnValue([]) + getBundledSkillSources.mockReturnValue([]) + fetchModules.mockResolvedValue([]) + detectModuleSkills.mockResolvedValue([]) + readPackageJSON.mockResolvedValue({ dependencies: { nuxt: '^3.0.0' } }) + uninstallSkill.mockResolvedValue({ + success: true, + removed: [{ skill: 'foo', agent: 'codex', path: '/tmp/foo' }], + errors: [], + } satisfies UninstallSkillResult) + installModuleSkills.mockResolvedValue(undefined) + }) + + it('handles --list failures without uncaught stack traces', async () => { + listInstalledSkills.mockImplementationOnce(() => { + throw new Error('ENOENT: no such file or directory') + }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code: number) => { + throw new Error(`process.exit:${code}`) + }) as never) + const errorSpy = vi.spyOn(logger, 'error') + + await expect(runSkillsCommand({ + cwd: '/fake-dir', + _: [], + list: true, + })).rejects.toThrow('process.exit:1') + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to list installed skills')) + expect(exitSpy).toHaveBeenCalledWith(1) + }) + + it('continues default scan when bundled/meta scanners fail', async () => { + const warnSpy = vi.spyOn(logger, 'warn') + getBundledSkillSources.mockImplementationOnce(() => { + throw new Error('bundled scan failed') + }) + detectModuleSkills.mockRejectedValueOnce(new Error('meta scan failed')) + + await expect(runSkillsCommand({ + cwd: '/fake-dir', + _: [], + install: true, + })).resolves.toBeUndefined() + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to scan bundled skills')) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to scan module meta skills')) + expect(installModuleSkills).not.toHaveBeenCalled() + }) + + it('handles --remove failures with clean exit', async () => { + uninstallSkill.mockResolvedValueOnce({ + success: false, + removed: [], + errors: [{ skill: 'missing-skill', agent: 'codex', error: 'Skill not installed' }], + } as any) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code: number) => { + throw new Error(`process.exit:${code}`) + }) as never) + const warnSpy = vi.spyOn(logger, 'warn') + const errorSpy = vi.spyOn(logger, 'error') + + await expect(runSkillsCommand({ + cwd: '/fake-dir', + _: [], + remove: 'missing-skill', + })).rejects.toThrow('process.exit:1') + + expect(warnSpy).toHaveBeenCalledWith('codex: Skill not installed') + expect(errorSpy).toHaveBeenCalledWith('Failed to remove skill: missing-skill') + expect(exitSpy).toHaveBeenCalledWith(1) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d978e42c7..a6deac1e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,6 +242,9 @@ importers: ufo: specifier: ^1.6.3 version: 1.6.3 + unagent: + specifier: ^0.0.5 + version: 0.0.5(unstorage@1.17.4(db0@0.3.4)(ioredis@5.9.2)) unplugin-purge-polyfills: specifier: ^0.1.0 version: 0.1.0 @@ -5213,6 +5216,7 @@ packages: tar@7.5.4: resolution: {integrity: sha512-AN04xbWGrSTDmVwlI4/GTlIIwMFk/XEv7uL8aa57zuvRy6s4hdBed+lVq2fAZ89XDa7Us3ANXcE3Tvqvja1kTA==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me terser@5.46.0: resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==} @@ -5351,6 +5355,20 @@ packages: ultrahtml@1.6.0: resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + unagent@0.0.5: + resolution: {integrity: sha512-k6uvvmeBCeI9i2vKCDBUNTHjRDyvAavc7yno+M9t/HplD51QzuEYPUxVVu8uYPUIFGDNcJ28jT8e71ihRusFUA==} + peerDependencies: + '@cloudflare/sandbox': '>=0.1.0' + '@vercel/sandbox': '>=0.1.0' + unstorage: '>=1.10.0' + peerDependenciesMeta: + '@cloudflare/sandbox': + optional: true + '@vercel/sandbox': + optional: true + unstorage: + optional: true + unconfig-core@7.4.2: resolution: {integrity: sha512-VgPCvLWugINbXvMQDf8Jh0mlbvNjNC6eSUziHsBCMpxR05OPrNrvDnyatdMjRgcHaaNsCqz+wjNXxNw1kRLHUg==} @@ -11313,6 +11331,15 @@ snapshots: ultrahtml@1.6.0: {} + unagent@0.0.5(unstorage@1.17.4(db0@0.3.4)(ioredis@5.9.2)): + dependencies: + hookable: 5.5.3 + pathe: 2.0.3 + std-env: 3.10.0 + yaml: 2.8.2 + optionalDependencies: + unstorage: 1.17.4(db0@0.3.4)(ioredis@5.9.2) + unconfig-core@7.4.2: dependencies: '@quansync/fs': 1.0.0 From 78210a0c6a0292fc4da927110f352bf6e36796b5 Mon Sep 17 00:00:00 2001 From: onmax Date: Tue, 3 Feb 2026 21:03:29 +0100 Subject: [PATCH 03/10] fix(nuxi): avoid logger crash on install error --- packages/nuxi/src/commands/module/add.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/nuxi/src/commands/module/add.ts b/packages/nuxi/src/commands/module/add.ts index bb231e922..6fde0aa81 100644 --- a/packages/nuxi/src/commands/module/add.ts +++ b/packages/nuxi/src/commands/module/add.ts @@ -205,7 +205,8 @@ async function addModules(modules: ResolvedModule[], { skipInstall, skipConfig, workspace: packageManager?.name === 'pnpm' && existsSync(resolve(cwd, 'pnpm-workspace.yaml')), }).then(() => true).catch( async (error) => { - logger.error(error) + const message = error instanceof Error ? error.message : String(error) + logger.error(message) const failedModulesList = notInstalledModules.map(module => colors.cyan(module.pkg)).join(', ') const s = notInstalledModules.length > 1 ? 's' : '' From ce8c87295163d1be9294fbe181a040efbf74260c Mon Sep 17 00:00:00 2001 From: onmax Date: Tue, 3 Feb 2026 21:39:30 +0100 Subject: [PATCH 04/10] fix(nuxi): ignore package.json exports error --- packages/nuxi/src/commands/module/add.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/nuxi/src/commands/module/add.ts b/packages/nuxi/src/commands/module/add.ts index 6fde0aa81..2e9b59661 100644 --- a/packages/nuxi/src/commands/module/add.ts +++ b/packages/nuxi/src/commands/module/add.ts @@ -206,6 +206,12 @@ async function addModules(modules: ResolvedModule[], { skipInstall, skipConfig, }).then(() => true).catch( async (error) => { const message = error instanceof Error ? error.message : String(error) + const isExportsPackageJsonError = message.includes('Package subpath \'./package.json\' is not defined by "exports"') + || message.includes('ERR_PACKAGE_PATH_NOT_EXPORTED') + if (isExportsPackageJsonError) { + logger.warn(`Peer dependency scan skipped: ${message}`) + return true + } logger.error(message) const failedModulesList = notInstalledModules.map(module => colors.cyan(module.pkg)).join(', ') From d598a4fc7c19cb007a937f968b339fcaa847e99f Mon Sep 17 00:00:00 2001 From: onmax Date: Tue, 3 Feb 2026 21:42:34 +0100 Subject: [PATCH 05/10] fix(nuxi): ignore package.json read errors after install --- packages/nuxi/src/commands/module/add.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/nuxi/src/commands/module/add.ts b/packages/nuxi/src/commands/module/add.ts index 2e9b59661..640932b5c 100644 --- a/packages/nuxi/src/commands/module/add.ts +++ b/packages/nuxi/src/commands/module/add.ts @@ -206,9 +206,15 @@ async function addModules(modules: ResolvedModule[], { skipInstall, skipConfig, }).then(() => true).catch( async (error) => { const message = error instanceof Error ? error.message : String(error) - const isExportsPackageJsonError = message.includes('Package subpath \'./package.json\' is not defined by "exports"') + const modulesInstalled = notInstalledModules.every(module => + existsSync(resolve(cwd, 'node_modules', module.pkgName)), + ) + const isPackageJsonError = message.includes('package.json') && ( + message.includes('Package subpath \'./package.json\' is not defined by "exports"') || message.includes('ERR_PACKAGE_PATH_NOT_EXPORTED') - if (isExportsPackageJsonError) { + || message.includes('Cannot find module') + ) + if (isPackageJsonError && modulesInstalled) { logger.warn(`Peer dependency scan skipped: ${message}`) return true } From 36d8c9e9f47a7b8136969e2c36d88b2c97883a90 Mon Sep 17 00:00:00 2001 From: onmax Date: Tue, 3 Feb 2026 21:57:48 +0100 Subject: [PATCH 06/10] fix(nuxi): reduce noisy skill logs --- packages/nuxi/src/commands/module/_skills.ts | 7 ++++++- packages/nuxi/src/commands/module/add.ts | 6 ++---- packages/nuxi/src/commands/module/skills.ts | 7 ++++++- packages/nuxi/test/unit/commands/module/skills.spec.ts | 10 +++++----- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/nuxi/src/commands/module/_skills.ts b/packages/nuxi/src/commands/module/_skills.ts index b5bf1d54c..ed7e673ae 100644 --- a/packages/nuxi/src/commands/module/_skills.ts +++ b/packages/nuxi/src/commands/module/_skills.ts @@ -80,11 +80,16 @@ export async function installModuleSkills(sources: ModuleSkillSource[]): Promise info._spinner?.stop(`${mode} ${skillNames} → ${agentNames}`) } else { - info._spinner?.stop('No skills found') + info._spinner?.stop('No skills to install') } }, onError: (source: SkillSource, error: string) => { const info = source as ModuleSkillSource & { _spinner: ReturnType } + const isAlreadyInstalled = error.includes('Cannot overwrite directory') || error.includes('EEXIST') + if (isAlreadyInstalled) { + info._spinner?.stop('Already installed') + return + } info._spinner?.stop('Failed to install skills') logger.warn(`Skill installation failed for ${info.moduleName}: ${error}`) }, diff --git a/packages/nuxi/src/commands/module/add.ts b/packages/nuxi/src/commands/module/add.ts index 640932b5c..e76ad5543 100644 --- a/packages/nuxi/src/commands/module/add.ts +++ b/packages/nuxi/src/commands/module/add.ts @@ -128,7 +128,7 @@ export default defineCommand({ const remoteOnly = metaSkills.filter(s => !bundledModules.has(s.moduleName)) skillInfos = [...bundledSkills, ...remoteOnly] - checkSpinner.stop(skillInfos.length > 0 ? `Found ${skillInfos.length} skill(s)` : 'No skills found') + checkSpinner.stop(skillInfos.length > 0 ? `Found ${skillInfos.length} skill(s)` : 'Skills scan complete') } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -214,10 +214,8 @@ async function addModules(modules: ResolvedModule[], { skipInstall, skipConfig, || message.includes('ERR_PACKAGE_PATH_NOT_EXPORTED') || message.includes('Cannot find module') ) - if (isPackageJsonError && modulesInstalled) { - logger.warn(`Peer dependency scan skipped: ${message}`) + if (isPackageJsonError && modulesInstalled) return true - } logger.error(message) const failedModulesList = notInstalledModules.map(module => colors.cyan(module.pkg)).join(', ') diff --git a/packages/nuxi/src/commands/module/skills.ts b/packages/nuxi/src/commands/module/skills.ts index 05241525e..dca78c214 100644 --- a/packages/nuxi/src/commands/module/skills.ts +++ b/packages/nuxi/src/commands/module/skills.ts @@ -44,6 +44,11 @@ export default defineCommand({ } catch (error) { const message = error instanceof Error ? error.message : String(error) + const isMissingSkillPath = message.includes('ENOENT') || message.includes('no such file or directory') + if (isMissingSkillPath) { + logger.warn(`Skipping invalid skill entry: ${message}`) + return + } logger.error(`Failed to list installed skills: ${message}`) process.exit(1) } @@ -147,7 +152,7 @@ export default defineCommand({ checkSpinner.stop(allSkills.length > 0 ? `Found skills in ${allSkills.length} package(s)` - : 'No agent skills found') + : 'Skills scan complete') if (allSkills.length === 0) return diff --git a/packages/nuxi/test/unit/commands/module/skills.spec.ts b/packages/nuxi/test/unit/commands/module/skills.spec.ts index 3cb2a02db..a09e580d0 100644 --- a/packages/nuxi/test/unit/commands/module/skills.spec.ts +++ b/packages/nuxi/test/unit/commands/module/skills.spec.ts @@ -106,23 +106,23 @@ describe('module skills', () => { installModuleSkills.mockResolvedValue(undefined) }) - it('handles --list failures without uncaught stack traces', async () => { + it('skips invalid skill entries on --list', async () => { listInstalledSkills.mockImplementationOnce(() => { throw new Error('ENOENT: no such file or directory') }) const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code: number) => { throw new Error(`process.exit:${code}`) }) as never) - const errorSpy = vi.spyOn(logger, 'error') + const warnSpy = vi.spyOn(logger, 'warn') await expect(runSkillsCommand({ cwd: '/fake-dir', _: [], list: true, - })).rejects.toThrow('process.exit:1') + })).resolves.toBeUndefined() - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to list installed skills')) - expect(exitSpy).toHaveBeenCalledWith(1) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping invalid skill entry')) + expect(exitSpy).not.toHaveBeenCalled() }) it('continues default scan when bundled/meta scanners fail', async () => { From fbc8b6344a899f10bf024631bbcdc071585a7ca8 Mon Sep 17 00:00:00 2001 From: onmax Date: Tue, 3 Feb 2026 22:09:32 +0100 Subject: [PATCH 07/10] chore(nuxi): remove blank line in skills list --- packages/nuxi/src/commands/module/skills.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuxi/src/commands/module/skills.ts b/packages/nuxi/src/commands/module/skills.ts index dca78c214..823d04bce 100644 --- a/packages/nuxi/src/commands/module/skills.ts +++ b/packages/nuxi/src/commands/module/skills.ts @@ -66,7 +66,7 @@ export default defineCommand({ } for (const [agent, skills] of byAgent) { - logger.info(`\n${colors.bold(agent)}:`) + logger.info(`${colors.bold(agent)}:`) for (const skill of skills) logger.info(` ${colors.cyan(skill.name)} ${colors.dim(skill.path)}`) } From aa5cab289f058077dd5f115d926bb40d4df4af78 Mon Sep 17 00:00:00 2001 From: onmax Date: Wed, 4 Feb 2026 08:20:24 +0100 Subject: [PATCH 08/10] feat(nuxi): improve skill install prompt --- packages/nuxi/src/commands/module/_skills.ts | 43 +++- packages/nuxi/src/commands/module/add.ts | 190 ++++++++++++++++-- packages/nuxi/src/commands/module/skills.ts | 179 ++++++++++++++++- .../test/unit/commands/module/skills.spec.ts | 15 +- 4 files changed, 391 insertions(+), 36 deletions(-) diff --git a/packages/nuxi/src/commands/module/_skills.ts b/packages/nuxi/src/commands/module/_skills.ts index ed7e673ae..16b6307e5 100644 --- a/packages/nuxi/src/commands/module/_skills.ts +++ b/packages/nuxi/src/commands/module/_skills.ts @@ -1,7 +1,7 @@ import type { BatchInstallCallbacks, InstallSkillResult, SkillSource } from 'unagent' import { createRequire } from 'node:module' import { spinner } from '@clack/prompts' -import { detectInstalledAgents, formatDetectedAgentIds, installSkillBatch } from 'unagent' +import { detectInstalledAgents, formatDetectedAgentIds, installSkill } from 'unagent' import { logger } from '../../utils/logger' @@ -52,14 +52,27 @@ async function getModuleMeta(pkgName: string, cwd: string): Promise { +export interface InstallModuleSkillOptions { + agents?: string[] +} + +export async function installModuleSkills(sources: ModuleSkillSource[], options: InstallModuleSkillOptions = {}): Promise { const installedAgents = detectInstalledAgents() if (installedAgents.length === 0) { logger.warn('No AI coding agents detected') return } - const agentNames = formatDetectedAgentIds() + const targetAgents = options.agents?.length + ? installedAgents.filter(agent => options.agents!.includes(agent.id)) + : installedAgents + + if (targetAgents.length === 0) { + logger.warn('No matching AI coding agents detected') + return + } + + const agentNames = formatDetectedAgentIds(targetAgents) const callbacks: BatchInstallCallbacks = { onStart: (source: SkillSource) => { @@ -95,11 +108,23 @@ export async function installModuleSkills(sources: ModuleSkillSource[]): Promise }, } - try { - await installSkillBatch(sources, callbacks) - } - catch (error) { - const message = error instanceof Error ? error.message : String(error) - logger.warn(`Failed to install agent skills: ${message}`) + for (const source of sources) { + callbacks.onStart?.(source) + try { + const result = await installSkill({ + source: source.source, + skills: source.skills, + mode: source.mode ?? 'copy', + agents: options.agents?.length ? options.agents : undefined, + }) + if (result.installed.length > 0) + callbacks.onSuccess?.(source, result) + if (result.errors.length > 0) + callbacks.onError?.(source, result.errors.map(e => e.error).join(', ')) + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + callbacks.onError?.(source, message) + } } } diff --git a/packages/nuxi/src/commands/module/add.ts b/packages/nuxi/src/commands/module/add.ts index e76ad5543..c3dd7e0b9 100644 --- a/packages/nuxi/src/commands/module/add.ts +++ b/packages/nuxi/src/commands/module/add.ts @@ -10,7 +10,7 @@ import { homedir } from 'node:os' import { join } from 'node:path' import process from 'node:process' -import { cancel, confirm, isCancel, select, spinner } from '@clack/prompts' +import { cancel, confirm, groupMultiselect, isCancel, note, select, spinner } from '@clack/prompts' import { updateConfig } from 'c12/update' import { defineCommand } from 'citty' import { colors } from 'consola/utils' @@ -20,7 +20,8 @@ import { resolve } from 'pathe' import { readPackageJSON } from 'pkg-types' import { satisfies } from 'semver' import { joinURL } from 'ufo' -import { formatSkillNames, getBundledSkillSources } from 'unagent' +import type { DetectedAgent } from 'unagent' +import { detectInstalledAgents, formatSkillNames, getAgentDisplayNames, getBundledSkillSources } from 'unagent' import { runCommand } from '../../run' import { logger } from '../../utils/logger' @@ -45,6 +46,93 @@ interface ResolvedModule { type UnresolvedModule = false type ModuleResolution = ResolvedModule | UnresolvedModule +type InstallMode = 'auto' | 'copy' | 'symlink' +const SKILL_VALUE_SEPARATOR = '::' +const SKILL_ALL_TOKEN = '*' + +function formatAgentList(agents: DetectedAgent[]): string { + if (agents.length === 0) + return 'none' + return getAgentDisplayNames(agents).join(', ') +} + +function formatSkillPlan(agents: DetectedAgent[], sources: ModuleSkillSource[]): string { + const lines = [ + `Agents: ${formatAgentList(agents)}`, + 'Mode: auto (symlink local, copy remote)', + 'Skills:', + ...sources.map((source) => { + const skills = source.skills?.length ? source.skills.join(', ') : 'all' + return `- ${source.moduleName}: ${skills}` + }), + ] + return lines.join('\n') +} + +function buildSkillGroups(sources: ModuleSkillSource[]) { + const groups: Record> = {} + const initialValues: string[] = [] + + for (const source of sources) { + const skills = source.skills?.length ? source.skills : null + const options = skills + ? skills.map(skill => ({ label: skill, value: `${source.moduleName}${SKILL_VALUE_SEPARATOR}${skill}` })) + : [{ label: 'all', value: `${source.moduleName}${SKILL_VALUE_SEPARATOR}${SKILL_ALL_TOKEN}`, hint: 'includes all skills' }] + groups[source.moduleName] = options + initialValues.push(...options.map(option => option.value)) + } + + return { groups, initialValues } +} + +function applySkillSelection(sources: ModuleSkillSource[], selectedValues: string[]) { + const selectedByModule = new Map }>() + + for (const value of selectedValues) { + const [moduleName, skillName] = value.split(SKILL_VALUE_SEPARATOR) + const entry = selectedByModule.get(moduleName) || { all: false, skills: new Set() } + if (skillName === SKILL_ALL_TOKEN) + entry.all = true + else if (skillName) + entry.skills.add(skillName) + selectedByModule.set(moduleName, entry) + } + + const selectedSources: ModuleSkillSource[] = [] + for (const source of sources) { + const selected = selectedByModule.get(source.moduleName) + if (!selected) + continue + if (selected.all) { + selectedSources.push({ ...source, skills: undefined }) + continue + } + const sourceSkills = source.skills?.length ? source.skills : [] + const filtered = sourceSkills.filter(skill => selected.skills.has(skill)) + if (filtered.length === 0) + continue + selectedSources.push({ ...source, skills: filtered }) + } + + return selectedSources +} + +function applyInstallMode(sources: ModuleSkillSource[], mode: InstallMode) { + if (mode === 'auto') + return { sources, forcedCopy: false } + + let forcedCopy = false + const next = sources.map((source) => { + if (mode === 'symlink' && !source.isLocal) { + forcedCopy = true + return { ...source, mode: 'copy' as const } + } + return { ...source, mode } + }) + + return { sources: next, forcedCopy } +} + export default defineCommand({ meta: { name: 'add', @@ -137,18 +225,92 @@ export default defineCommand({ } if (skillInfos.length > 0) { - const shouldInstall = await confirm({ - message: `Install agent skill(s): ${formatSkillNames(skillInfos)}?`, - initialValue: true, - }) - - if (!isCancel(shouldInstall) && shouldInstall) { - try { - await installModuleSkills(skillInfos) - } - catch (error) { - const message = error instanceof Error ? error.message : String(error) - logger.warn(`Failed to install agent skills: ${message}`) + const detectedAgents = detectInstalledAgents() + if (detectedAgents.length === 0) { + logger.warn('No AI coding agents detected') + } + else { + note(formatSkillPlan(detectedAgents, skillInfos), 'Planned install') + + const action = await select({ + message: `Install agent skill(s): ${formatSkillNames(skillInfos)}?`, + options: [ + { value: 'yes', label: 'Yes', hint: 'Install with planned settings' }, + { value: 'config', label: 'Change configuration', hint: 'Choose agent, mode, and skills' }, + { value: 'no', label: 'No', hint: 'Skip installing skills' }, + ], + initialValue: 'yes', + }) + + if (!isCancel(action) && action !== 'no') { + let selectedSources = skillInfos + let selectedAgents: string[] | undefined + let selectedMode: InstallMode = 'auto' + + if (action === 'config') { + const agentChoice = await select({ + message: 'Which AI agent should receive the skills?', + options: [ + { value: '__all__', label: 'All detected agents', hint: 'default' }, + ...detectedAgents.map(agent => ({ + value: agent.id, + label: `${agent.config.name} (${agent.id})`, + })), + ], + initialValue: '__all__', + }) + + if (isCancel(agentChoice)) + return + + if (agentChoice !== '__all__') + selectedAgents = [agentChoice] + + const modeChoice = await select({ + message: 'Install mode:', + options: [ + { value: 'auto', label: 'Auto', hint: 'symlink local, copy remote' }, + { value: 'copy', label: 'Copy', hint: 'copy into agent skill dir' }, + { value: 'symlink', label: 'Symlink', hint: 'link to source (local only)' }, + ], + initialValue: 'auto', + }) + + if (isCancel(modeChoice)) + return + + selectedMode = modeChoice as InstallMode + + const { groups, initialValues } = buildSkillGroups(skillInfos) + const skillSelection = await groupMultiselect({ + message: 'Select skills to install:', + options: groups, + initialValues, + required: false, + }) + + if (isCancel(skillSelection)) + return + + selectedSources = applySkillSelection(skillInfos, skillSelection as string[]) + if (selectedSources.length === 0) { + logger.info('No skills selected') + return + } + } + + const modeResult = applyInstallMode(selectedSources, selectedMode) + selectedSources = modeResult.sources + if (modeResult.forcedCopy) + logger.warn('Symlink mode applies only to local skills; remote skills will be copied.') + + try { + await installModuleSkills(selectedSources, { agents: selectedAgents }) + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.warn(`Failed to install agent skills: ${message}`) + } } } } diff --git a/packages/nuxi/src/commands/module/skills.ts b/packages/nuxi/src/commands/module/skills.ts index 823d04bce..a3f8da8b3 100644 --- a/packages/nuxi/src/commands/module/skills.ts +++ b/packages/nuxi/src/commands/module/skills.ts @@ -1,18 +1,106 @@ import type { BundledSkillSource, InstalledSkill, UninstallSkillResult } from 'unagent' import type { ModuleSkillSource } from './_skills' import process from 'node:process' -import { confirm, isCancel, spinner } from '@clack/prompts' +import { groupMultiselect, isCancel, note, select, spinner } from '@clack/prompts' import { defineCommand } from 'citty' import { colors } from 'consola/utils' import { resolve } from 'pathe' import { readPackageJSON } from 'pkg-types' -import { formatSkillNames, getAgentDisplayNames, getBundledSkillSources, listInstalledSkills, uninstallSkill } from 'unagent' +import type { DetectedAgent } from 'unagent' +import { detectInstalledAgents, formatSkillNames, getAgentDisplayNames, getBundledSkillSources, listInstalledSkills, uninstallSkill } from 'unagent' import { logger } from '../../utils/logger' import { cwdArgs, logLevelArgs } from '../_shared' import { detectModuleSkills, installModuleSkills } from './_skills' import { fetchModules } from './_utils' +type InstallMode = 'auto' | 'copy' | 'symlink' +const SKILL_VALUE_SEPARATOR = '::' +const SKILL_ALL_TOKEN = '*' + +function formatAgentList(agents: DetectedAgent[]): string { + if (agents.length === 0) + return 'none' + return getAgentDisplayNames(agents).join(', ') +} + +function formatSkillPlan(agents: DetectedAgent[], sources: ModuleSkillSource[]): string { + const lines = [ + `Agents: ${formatAgentList(agents)}`, + 'Mode: auto (symlink local, copy remote)', + 'Skills:', + ...sources.map((source) => { + const skills = source.skills?.length ? source.skills.join(', ') : 'all' + return `- ${source.moduleName}: ${skills}` + }), + ] + return lines.join('\n') +} + +function buildSkillGroups(sources: ModuleSkillSource[]) { + const groups: Record> = {} + const initialValues: string[] = [] + + for (const source of sources) { + const skills = source.skills?.length ? source.skills : null + const options = skills + ? skills.map(skill => ({ label: skill, value: `${source.moduleName}${SKILL_VALUE_SEPARATOR}${skill}` })) + : [{ label: 'all', value: `${source.moduleName}${SKILL_VALUE_SEPARATOR}${SKILL_ALL_TOKEN}`, hint: 'includes all skills' }] + groups[source.moduleName] = options + initialValues.push(...options.map(option => option.value)) + } + + return { groups, initialValues } +} + +function applySkillSelection(sources: ModuleSkillSource[], selectedValues: string[]) { + const selectedByModule = new Map }>() + + for (const value of selectedValues) { + const [moduleName, skillName] = value.split(SKILL_VALUE_SEPARATOR) + const entry = selectedByModule.get(moduleName) || { all: false, skills: new Set() } + if (skillName === SKILL_ALL_TOKEN) + entry.all = true + else if (skillName) + entry.skills.add(skillName) + selectedByModule.set(moduleName, entry) + } + + const selectedSources: ModuleSkillSource[] = [] + for (const source of sources) { + const selected = selectedByModule.get(source.moduleName) + if (!selected) + continue + if (selected.all) { + selectedSources.push({ ...source, skills: undefined }) + continue + } + const sourceSkills = source.skills?.length ? source.skills : [] + const filtered = sourceSkills.filter(skill => selected.skills.has(skill)) + if (filtered.length === 0) + continue + selectedSources.push({ ...source, skills: filtered }) + } + + return selectedSources +} + +function applyInstallMode(sources: ModuleSkillSource[], mode: InstallMode) { + if (mode === 'auto') + return { sources, forcedCopy: false } + + let forcedCopy = false + const next = sources.map((source) => { + if (mode === 'symlink' && !source.isLocal) { + forcedCopy = true + return { ...source, mode: 'copy' as const } + } + return { ...source, mode } + }) + + return { sources: next, forcedCopy } +} + export default defineCommand({ meta: { name: 'skills', @@ -29,7 +117,8 @@ export default defineCommand({ const cwd = resolve(ctx.args.cwd) // Show detected agents - const agents = getAgentDisplayNames() + const detectedAgents = detectInstalledAgents() + const agents = getAgentDisplayNames(detectedAgents) if (agents.length === 0) { logger.warn('No AI coding agents detected') return @@ -164,14 +253,84 @@ export default defineCommand({ logger.info(` ${colors.cyan(info.moduleName)}: ${skills} ${source}`) } - const shouldInstall = ctx.args.install || await confirm({ - message: `Install agent skill(s): ${formatSkillNames(allSkills)}?`, - initialValue: true, - }) + let selectedSources = allSkills + let selectedAgents: string[] | undefined + let selectedMode: InstallMode = 'auto' - if (isCancel(shouldInstall) || !shouldInstall) - return + if (!ctx.args.install) { + note(formatSkillPlan(detectedAgents, allSkills), 'Planned install') + + const action = await select({ + message: `Install agent skill(s): ${formatSkillNames(allSkills)}?`, + options: [ + { value: 'yes', label: 'Yes', hint: 'Install with planned settings' }, + { value: 'config', label: 'Change configuration', hint: 'Choose agent, mode, and skills' }, + { value: 'no', label: 'No', hint: 'Skip installing skills' }, + ], + initialValue: 'yes', + }) + + if (isCancel(action) || action === 'no') + return + + if (action === 'config') { + const agentChoice = await select({ + message: 'Which AI agent should receive the skills?', + options: [ + { value: '__all__', label: 'All detected agents', hint: 'default' }, + ...detectedAgents.map(agent => ({ + value: agent.id, + label: `${agent.config.name} (${agent.id})`, + })), + ], + initialValue: '__all__', + }) + + if (isCancel(agentChoice)) + return + + if (agentChoice !== '__all__') + selectedAgents = [agentChoice] + + const modeChoice = await select({ + message: 'Install mode:', + options: [ + { value: 'auto', label: 'Auto', hint: 'symlink local, copy remote' }, + { value: 'copy', label: 'Copy', hint: 'copy into agent skill dir' }, + { value: 'symlink', label: 'Symlink', hint: 'link to source (local only)' }, + ], + initialValue: 'auto', + }) + + if (isCancel(modeChoice)) + return + + selectedMode = modeChoice as InstallMode + + const { groups, initialValues } = buildSkillGroups(allSkills) + const skillSelection = await groupMultiselect({ + message: 'Select skills to install:', + options: groups, + initialValues, + required: false, + }) + + if (isCancel(skillSelection)) + return + + selectedSources = applySkillSelection(allSkills, skillSelection as string[]) + if (selectedSources.length === 0) { + logger.info('No skills selected') + return + } + } + } + + const modeResult = applyInstallMode(selectedSources, selectedMode) + selectedSources = modeResult.sources + if (modeResult.forcedCopy) + logger.warn('Symlink mode applies only to local skills; remote skills will be copied.') - await installModuleSkills(allSkills) + await installModuleSkills(selectedSources, { agents: selectedAgents }) }, }) diff --git a/packages/nuxi/test/unit/commands/module/skills.spec.ts b/packages/nuxi/test/unit/commands/module/skills.spec.ts index a09e580d0..3a223e318 100644 --- a/packages/nuxi/test/unit/commands/module/skills.spec.ts +++ b/packages/nuxi/test/unit/commands/module/skills.spec.ts @@ -5,8 +5,11 @@ import skillsCommand from '../../../../src/commands/module/skills' import { logger } from '../../../../src/utils/logger' const { - confirm, + select, + groupMultiselect, + note, detectModuleSkills, + detectInstalledAgents, fetchModules, formatSkillNames, getAgentDisplayNames, @@ -21,8 +24,11 @@ const { log, } = vi.hoisted(() => { return { - confirm: vi.fn(async () => true), + select: vi.fn(async () => 'yes'), + groupMultiselect: vi.fn(async () => []), + note: vi.fn(), detectModuleSkills: vi.fn(async () => []), + detectInstalledAgents: vi.fn(() => [{ id: 'codex', config: { name: 'OpenAI Codex CLI', skillsDir: 'skills' } }]), fetchModules: vi.fn(async () => []), formatSkillNames: vi.fn(() => 'all'), getAgentDisplayNames: vi.fn(() => ['OpenAI Codex CLI (codex)']), @@ -46,9 +52,11 @@ const { vi.mock('@clack/prompts', async () => { return { - confirm, + groupMultiselect, isCancel, log, + note, + select, spinner: () => ({ start: spinnerStart, stop: spinnerStop, @@ -64,6 +72,7 @@ vi.mock('pkg-types', async () => { vi.mock('unagent', async () => { return { + detectInstalledAgents, formatSkillNames, getAgentDisplayNames, getBundledSkillSources, From 946e59fd97dcadf63fd8a8fd3d0f5a340cbc7a39 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:21:10 +0000 Subject: [PATCH 09/10] [autofix.ci] apply automated fixes --- packages/nuxi/src/commands/module/add.ts | 3 +-- packages/nuxi/src/commands/module/skills.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/nuxi/src/commands/module/add.ts b/packages/nuxi/src/commands/module/add.ts index c3dd7e0b9..37bede6cd 100644 --- a/packages/nuxi/src/commands/module/add.ts +++ b/packages/nuxi/src/commands/module/add.ts @@ -1,6 +1,6 @@ import type { FileHandle } from 'node:fs/promises' import type { PackageJson } from 'pkg-types' -import type { BundledSkillSource } from 'unagent' +import type { BundledSkillSource, DetectedAgent } from 'unagent' import type { ModuleSkillSource } from './_skills' import type { NuxtModule } from './_utils' @@ -20,7 +20,6 @@ import { resolve } from 'pathe' import { readPackageJSON } from 'pkg-types' import { satisfies } from 'semver' import { joinURL } from 'ufo' -import type { DetectedAgent } from 'unagent' import { detectInstalledAgents, formatSkillNames, getAgentDisplayNames, getBundledSkillSources } from 'unagent' import { runCommand } from '../../run' diff --git a/packages/nuxi/src/commands/module/skills.ts b/packages/nuxi/src/commands/module/skills.ts index a3f8da8b3..5af933af5 100644 --- a/packages/nuxi/src/commands/module/skills.ts +++ b/packages/nuxi/src/commands/module/skills.ts @@ -1,4 +1,4 @@ -import type { BundledSkillSource, InstalledSkill, UninstallSkillResult } from 'unagent' +import type { BundledSkillSource, DetectedAgent, InstalledSkill, UninstallSkillResult } from 'unagent' import type { ModuleSkillSource } from './_skills' import process from 'node:process' import { groupMultiselect, isCancel, note, select, spinner } from '@clack/prompts' @@ -6,7 +6,6 @@ import { defineCommand } from 'citty' import { colors } from 'consola/utils' import { resolve } from 'pathe' import { readPackageJSON } from 'pkg-types' -import type { DetectedAgent } from 'unagent' import { detectInstalledAgents, formatSkillNames, getAgentDisplayNames, getBundledSkillSources, listInstalledSkills, uninstallSkill } from 'unagent' import { logger } from '../../utils/logger' From 282d54438d2f4bc2f4064bbacd3e2d610a414258 Mon Sep 17 00:00:00 2001 From: onmax Date: Fri, 6 Feb 2026 06:03:27 +0100 Subject: [PATCH 10/10] fix(nuxi): sanitize skill agent ids --- packages/nuxi/src/commands/module/_skills.ts | 8 +- .../test/unit/commands/module/_skills.spec.ts | 102 ++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 packages/nuxi/test/unit/commands/module/_skills.spec.ts diff --git a/packages/nuxi/src/commands/module/_skills.ts b/packages/nuxi/src/commands/module/_skills.ts index 16b6307e5..c52b7a86e 100644 --- a/packages/nuxi/src/commands/module/_skills.ts +++ b/packages/nuxi/src/commands/module/_skills.ts @@ -63,8 +63,9 @@ export async function installModuleSkills(sources: ModuleSkillSource[], options: return } - const targetAgents = options.agents?.length - ? installedAgents.filter(agent => options.agents!.includes(agent.id)) + const requested = options.agents?.length ? new Set(options.agents) : null + const targetAgents = requested + ? installedAgents.filter(agent => requested.has(agent.id)) : installedAgents if (targetAgents.length === 0) { @@ -73,6 +74,7 @@ export async function installModuleSkills(sources: ModuleSkillSource[], options: } const agentNames = formatDetectedAgentIds(targetAgents) + const effectiveAgentIds = requested ? targetAgents.map(agent => agent.id) : undefined const callbacks: BatchInstallCallbacks = { onStart: (source: SkillSource) => { @@ -115,7 +117,7 @@ export async function installModuleSkills(sources: ModuleSkillSource[], options: source: source.source, skills: source.skills, mode: source.mode ?? 'copy', - agents: options.agents?.length ? options.agents : undefined, + agents: effectiveAgentIds, }) if (result.installed.length > 0) callbacks.onSuccess?.(source, result) diff --git a/packages/nuxi/test/unit/commands/module/_skills.spec.ts b/packages/nuxi/test/unit/commands/module/_skills.spec.ts new file mode 100644 index 000000000..9020acfc3 --- /dev/null +++ b/packages/nuxi/test/unit/commands/module/_skills.spec.ts @@ -0,0 +1,102 @@ +import process from 'node:process' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { installModuleSkills } from '../../../../src/commands/module/_skills' +import { logger } from '../../../../src/utils/logger' + +const { + detectInstalledAgents, + formatDetectedAgentIds, + installSkill, + spinnerStart, + spinnerStop, + log, +} = vi.hoisted(() => { + return { + detectInstalledAgents: vi.fn(() => [{ id: 'codex', config: { name: 'OpenAI Codex CLI', skillsDir: 'skills' } }]), + formatDetectedAgentIds: vi.fn(() => 'OpenAI Codex CLI (codex)'), + installSkill: vi.fn(async () => ({ installed: [{ skill: 'foo', agent: 'codex', path: '/tmp/foo' }], errors: [] })), + spinnerStart: vi.fn(), + spinnerStop: vi.fn(), + log: { + error: vi.fn(), + info: vi.fn(), + message: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + }, + } +}) + +vi.mock('@clack/prompts', async () => { + return { + log, + spinner: () => ({ + start: spinnerStart, + stop: spinnerStop, + }), + } +}) + +vi.mock('unagent', async () => { + return { + detectInstalledAgents, + formatDetectedAgentIds, + installSkill, + } +}) + +describe('installModuleSkills', () => { + beforeEach(() => { + vi.clearAllMocks() + detectInstalledAgents.mockReturnValue([{ id: 'codex', config: { name: 'OpenAI Codex CLI', skillsDir: 'skills' } }]) + installSkill.mockResolvedValue({ installed: [{ skill: 'foo', agent: 'codex', path: '/tmp/foo' }], errors: [] }) + }) + + it('sanitizes unknown agent ids', async () => { + await expect(installModuleSkills([{ + source: '/tmp/source', + label: 'some-module', + moduleName: 'some-module', + isLocal: true, + mode: 'symlink', + } as any], { agents: ['codex', 'unknown'] })).resolves.toBeUndefined() + + expect(installSkill).toHaveBeenCalledTimes(1) + expect(installSkill).toHaveBeenCalledWith(expect.objectContaining({ + agents: ['codex'], + })) + }) + + it('does not install when no matching agent ids are provided', async () => { + const warnSpy = vi.spyOn(logger, 'warn') + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + + await expect(installModuleSkills([{ + source: '/tmp/source', + label: 'some-module', + moduleName: 'some-module', + isLocal: false, + mode: 'copy', + } as any], { agents: ['unknown'] })).resolves.toBeUndefined() + + expect(installSkill).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith('No matching AI coding agents detected') + expect(exitSpy).not.toHaveBeenCalled() + }) + + it('passes agents as undefined when no filtering is requested', async () => { + await expect(installModuleSkills([{ + source: '/tmp/source', + label: 'some-module', + moduleName: 'some-module', + isLocal: false, + mode: 'copy', + } as any])).resolves.toBeUndefined() + + expect(installSkill).toHaveBeenCalledTimes(1) + expect(installSkill).toHaveBeenCalledWith(expect.objectContaining({ + agents: undefined, + })) + }) +})