diff --git a/package.json b/package.json index ff35639..4447d87 100644 --- a/package.json +++ b/package.json @@ -28,12 +28,20 @@ "type": "git", "url": "git+https://github.com/TxnLab/skills.git" }, + "engines": { + "node": ">=18" + }, "keywords": [ "agent-skills", "algorand", "txnlab", "nfd", + "use-wallet", + "haystack", "claude-code", + "codex", + "cursor", + "opencode", "ai-agent" ], "devDependencies": { diff --git a/src/agents.ts b/src/agents.ts index 39b8f3c..d0e4bd7 100644 --- a/src/agents.ts +++ b/src/agents.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' import { homedir } from 'node:os' +import chalk from 'chalk' import type { Agent } from './types.ts' const home = homedir() @@ -58,3 +59,23 @@ export function findAgent(name: string): Agent | undefined { export function getAgentNames(): string[] { return agents.map((a) => a.name) } + +export function resolveAgents(agentNames?: string[]): Agent[] { + if (agentNames && agentNames.length > 0) { + const resolved: Agent[] = [] + for (const name of agentNames) { + const agent = findAgent(name) + if (agent) { + resolved.push(agent) + } else { + const valid = getAgentNames().join(', ') + console.warn( + chalk.yellow(` Unknown agent: "${name}". Valid agents: ${valid}`), + ) + } + } + return resolved + } + + return detectAgents() +} diff --git a/src/cli.ts b/src/cli.ts index 6d53219..8c66f46 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -63,8 +63,8 @@ program .option('-l, --local', 'Remove from project-level skill directory') .option('--all', 'Remove all skills') .option('-y, --yes', 'Skip confirmation prompts') - .action((skillNames: string[], options) => { - removeCommand(skillNames, options) + .action(async (skillNames: string[], options) => { + await removeCommand(skillNames, options) }) program diff --git a/src/commands/add.ts b/src/commands/add.ts index 692fcf7..2c1cba1 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -1,8 +1,7 @@ import chalk from 'chalk' import { findSkill, discoverSkills } from '../skills.ts' -import { detectAgents, findAgent } from '../agents.ts' +import { resolveAgents } from '../agents.ts' import { installSkill } from '../installer.ts' -import type { Agent } from '../types.ts' export function addCommand( skillNames: string[], @@ -39,6 +38,14 @@ export function addCommand( process.exit(1) } + // Show detected agents when auto-detected (no explicit --agent flag) + if (!options.agent || options.agent.length === 0) { + console.log(chalk.dim(' Detected agents:')) + for (const a of targetAgents) { + console.log(` ${chalk.green('✔')} ${a.displayName}`) + } + } + const isLocal = options.local === true for (const name of names) { @@ -56,8 +63,9 @@ export function addCommand( for (const agent of targetAgents) { const result = installSkill(skill, agent, { local: isLocal }) if (result.success) { + const method = result.method === 'copy' ? chalk.dim(' (copied)') : '' console.log( - ` ${chalk.green('✔')} ${agent.displayName} — ${result.skillName} → ${result.targetPath}`, + ` ${chalk.green('✔')} ${agent.displayName} — ${result.skillName} → ${result.targetPath}${method}`, ) } else { console.error( @@ -69,29 +77,3 @@ export function addCommand( console.log() } - -function resolveAgents(agentNames?: string[]): Agent[] { - if (agentNames && agentNames.length > 0) { - const resolved: Agent[] = [] - for (const name of agentNames) { - const agent = findAgent(name) - if (agent) { - resolved.push(agent) - } else { - console.warn(chalk.yellow(` Unknown agent: "${name}"`)) - } - } - return resolved - } - - const detected = detectAgents() - if (detected.length > 0) { - console.log(chalk.dim(' Detected agents:')) - for (const a of detected) { - console.log(` ${chalk.green('✔')} ${a.displayName}`) - } - return detected - } - - return [] -} diff --git a/src/commands/dev.ts b/src/commands/dev.ts index 7615ebd..5236e2f 100644 --- a/src/commands/dev.ts +++ b/src/commands/dev.ts @@ -1,8 +1,7 @@ import chalk from 'chalk' import { discoverSkills, findSkill } from '../skills.ts' -import { detectAgents, findAgent } from '../agents.ts' +import { resolveAgents } from '../agents.ts' import { devLink, devUnlink } from '../installer.ts' -import type { Agent } from '../types.ts' export function devLinkCommand( skillNames: string[], @@ -96,20 +95,3 @@ export function devUnlinkCommand( } } } - -function resolveAgents(agentNames?: string[]): Agent[] { - if (agentNames && agentNames.length > 0) { - const resolved: Agent[] = [] - for (const name of agentNames) { - const agent = findAgent(name) - if (agent) { - resolved.push(agent) - } else { - console.warn(chalk.yellow(` Unknown agent: "${name}"`)) - } - } - return resolved - } - - return detectAgents() -} diff --git a/src/commands/list.ts b/src/commands/list.ts index 870f46f..639eb64 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -14,13 +14,29 @@ export function listCommand(): void { console.log() const maxNameLen = Math.max(...skills.map((s) => s.name.length)) + const indent = 2 + const gap = 2 + const maxDescLen = 80 - indent - maxNameLen - gap for (const skill of skills) { - const name = chalk.cyan(skill.name.padEnd(maxNameLen + 2)) - console.log(` ${name}${skill.description}`) + const name = chalk.cyan(skill.name.padEnd(maxNameLen + gap)) + const desc = truncate(skill.description, maxDescLen) + console.log(` ${name}${desc}`) } console.log() + console.log(chalk.dim(` Details: npx @txnlab/skills info `)) console.log(chalk.dim(` Install: npx @txnlab/skills add `)) console.log() } + +function truncate(text: string, maxLen: number): string { + if (maxLen < 4) return text + // Truncate at first sentence boundary (period followed by space) within maxLen + const period = text.indexOf('. ') + if (period >= 0 && period + 1 <= maxLen) { + return text.slice(0, period + 1) + } + if (text.length <= maxLen) return text + return text.slice(0, maxLen - 3).trimEnd() + '...' +} diff --git a/src/commands/remove.ts b/src/commands/remove.ts index 62c69bd..50069e2 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -1,10 +1,10 @@ import chalk from 'chalk' import { discoverSkills } from '../skills.ts' -import { detectAgents, findAgent } from '../agents.ts' +import { resolveAgents } from '../agents.ts' import { uninstallSkill } from '../installer.ts' -import type { Agent } from '../types.ts' +import { confirm } from '../prompt.ts' -export function removeCommand( +export async function removeCommand( skillNames: string[], options: { agent?: string[] @@ -13,7 +13,7 @@ export function removeCommand( all?: boolean yes?: boolean }, -): void { +): Promise { let names = skillNames if (options.all) { const allSkills = discoverSkills() @@ -37,6 +37,18 @@ export function removeCommand( process.exit(1) } + if (!options.yes) { + const skillList = names.join(', ') + const agentList = targetAgents.map((a) => a.displayName).join(', ') + const confirmed = await confirm( + `Remove ${options.all ? 'all skills' : skillList} from ${agentList}?`, + ) + if (!confirmed) { + console.log(chalk.dim('Cancelled.')) + return + } + } + const isLocal = options.local === true for (const name of names) { @@ -56,20 +68,3 @@ export function removeCommand( console.log() } - -function resolveAgents(agentNames?: string[]): Agent[] { - if (agentNames && agentNames.length > 0) { - const resolved: Agent[] = [] - for (const name of agentNames) { - const agent = findAgent(name) - if (agent) { - resolved.push(agent) - } else { - console.warn(chalk.yellow(` Unknown agent: "${name}"`)) - } - } - return resolved - } - - return detectAgents() -} diff --git a/src/installer.ts b/src/installer.ts index 5bb3991..c9ad8ba 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -32,17 +32,10 @@ export function installSkill( if (existsSync(targetPath)) { const stat = lstatSync(targetPath) if (stat.isSymbolicLink()) { - // Replace existing symlink unlinkSync(targetPath) } else { - return { - success: false, - skillName: skill.name, - agent, - targetPath, - method: 'symlink', - error: `Target path already exists and is not a symlink: ${targetPath}`, - } + // Replace existing copy (e.g., from a previous install) + rmSync(targetPath, { recursive: true }) } } diff --git a/src/prompt.ts b/src/prompt.ts new file mode 100644 index 0000000..7d5288d --- /dev/null +++ b/src/prompt.ts @@ -0,0 +1,15 @@ +import { createInterface } from 'node:readline' + +export async function confirm(message: string): Promise { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }) + + return new Promise((resolve) => { + rl.question(`${message} [y/N] `, (answer) => { + rl.close() + resolve(answer.trim().toLowerCase() === 'y') + }) + }) +} diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 503a786..99a1328 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -78,14 +78,14 @@ describe('installSkill', () => { expect(result.success).toBe(true) }) - test('fails if target is a regular directory', () => { + test('replaces existing directory (previous copy)', () => { const targetPath = join(agentSkillsDir, 'my-skill') mkdirSync(targetPath, { recursive: true }) writeFileSync(join(targetPath, 'file.txt'), 'existing') const result = installSkill(mockSkill, mockAgent) - expect(result.success).toBe(false) - expect(result.error).toContain('not a symlink') + expect(result.success).toBe(true) + expect(lstatSync(targetPath).isSymbolicLink()).toBe(true) }) })