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 new file mode 100644 index 000000000..c52b7a86e --- /dev/null +++ b/packages/nuxi/src/commands/module/_skills.ts @@ -0,0 +1,132 @@ +import type { BatchInstallCallbacks, InstallSkillResult, SkillSource } from 'unagent' +import { createRequire } from 'node:module' +import { spinner } from '@clack/prompts' +import { detectInstalledAgents, formatDetectedAgentIds, installSkill } from 'unagent' + +import { logger } from '../../utils/logger' + +// 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 ModuleSkillSource extends SkillSource { + moduleName: string + isLocal: boolean +} + +/** + * 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({ + source: meta.agents.skills.url, + skills: meta.agents.skills.skills, + label: pkgName, + moduleName: pkgName, + isLocal: false, + mode: 'copy', + }) + } + } + return result +} + +async function getModuleMeta(pkgName: string, cwd: string): Promise { + try { + const require = createRequire(`${cwd}/`) + const modulePath = require.resolve(pkgName) + const mod = await import(modulePath) + const meta: unknown = await mod?.default?.getMeta?.() + if (meta && typeof meta === 'object') + return meta as ModuleMeta + return null + } + catch { + return null + } +} + +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 requested = options.agents?.length ? new Set(options.agents) : null + const targetAgents = requested + ? installedAgents.filter(agent => requested.has(agent.id)) + : installedAgents + + if (targetAgents.length === 0) { + logger.warn('No matching AI coding agents detected') + return + } + + const agentNames = formatDetectedAgentIds(targetAgents) + const effectiveAgentIds = requested ? targetAgents.map(agent => agent.id) : undefined + + 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}`) + } + else { + 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}`) + }, + } + + for (const source of sources) { + callbacks.onStart?.(source) + try { + const result = await installSkill({ + source: source.source, + skills: source.skills, + mode: source.mode ?? 'copy', + agents: effectiveAgentIds, + }) + 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 b4286535f..37bede6cd 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, DetectedAgent } from 'unagent' +import type { ModuleSkillSource } from './_skills' import type { NuxtModule } from './_utils' import * as fs from 'node:fs' @@ -8,7 +10,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, groupMultiselect, isCancel, note, select, spinner } from '@clack/prompts' import { updateConfig } from 'c12/update' import { defineCommand } from 'citty' import { colors } from 'consola/utils' @@ -18,6 +20,7 @@ import { resolve } from 'pathe' import { readPackageJSON } from 'pkg-types' import { satisfies } from 'semver' import { joinURL } from 'ufo' +import { detectInstalledAgents, formatSkillNames, getAgentDisplayNames, getBundledSkillSources } from 'unagent' import { runCommand } from '../../run' import { logger } from '../../utils/logger' @@ -25,6 +28,7 @@ import { relativeToProcess } from '../../utils/paths' import { getNuxtVersion } from '../../utils/versions' import { cwdArgs, logLevelArgs } from '../_shared' import prepareCommand from '../prepare' +import { detectModuleSkills, installModuleSkills } from './_skills' import { checkNuxtCompatibility, fetchModules, getRegistryFromContent } from './_utils' interface RegistryMeta { @@ -41,6 +45,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', @@ -101,10 +192,130 @@ export default defineCommand({ await addModules(resolvedModules, { ...ctx.args, cwd }, projectPkg) - // 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}`) + let skillInfos: ModuleSkillSource[] = [] + const moduleNames = resolvedModules.map(m => m.pkgName) + const checkSpinner = spinner() + checkSpinner.start('Checking for agent skills...') + 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)` : 'Skills scan complete') + } + 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 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}`) + } + } + } + } + + // 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) } }, @@ -155,7 +366,18 @@ 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) + 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') + || message.includes('Cannot find module') + ) + if (isPackageJsonError && modulesInstalled) + return true + logger.error(message) const failedModulesList = notInstalledModules.map(module => colors.cyan(module.pkg)).join(', ') const s = notInstalledModules.length > 1 ? 's' : '' 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..5af933af5 --- /dev/null +++ b/packages/nuxi/src/commands/module/skills.ts @@ -0,0 +1,335 @@ +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' +import { defineCommand } from 'citty' +import { colors } from 'consola/utils' +import { resolve } from 'pathe' +import { readPackageJSON } from 'pkg-types' +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', + 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 detectedAgents = detectInstalledAgents() + const agents = getAgentDisplayNames(detectedAgents) + 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) + 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) + } + + 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(`${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)` + : 'Skills scan complete') + + 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}`) + } + + let selectedSources = allSkills + let selectedAgents: string[] | undefined + let selectedMode: InstallMode = 'auto' + + 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(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 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, + })) + }) +}) 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..3a223e318 --- /dev/null +++ b/packages/nuxi/test/unit/commands/module/skills.spec.ts @@ -0,0 +1,177 @@ +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 { + select, + groupMultiselect, + note, + detectModuleSkills, + detectInstalledAgents, + fetchModules, + formatSkillNames, + getAgentDisplayNames, + getBundledSkillSources, + installModuleSkills, + isCancel, + listInstalledSkills, + readPackageJSON, + spinnerStart, + spinnerStop, + uninstallSkill, + log, +} = vi.hoisted(() => { + return { + 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)']), + 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 { + groupMultiselect, + isCancel, + log, + note, + select, + spinner: () => ({ + start: spinnerStart, + stop: spinnerStop, + }), + } +}) + +vi.mock('pkg-types', async () => { + return { + readPackageJSON, + } +}) + +vi.mock('unagent', async () => { + return { + detectInstalledAgents, + 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('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 warnSpy = vi.spyOn(logger, 'warn') + + await expect(runSkillsCommand({ + cwd: '/fake-dir', + _: [], + list: true, + })).resolves.toBeUndefined() + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping invalid skill entry')) + expect(exitSpy).not.toHaveBeenCalled() + }) + + 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