diff --git a/package.json b/package.json index 2979289d..c375b692 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "@clack/prompts": "^0.11.0", "@types/bun": "latest", "@types/node": "^22.10.0", + "@vercel/detect-agent": "^1.2.1", "gray-matter": "^4.0.3", "husky": "^9.1.7", "lint-staged": "^16.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afc9cd59..cbed40b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@types/node': specifier: ^22.10.0 version: 22.19.7 + '@vercel/detect-agent': + specifier: ^1.2.1 + version: 1.2.1 gray-matter: specifier: ^4.0.3 version: 4.0.3 @@ -496,6 +499,10 @@ packages: '@types/node@22.19.7': resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==} + '@vercel/detect-agent@1.2.1': + resolution: {integrity: sha512-U/BJCltQSTFTHwaiCQQTQG3GonTbRoEewjV+OU2mMjcHLAoPOh6CP1SXA2XNmqiqI3c82nkRNJ7piZ14RqmTXw==} + engines: {node: '>=14'} + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} @@ -1361,6 +1368,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@vercel/detect-agent@1.2.1': {} + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..066686f5 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "vercel-react-best-practices": { + "source": "vercel-labs/agent-skills", + "sourceType": "github", + "computedHash": "afeca00ac2f492d88fdd010e73d91719a5b57c564bb4a73325b0bd77e11323ab" + } + } +} diff --git a/src/add.ts b/src/add.ts index 9bcc14fa..6088b289 100644 --- a/src/add.ts +++ b/src/add.ts @@ -44,6 +44,7 @@ import { type AuditResponse, type PartnerAudit, } from './telemetry.ts'; +import { detectAgent, getAgentType } from './detect-agent.ts'; import { wellKnownProvider, type WellKnownSkill } from './providers/index.ts'; import { addSkillToLock, @@ -915,10 +916,31 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< options.yes = true; } + // Auto-enable non-interactive mode when running inside an AI agent + const agentResult = await detectAgent(); + if (agentResult.isAgent) { + options.yes = true; + // Auto-select the detected agent + universal agents (unless user explicitly specified agents) + if (!options.agent || options.agent.length === 0) { + const mappedAgent = getAgentType(agentResult.agent.name); + if (mappedAgent) { + options.agent = ensureUniversalAgents([mappedAgent]); + } + } + } + console.log(); - p.intro(pc.bgCyan(pc.black(' skills '))); + if (!agentResult.isAgent) { + p.intro(pc.bgCyan(pc.black(' skills '))); + } - if (!process.stdin.isTTY) { + if (agentResult.isAgent) { + p.log.info( + pc.bgCyan(pc.black(pc.bold(` ${agentResult.agent.name} `))) + + ' ' + + 'Agent detected — installing non-interactively' + ); + } else if (!process.stdin.isTTY) { showInstallTip(); } diff --git a/src/cli.ts b/src/cli.ts index 77e1281b..e93be2d0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,7 @@ import { runList } from './list.ts'; import { removeCommand, parseRemoveOptions } from './remove.ts'; import { runSync, parseSyncOptions } from './sync.ts'; import { track } from './telemetry.ts'; +import { isRunningInAgent } from './detect-agent.ts'; import { fetchSkillFolderHash, getGitHubToken } from './skill-lock.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -607,9 +608,12 @@ async function runUpdate(): Promise { async function main(): Promise { const args = process.argv.slice(2); + const inAgent = await isRunningInAgent(); if (args.length === 0) { - showBanner(); + if (!inAgent) { + showBanner(); + } return; } @@ -621,17 +625,17 @@ async function main(): Promise { case 'search': case 'f': case 's': - showLogo(); + if (!inAgent) showLogo(); console.log(); await runFind(restArgs); break; case 'init': - showLogo(); + if (!inAgent) showLogo(); console.log(); runInit(restArgs); break; case 'experimental_install': { - showLogo(); + if (!inAgent) showLogo(); await runInstallFromLock(restArgs); break; } @@ -639,7 +643,7 @@ async function main(): Promise { case 'install': case 'a': case 'add': { - showLogo(); + if (!inAgent) showLogo(); const { source: addSource, options: addOpts } = parseAddOptions(restArgs); await runAdd(addSource, addOpts); break; @@ -656,7 +660,7 @@ async function main(): Promise { await removeCommand(skills, removeOptions); break; case 'experimental_sync': { - showLogo(); + if (!inAgent) showLogo(); const { options: syncOptions } = parseSyncOptions(restArgs); await runSync(restArgs, syncOptions); break; diff --git a/src/detect-agent.ts b/src/detect-agent.ts new file mode 100644 index 00000000..b097a116 --- /dev/null +++ b/src/detect-agent.ts @@ -0,0 +1,62 @@ +import { determineAgent, type AgentResult } from '@vercel/detect-agent'; +import { setDetectedAgent } from './telemetry.ts'; +import type { AgentType } from './types.ts'; + +let cachedResult: AgentResult | null = null; + +/** + * Map from @vercel/detect-agent names to skills-cli AgentType identifiers. + * Only includes agents that exist in both systems. + */ +const agentNameToType: Record = { + cursor: 'cursor', + 'cursor-cli': 'cursor', + claude: 'claude-code', + cowork: 'claude-code', + devin: 'universal', // Devin not in skills-cli agent list, use universal + replit: 'replit', + gemini: 'gemini-cli', + codex: 'codex', + antigravity: 'antigravity', + 'augment-cli': 'augment', + opencode: 'opencode', + 'github-copilot': 'github-copilot', +}; + +/** + * Detect if the CLI is being run inside an AI agent environment. + * Results are cached after the first call. Also updates telemetry with the agent name. + */ +export async function detectAgent(): Promise { + if (cachedResult) return cachedResult; + cachedResult = await determineAgent(); + if (cachedResult.isAgent) { + setDetectedAgent(cachedResult.agent.name); + } + return cachedResult; +} + +/** + * Returns true if the CLI is running inside a detected AI agent. + * When true, the CLI should skip interactive prompts and use sensible defaults. + */ +export async function isRunningInAgent(): Promise { + const result = await detectAgent(); + return result.isAgent; +} + +/** + * Returns the name of the detected agent, or null if not running in an agent. + */ +export async function getAgentName(): Promise { + const result = await detectAgent(); + return result.isAgent ? result.agent.name : null; +} + +/** + * Maps a detected agent name to the corresponding skills-cli AgentType. + * Returns null if the agent can't be mapped to a specific skills-cli agent. + */ +export function getAgentType(agentName: string): AgentType | null { + return agentNameToType[agentName] ?? null; +} diff --git a/src/find.ts b/src/find.ts index c094b216..1ce605c3 100644 --- a/src/find.ts +++ b/src/find.ts @@ -2,6 +2,7 @@ import * as readline from 'readline'; import { runAdd, parseAddOptions } from './add.ts'; import { track } from './telemetry.ts'; import { isRepoPrivate } from './source-parser.ts'; +import { isRunningInAgent } from './detect-agent.ts'; const RESET = '\x1b[0m'; const BOLD = '\x1b[1m'; @@ -304,11 +305,14 @@ ${DIM} 2) npx skills add ${RESET}`; return; } - // Interactive mode - show tip only if running non-interactively (likely in a coding agent) - if (isNonInteractive) { + // Skip interactive search when running inside an AI agent or non-TTY + if (isNonInteractive || (await isRunningInAgent())) { console.log(agentTip); console.log(); + console.log(`${DIM}Usage: npx skills find ${RESET}`); + return; } + const selected = await runSearchPrompt(); // Track telemetry for interactive search diff --git a/src/remove.ts b/src/remove.ts index 67653bef..20ad0cff 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -4,6 +4,7 @@ import { readdir, rm, lstat } from 'fs/promises'; import { join } from 'path'; import { agents, detectInstalledAgents } from './agents.ts'; import { track } from './telemetry.ts'; +import { detectAgent } from './detect-agent.ts'; import { removeSkillFromLock, getSkillFromLock } from './skill-lock.ts'; import type { AgentType } from './types.ts'; import { @@ -21,6 +22,17 @@ export interface RemoveOptions { } export async function removeCommand(skillNames: string[], options: RemoveOptions) { + // Auto-enable non-interactive mode when running inside an AI agent + const agentResult = await detectAgent(); + if (agentResult.isAgent) { + options.yes = true; + p.log.info( + pc.bgCyan(pc.black(pc.bold(` ${agentResult.agent.name} `))) + + ' ' + + 'Agent detected — removing non-interactively' + ); + } + const isGlobal = options.global ?? false; const cwd = process.cwd(); diff --git a/src/sync.ts b/src/sync.ts index 00e037c2..a35ebe51 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -15,6 +15,7 @@ import { searchMultiselect } from './prompts/search-multiselect.ts'; import { addSkillToLocalLock, computeSkillFolderHash, readLocalLock } from './local-lock.ts'; import type { Skill, AgentType } from './types.ts'; import { track } from './telemetry.ts'; +import { detectAgent, getAgentType } from './detect-agent.ts'; const isCancelled = (value: unknown): value is symbol => typeof value === 'symbol'; @@ -132,8 +133,34 @@ async function discoverNodeModuleSkills( export async function runSync(args: string[], options: SyncOptions = {}): Promise { const cwd = process.cwd(); + // Auto-enable non-interactive mode when running inside an AI agent + const agentResult = await detectAgent(); + if (agentResult.isAgent) { + options.yes = true; + if (!options.agent || options.agent.length === 0) { + const mappedAgent = getAgentType(agentResult.agent.name); + if (mappedAgent) { + const agentList: AgentType[] = [mappedAgent]; + for (const ua of getUniversalAgents()) { + if (!agentList.includes(ua)) agentList.push(ua); + } + options.agent = agentList; + } + } + } + console.log(); - p.intro(pc.bgCyan(pc.black(' skills experimental_sync '))); + if (!agentResult.isAgent) { + p.intro(pc.bgCyan(pc.black(' skills experimental_sync '))); + } + + if (agentResult.isAgent) { + p.log.info( + pc.bgCyan(pc.black(pc.bold(` ${agentResult.agent.name} `))) + + ' ' + + 'Agent detected — installing non-interactively' + ); + } const spinner = p.spinner(); diff --git a/src/telemetry.ts b/src/telemetry.ts index fb35b274..6ed6ec39 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -62,6 +62,15 @@ type TelemetryData = | SyncTelemetryData; let cliVersion: string | null = null; +let detectedAgentName: string | null = null; + +/** + * Set the detected AI agent name for telemetry tracking. + * Called once during agent detection, then included in all telemetry events. + */ +export function setDetectedAgent(agentName: string | null): void { + detectedAgentName = agentName; +} function isCI(): boolean { return !!( @@ -144,6 +153,11 @@ export function track(data: TelemetryData): void { params.set('ci', '1'); } + // Add detected AI agent name + if (detectedAgentName) { + params.set('agent', detectedAgentName); + } + // Add event data for (const [key, value] of Object.entries(data)) { if (value !== undefined && value !== null) {