import * as p from '@clack/prompts' import pc from 'picocolors' import { ensureUniversalAgents, getInitialAgents, loadSavedAgentPrefs, saveAgentPrefs } from '../lib/agent-selection.js' import { type AgentConfig, agents as allAgentConfigs, detectInstalledAgents } from '../lib/agents.js' import { syncClaudeMd } from '../lib/context.js' import { fetchLlmsTxt } from '../lib/fetcher.js' import { addEntry } from '../lib/lockfile.js' import * as logger from '../lib/logger.js' import { loadRegistry, resolveSlug, searchRegistry } from '../lib/registry.js' import { installToAgents, isInstalled } from '../lib/storage.js' import { track } from '../lib/telemetry.js' export interface InstallOptions { full?: boolean force?: boolean } export interface InstallInput { names: string[] options: InstallOptions } /** * Install llms.txt skills by name or slug. */ export async function install({ names, options }: InstallInput): Promise { if (names.length === 0) { logger.error('Please specify one or more names to install') p.log.message(pc.dim('Usage: llmstxt install ')) p.log.message(pc.dim('Example: llmstxt install astro vercel-ai-sdk')) process.exitCode = 1 return } const projectDir = process.cwd() const spin = logger.spinner('Loading registry...') spin.start() await loadRegistry() spin.succeed('Registry loaded') // Let user choose agents const agents = detectInstalledAgents() let targetAgents: AgentConfig[] if (!process.stdin.isTTY) { targetAgents = agents if (agents.length > 0) { const display = agents.map(a => a.displayName).join(', ') p.log.message(pc.dim(`Installing to: ${display}`)) } } else { const savedPrefs = loadSavedAgentPrefs(projectDir) const initialValues = getInitialAgents({ allAgents: allAgentConfigs, savedPrefs, projectDir }) const selected = await p.multiselect({ message: 'Which agents should receive the skills?', options: allAgentConfigs.map(a => ({ value: a.name, label: a.displayName, hint: a.isUniversal ? 'always included' : a.skillsDir })), initialValues, required: true }) if (p.isCancel(selected)) { p.cancel('Installation cancelled.') return } const finalNames = ensureUniversalAgents({ selected, allAgents: allAgentConfigs }) saveAgentPrefs(projectDir, selected) const nameSet = new Set(finalNames) targetAgents = allAgentConfigs.filter(a => nameSet.has(a.name)) } let installed = 0 let failed = 0 const installedSlugs: string[] = [] for (const name of names) { let entry = resolveSlug(name) // Fuzzy search with interactive selection if not found by exact/close match if (!entry) { const suggestions = searchRegistry(name).slice(0, 5) if (suggestions.length > 0 && process.stdin.isTTY) { const selected = await p.select({ message: `"${name}" not found. Did you mean one of these?`, options: [ ...suggestions.map(s => ({ value: s.slug, label: s.name, hint: s.category })), { value: '__none__', label: 'None of these' } ] }) if (p.isCancel(selected) || selected === '__none__') { p.log.warn(`Skipped "${name}"`) failed++ continue } entry = resolveSlug(selected as string) } if (!entry) { logger.error(`"${name}" not found in registry`) p.log.message(pc.dim(`Try \`llmstxt search "${name}"\` to find matching entries`)) failed++ continue } } if (!options.force && isInstalled({ projectDir, slug: entry.slug })) { p.log.info(`${entry.name} already installed (use --force to re-download)`) continue } const wantFull = options.full const url = wantFull && entry.llmsFullTxtUrl ? entry.llmsFullTxtUrl : entry.llmsTxtUrl const format: 'llms.txt' | 'llms-full.txt' = wantFull && entry.llmsFullTxtUrl ? 'llms-full.txt' : 'llms.txt' if (wantFull && !entry.llmsFullTxtUrl) { p.log.warn(`${entry.name} has no llms-full.txt — installing llms.txt instead`) } const spin2 = logger.spinner(`Fetching ${entry.name}...`) spin2.start() try { const result = await fetchLlmsTxt({ url }) const { checksum, size, agents: installedTo } = installToAgents({ projectDir, slug: entry.slug, entry, content: result.content, format, targetAgents }) addEntry({ projectDir, entry: { slug: entry.slug, format, sourceUrl: url, etag: result.etag, lastModified: result.lastModified, fetchedAt: new Date().toISOString(), checksum, size, name: entry.name } }) const agentList = installedTo.join(', ') spin2.succeed(`${entry.name} ${pc.dim(`→ ${agentList}`)}`) installed++ installedSlugs.push(entry.slug) } catch (err) { const msg = err instanceof Error ? err.message : String(err) spin2.fail(`${entry.name}: ${msg}`) failed++ } } // Summary if (installed > 0 || failed > 0) { const lines: string[] = [] if (installed > 0) lines.push(`${pc.green('✓')} Installed: ${installed}`) if (failed > 0) lines.push(`${pc.red('✗')} Failed: ${failed}`) p.note(lines.join('\n'), 'Summary') } if (installed > 0) { syncClaudeMd(projectDir) } if (failed > 0) { process.exitCode = 1 } // Telemetry track({ event: 'install', skills: installedSlugs.join(','), agents: agents.map(a => a.name).join(',') }) }