|
import type { |
|
AgentStartEvent, |
|
CommandSubscription, |
|
PluginAPI, |
|
PluginCommandContext, |
|
PluginEventContext, |
|
StatusItem, |
|
} from '@ampcode/plugin'; |
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; |
|
import { basename, join } from 'node:path'; |
|
|
|
type Skill = { |
|
name: string; |
|
description: string; |
|
path: string; |
|
body: string; |
|
}; |
|
|
|
const SKILL_DIRS = [ |
|
'~/.agents/skills', |
|
'~/.config/amp/skills', |
|
'~/.config/agents/skills', |
|
'~/.codex/skills', |
|
'~/.claude/skills', |
|
'.agents/skills', |
|
'.claude/skills', |
|
]; |
|
|
|
const SKILL_TOKEN = /(?:^|\s)\$([a-zA-Z0-9][a-zA-Z0-9_-]*)(?=\s|$)/g; |
|
|
|
const pendingSkillsByThread = new Map<string, string[]>(); |
|
|
|
function homePath(path: string): string { |
|
if (path === '~') { |
|
return process.env.HOME || path; |
|
} |
|
if (path.startsWith('~/')) { |
|
const home = process.env.HOME; |
|
return home ? join(home, path.slice(2)) : path; |
|
} |
|
return path; |
|
} |
|
|
|
function frontmatterValue(frontmatter: string, key: string): string | undefined { |
|
const value = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim(); |
|
return value?.replace(/^["']|["']$/g, ''); |
|
} |
|
|
|
function unique<T>(values: T[]): T[] { |
|
return [...new Set(values)]; |
|
} |
|
|
|
function parseSkillFile(path: string, fallbackName: string): Skill | null { |
|
const content = readFileSync(path, 'utf8'); |
|
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); |
|
const frontmatter = frontmatterMatch?.[1] ?? ''; |
|
const body = (frontmatterMatch?.[2] ?? content).trim(); |
|
const name = frontmatterValue(frontmatter, 'name') ?? fallbackName; |
|
const description = frontmatterValue(frontmatter, 'description') ?? firstMeaningfulLine(body); |
|
|
|
if (!name) { |
|
return null; |
|
} |
|
|
|
return { name, description, path, body }; |
|
} |
|
|
|
function firstMeaningfulLine(content: string): string { |
|
return ( |
|
content |
|
.split('\n') |
|
.map((line) => line.trim().replace(/^#+\s*/, '')) |
|
.find((line) => line.length > 0) ?? 'Invoke this skill.' |
|
); |
|
} |
|
|
|
function loadSkills(): Skill[] { |
|
const skills = new Map<string, Skill>(); |
|
|
|
for (const configuredDir of SKILL_DIRS) { |
|
const dir = homePath(configuredDir); |
|
if (!existsSync(dir)) { |
|
continue; |
|
} |
|
|
|
for (const entry of readdirSync(dir)) { |
|
const skillDir = join(dir, entry); |
|
const skillFile = join(skillDir, 'SKILL.md'); |
|
if (!existsSync(skillFile) || !statSync(skillFile).isFile()) { |
|
continue; |
|
} |
|
|
|
const skill = parseSkillFile(skillFile, basename(skillDir)); |
|
if (skill && !skills.has(skill.name)) { |
|
skills.set(skill.name, skill); |
|
} |
|
} |
|
} |
|
|
|
return [...skills.values()].sort((a, b) => a.name.localeCompare(b.name)); |
|
} |
|
|
|
function selectedSkillNames(skillsByName: Map<string, Skill>, message: string, pending: string[]): string[] { |
|
const names = [...pending]; |
|
|
|
for (const match of message.matchAll(SKILL_TOKEN)) { |
|
const name = match[1]; |
|
if (skillsByName.has(name)) { |
|
names.push(name); |
|
} |
|
} |
|
|
|
return unique(names); |
|
} |
|
|
|
function buildSkillInvocationRequest(skills: Skill[]): string { |
|
return [ |
|
`The user explicitly invoked ${skills.length === 1 ? 'this skill' : 'these skills'} for this turn: ${skills.map((skill) => skill.name).join(', ')}.`, |
|
"Use Amp's native skill invocation mechanism for the named skill(s) before working on the user request. Do not treat this as ordinary prose; load/invoke the skill(s) so the invocation appears in the agent actions when the runtime supports it.", |
|
...skills.map((skill) => `${skill.name}: ${skill.description}`), |
|
].join('\n\n'); |
|
} |
|
|
|
function statusText(names: string[]): string { |
|
if (names.length === 0) { |
|
return ''; |
|
} |
|
return `- Skills: ${names.map((name) => `$${name}`).join(' ')}`; |
|
} |
|
|
|
async function chooseSkill(ctx: PluginCommandContext, skills: Skill[]): Promise<string | undefined> { |
|
const options = skills.map((skill, index) => `${index + 1}. ${skill.name} — ${skill.description}`); |
|
const selected = await ctx.ui.select({ |
|
title: 'Select skill for next message', |
|
message: 'The selected skill is injected into the next user message in the active thread.', |
|
options, |
|
}); |
|
const selectedIndex = selected ? options.indexOf(selected) : -1; |
|
|
|
return selectedIndex >= 0 ? skills[selectedIndex]?.name : undefined; |
|
} |
|
|
|
export default function (amp: PluginAPI) { |
|
let skills = loadSkills(); |
|
let skillsByName = new Map(skills.map((skill) => [skill.name, skill])); |
|
let pendingSkillsForNextThread: string[] = []; |
|
let cancelCommand: CommandSubscription | undefined; |
|
let showPendingCommand: CommandSubscription | undefined; |
|
let skillCommands: CommandSubscription[] = []; |
|
let statusItem: StatusItem | undefined; |
|
|
|
function refreshSkills() { |
|
skills = loadSkills(); |
|
skillsByName = new Map(skills.map((skill) => [skill.name, skill])); |
|
registerSkillCommands(); |
|
} |
|
|
|
function addPendingSkill(threadID: string | undefined, skillName: string): string[] { |
|
if (!threadID) { |
|
pendingSkillsForNextThread = unique([...pendingSkillsForNextThread, skillName]); |
|
updatePendingCommandAvailability(); |
|
updatePendingStatus(); |
|
return pendingSkillsForNextThread; |
|
} |
|
|
|
const pending = pendingSkillsByThread.get(threadID) ?? []; |
|
const nextPending = unique([...pending, skillName]); |
|
pendingSkillsByThread.set(threadID, nextPending); |
|
updatePendingCommandAvailability(); |
|
updatePendingStatus(); |
|
return nextPending; |
|
} |
|
|
|
function activeThreadID(ctx: PluginCommandContext): string | undefined { |
|
return ctx.thread?.id ?? amp.activeThread.current?.id; |
|
} |
|
|
|
function pendingSkills(ctx?: PluginCommandContext): string[] { |
|
const threadID = ctx ? activeThreadID(ctx) : amp.activeThread.current?.id; |
|
return unique([ |
|
...pendingSkillsForNextThread, |
|
...(threadID ? (pendingSkillsByThread.get(threadID) ?? []) : []), |
|
]); |
|
} |
|
|
|
function updatePendingCommandAvailability() { |
|
const hasPending = pendingSkills().length > 0; |
|
const availability = hasPending |
|
? { type: 'enabled' as const } |
|
: { type: 'disabled' as const, reason: 'No pending skills' }; |
|
cancelCommand?.setAvailability(availability); |
|
showPendingCommand?.setAvailability(availability); |
|
} |
|
|
|
function updatePendingStatus() { |
|
if (!amp.experimental) { |
|
return; |
|
} |
|
|
|
const pending = pendingSkills(); |
|
if (pending.length === 0) { |
|
statusItem?.unsubscribe(); |
|
statusItem = undefined; |
|
return; |
|
} |
|
|
|
if (!statusItem && pending.length > 0) { |
|
statusItem = amp.experimental.createStatusItem(); |
|
} |
|
|
|
statusItem?.update({ |
|
text: statusText(pending), |
|
url: 'command:skill-invoker.show-pending', |
|
}); |
|
} |
|
|
|
function registerSkillCommands() { |
|
for (const command of skillCommands) { |
|
command.unsubscribe(); |
|
} |
|
skillCommands = []; |
|
|
|
for (const skill of skills) { |
|
skillCommands.push( |
|
amp.registerCommand( |
|
`skill-invoker.invoke.${skill.name}`, |
|
{ |
|
title: skill.name, |
|
category: 'Skill', |
|
description: skill.description, |
|
}, |
|
async (ctx) => { |
|
const threadID = activeThreadID(ctx); |
|
const pending = addPendingSkill(threadID, skill.name); |
|
await ctx.ui.notify( |
|
`${statusText(pending)} selected for next message. Run “Skill: Cancel pending skills” to cancel.`, |
|
); |
|
}, |
|
), |
|
); |
|
} |
|
} |
|
|
|
amp.activeThread.subscribe(() => updatePendingStatus()); |
|
|
|
amp.registerCommand( |
|
'skill-invoker.select', |
|
{ |
|
title: 'Select skill…', |
|
category: 'Skill', |
|
description: 'Choose a skill to invoke with the next message.', |
|
}, |
|
async (ctx) => { |
|
refreshSkills(); |
|
const threadID = activeThreadID(ctx); |
|
if (skills.length === 0) { |
|
await ctx.ui.notify( |
|
'No skills found in ~/.config/agents/skills, ~/.codex/skills, or ~/.claude/skills.', |
|
); |
|
return; |
|
} |
|
|
|
const skillName = await chooseSkill(ctx, skills); |
|
if (!skillName) { |
|
return; |
|
} |
|
|
|
const pending = addPendingSkill(threadID, skillName); |
|
await ctx.ui.notify( |
|
`${statusText(pending)} selected for next message. Run “Skill: Cancel pending skills” to cancel.`, |
|
); |
|
}, |
|
); |
|
|
|
cancelCommand = amp.registerCommand( |
|
'skill-invoker.cancel', |
|
{ |
|
title: 'Cancel pending skills', |
|
category: 'Skill', |
|
description: 'Clear skills selected from the command palette before sending.', |
|
availability: { type: 'disabled', reason: 'No pending skills' }, |
|
}, |
|
async (ctx) => { |
|
const threadID = activeThreadID(ctx); |
|
if (threadID) { |
|
pendingSkillsByThread.delete(threadID); |
|
} |
|
pendingSkillsForNextThread = []; |
|
updatePendingCommandAvailability(); |
|
updatePendingStatus(); |
|
await ctx.ui.notify('Pending skills cleared.'); |
|
}, |
|
); |
|
|
|
showPendingCommand = amp.registerCommand( |
|
'skill-invoker.show-pending', |
|
{ |
|
title: 'Show pending skills', |
|
category: 'Skill', |
|
description: 'Show skills selected for the next message.', |
|
availability: { type: 'disabled', reason: 'No pending skills' }, |
|
}, |
|
async (ctx) => { |
|
await ctx.ui.notify(`${statusText(pendingSkills(ctx))} selected for next message.`); |
|
}, |
|
); |
|
|
|
amp.registerCommand( |
|
'skill-invoker.reload', |
|
{ |
|
title: 'Reload skill list', |
|
category: 'Skill', |
|
description: 'Rescan local skill directories.', |
|
}, |
|
async (ctx) => { |
|
refreshSkills(); |
|
await ctx.ui.notify(`Loaded ${skills.length} skills.`); |
|
}, |
|
); |
|
|
|
registerSkillCommands(); |
|
|
|
amp.on('agent.start', async (event: AgentStartEvent, _ctx: PluginEventContext<'agent.start'>) => { |
|
const pending = unique([ |
|
...pendingSkillsForNextThread, |
|
...(pendingSkillsByThread.get(event.thread.id) ?? []), |
|
]); |
|
const names = selectedSkillNames(skillsByName, event.message, pending); |
|
pendingSkillsForNextThread = []; |
|
pendingSkillsByThread.delete(event.thread.id); |
|
updatePendingCommandAvailability(); |
|
updatePendingStatus(); |
|
|
|
if (names.length === 0) { |
|
return {}; |
|
} |
|
|
|
const invokedSkills = names |
|
.map((name) => skillsByName.get(name)) |
|
.filter((skill): skill is Skill => Boolean(skill)); |
|
|
|
return { |
|
message: { |
|
content: buildSkillInvocationRequest(invokedSkills), |
|
display: false, |
|
}, |
|
}; |
|
}); |
|
|
|
amp.logger.log(`skill-invoker loaded ${skills.length} skills.`); |
|
} |