Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
21 changes: 21 additions & 0 deletions src/agents.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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()
}
4 changes: 2 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 11 additions & 29 deletions src/commands/add.ts
Original file line number Diff line number Diff line change
@@ -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[],
Expand Down Expand Up @@ -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) {
Expand All @@ -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(
Expand All @@ -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 []
}
20 changes: 1 addition & 19 deletions src/commands/dev.ts
Original file line number Diff line number Diff line change
@@ -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[],
Expand Down Expand Up @@ -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()
}
20 changes: 18 additions & 2 deletions src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <skill-name>`))
console.log(chalk.dim(` Install: npx @txnlab/skills add <skill-name>`))
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() + '...'
}
37 changes: 16 additions & 21 deletions src/commands/remove.ts
Original file line number Diff line number Diff line change
@@ -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[]
Expand All @@ -13,7 +13,7 @@ export function removeCommand(
all?: boolean
yes?: boolean
},
): void {
): Promise<void> {
let names = skillNames
if (options.all) {
const allSkills = discoverSkills()
Expand All @@ -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) {
Expand All @@ -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()
}
11 changes: 2 additions & 9 deletions src/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
}

Expand Down
15 changes: 15 additions & 0 deletions src/prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createInterface } from 'node:readline'

export async function confirm(message: string): Promise<boolean> {
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')
})
})
}
6 changes: 3 additions & 3 deletions tests/installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})

Expand Down