{"slug": "amp-review-a-deterministic-amp-review-skill-invoker-plugin", "title": "amp-review — a deterministic Amp review skill + invoker plugin", "summary": "A developer created amp-review, a deterministic skill review and invoker plugin for the Amp code editor. The plugin loads skills from multiple directories, parses SKILL.md files, and allows users to invoke skills via $skillname tokens in messages. It provides a deterministic way to manage and invoke skills within the Amp environment.", "body_md": "|\nimport type { |\n|\nAgentStartEvent, |\n|\nCommandSubscription, |\n|\nPluginAPI, |\n|\nPluginCommandContext, |\n|\nPluginEventContext, |\n|\nStatusItem, |\n|\n} from '@ampcode/plugin'; |\n|\nimport { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; |\n|\nimport { basename, join } from 'node:path'; |\n|\n|\n|\ntype Skill = { |\n|\nname: string; |\n|\ndescription: string; |\n|\npath: string; |\n|\nbody: string; |\n|\n}; |\n|\n|\n|\nconst SKILL_DIRS = [ |\n|\n'~/.agents/skills', |\n|\n'~/.config/amp/skills', |\n|\n'~/.config/agents/skills', |\n|\n'~/.codex/skills', |\n|\n'~/.claude/skills', |\n|\n'.agents/skills', |\n|\n'.claude/skills', |\n|\n]; |\n|\n|\n|\nconst SKILL_TOKEN = /(?:^|\\s)\\$([a-zA-Z0-9][a-zA-Z0-9_-]*)(?=\\s|$)/g; |\n|\n|\n|\nconst pendingSkillsByThread = new Map<string, string[]>(); |\n|\n|\n|\nfunction homePath(path: string): string { |\n|\nif (path === '~') { |\n|\nreturn process.env.HOME || path; |\n|\n} |\n|\nif (path.startsWith('~/')) { |\n|\nconst home = process.env.HOME; |\n|\nreturn home ? join(home, path.slice(2)) : path; |\n|\n} |\n|\nreturn path; |\n|\n} |\n|\n|\n|\nfunction frontmatterValue(frontmatter: string, key: string): string | undefined { |\n|\nconst value = frontmatter.match(new RegExp(`^${key}:\\\\s*(.+)$`, 'm'))?.[1]?.trim(); |\n|\nreturn value?.replace(/^[\"']|[\"']$/g, ''); |\n|\n} |\n|\n|\n|\nfunction unique<T>(values: T[]): T[] { |\n|\nreturn [...new Set(values)]; |\n|\n} |\n|\n|\n|\nfunction parseSkillFile(path: string, fallbackName: string): Skill | null { |\n|\nconst content = readFileSync(path, 'utf8'); |\n|\nconst frontmatterMatch = content.match(/^---\\r?\\n([\\s\\S]*?)\\r?\\n---\\r?\\n?([\\s\\S]*)$/); |\n|\nconst frontmatter = frontmatterMatch?.[1] ?? ''; |\n|\nconst body = (frontmatterMatch?.[2] ?? content).trim(); |\n|\nconst name = frontmatterValue(frontmatter, 'name') ?? fallbackName; |\n|\nconst description = frontmatterValue(frontmatter, 'description') ?? firstMeaningfulLine(body); |\n|\n|\n|\nif (!name) { |\n|\nreturn null; |\n|\n} |\n|\n|\n|\nreturn { name, description, path, body }; |\n|\n} |\n|\n|\n|\nfunction firstMeaningfulLine(content: string): string { |\n|\nreturn ( |\n|\ncontent |\n|\n.split('\\n') |\n|\n.map((line) => line.trim().replace(/^#+\\s*/, '')) |\n|\n.find((line) => line.length > 0) ?? 'Invoke this skill.' |\n|\n); |\n|\n} |\n|\n|\n|\nfunction loadSkills(): Skill[] { |\n|\nconst skills = new Map<string, Skill>(); |\n|\n|\n|\nfor (const configuredDir of SKILL_DIRS) { |\n|\nconst dir = homePath(configuredDir); |\n|\nif (!existsSync(dir)) { |\n|\ncontinue; |\n|\n} |\n|\n|\n|\nfor (const entry of readdirSync(dir)) { |\n|\nconst skillDir = join(dir, entry); |\n|\nconst skillFile = join(skillDir, 'SKILL.md'); |\n|\nif (!existsSync(skillFile) || !statSync(skillFile).isFile()) { |\n|\ncontinue; |\n|\n} |\n|\n|\n|\nconst skill = parseSkillFile(skillFile, basename(skillDir)); |\n|\nif (skill && !skills.has(skill.name)) { |\n|\nskills.set(skill.name, skill); |\n|\n} |\n|\n} |\n|\n} |\n|\n|\n|\nreturn [...skills.values()].sort((a, b) => a.name.localeCompare(b.name)); |\n|\n} |\n|\n|\n|\nfunction selectedSkillNames(skillsByName: Map<string, Skill>, message: string, pending: string[]): string[] { |\n|\nconst names = [...pending]; |\n|\n|\n|\nfor (const match of message.matchAll(SKILL_TOKEN)) { |\n|\nconst name = match[1]; |\n|\nif (skillsByName.has(name)) { |\n|\nnames.push(name); |\n|\n} |\n|\n} |\n|\n|\n|\nreturn unique(names); |\n|\n} |\n|\n|\n|\nfunction buildSkillInvocationRequest(skills: Skill[]): string { |\n|\nreturn [ |\n|\n`The user explicitly invoked ${skills.length === 1 ? 'this skill' : 'these skills'} for this turn: ${skills.map((skill) => skill.name).join(', ')}.`, |\n|\n\"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.\", |\n|\n...skills.map((skill) => `${skill.name}: ${skill.description}`), |\n|\n].join('\\n\\n'); |\n|\n} |\n|\n|\n|\nfunction statusText(names: string[]): string { |\n|\nif (names.length === 0) { |\n|\nreturn ''; |\n|\n} |\n|\nreturn `- Skills: ${names.map((name) => `$${name}`).join(' ')}`; |\n|\n} |\n|\n|\n|\nasync function chooseSkill(ctx: PluginCommandContext, skills: Skill[]): Promise<string | undefined> { |\n|\nconst options = skills.map((skill, index) => `${index + 1}. ${skill.name} — ${skill.description}`); |\n|\nconst selected = await ctx.ui.select({ |\n|\ntitle: 'Select skill for next message', |\n|\nmessage: 'The selected skill is injected into the next user message in the active thread.', |\n|\noptions, |\n|\n}); |\n|\nconst selectedIndex = selected ? options.indexOf(selected) : -1; |\n|\n|\n|\nreturn selectedIndex >= 0 ? skills[selectedIndex]?.name : undefined; |\n|\n} |\n|\n|\n|\nexport default function (amp: PluginAPI) { |\n|\nlet skills = loadSkills(); |\n|\nlet skillsByName = new Map(skills.map((skill) => [skill.name, skill])); |\n|\nlet pendingSkillsForNextThread: string[] = []; |\n|\nlet cancelCommand: CommandSubscription | undefined; |\n|\nlet showPendingCommand: CommandSubscription | undefined; |\n|\nlet skillCommands: CommandSubscription[] = []; |\n|\nlet statusItem: StatusItem | undefined; |\n|\n|\n|\nfunction refreshSkills() { |\n|\nskills = loadSkills(); |\n|\nskillsByName = new Map(skills.map((skill) => [skill.name, skill])); |\n|\nregisterSkillCommands(); |\n|\n} |\n|\n|\n|\nfunction addPendingSkill(threadID: string | undefined, skillName: string): string[] { |\n|\nif (!threadID) { |\n|\npendingSkillsForNextThread = unique([...pendingSkillsForNextThread, skillName]); |\n|\nupdatePendingCommandAvailability(); |\n|\nupdatePendingStatus(); |\n|\nreturn pendingSkillsForNextThread; |\n|\n} |\n|\n|\n|\nconst pending = pendingSkillsByThread.get(threadID) ?? []; |\n|\nconst nextPending = unique([...pending, skillName]); |\n|\npendingSkillsByThread.set(threadID, nextPending); |\n|\nupdatePendingCommandAvailability(); |\n|\nupdatePendingStatus(); |\n|\nreturn nextPending; |\n|\n} |\n|\n|\n|\nfunction activeThreadID(ctx: PluginCommandContext): string | undefined { |\n|\nreturn ctx.thread?.id ?? amp.activeThread.current?.id; |\n|\n} |\n|\n|\n|\nfunction pendingSkills(ctx?: PluginCommandContext): string[] { |\n|\nconst threadID = ctx ? activeThreadID(ctx) : amp.activeThread.current?.id; |\n|\nreturn unique([ |\n|\n...pendingSkillsForNextThread, |\n|\n...(threadID ? (pendingSkillsByThread.get(threadID) ?? []) : []), |\n|\n]); |\n|\n} |\n|\n|\n|\nfunction updatePendingCommandAvailability() { |\n|\nconst hasPending = pendingSkills().length > 0; |\n|\nconst availability = hasPending |\n|\n? { type: 'enabled' as const } |\n|\n: { type: 'disabled' as const, reason: 'No pending skills' }; |\n|\ncancelCommand?.setAvailability(availability); |\n|\nshowPendingCommand?.setAvailability(availability); |\n|\n} |\n|\n|\n|\nfunction updatePendingStatus() { |\n|\nif (!amp.experimental) { |\n|\nreturn; |\n|\n} |\n|\n|\n|\nconst pending = pendingSkills(); |\n|\nif (pending.length === 0) { |\n|\nstatusItem?.unsubscribe(); |\n|\nstatusItem = undefined; |\n|\nreturn; |\n|\n} |\n|\n|\n|\nif (!statusItem && pending.length > 0) { |\n|\nstatusItem = amp.experimental.createStatusItem(); |\n|\n} |\n|\n|\n|\nstatusItem?.update({ |\n|\ntext: statusText(pending), |\n|\nurl: 'command:skill-invoker.show-pending', |\n|\n}); |\n|\n} |\n|\n|\n|\nfunction registerSkillCommands() { |\n|\nfor (const command of skillCommands) { |\n|\ncommand.unsubscribe(); |\n|\n} |\n|\nskillCommands = []; |\n|\n|\n|\nfor (const skill of skills) { |\n|\nskillCommands.push( |\n|\namp.registerCommand( |\n|\n`skill-invoker.invoke.${skill.name}`, |\n|\n{ |\n|\ntitle: skill.name, |\n|\ncategory: 'Skill', |\n|\ndescription: skill.description, |\n|\n}, |\n|\nasync (ctx) => { |\n|\nconst threadID = activeThreadID(ctx); |\n|\nconst pending = addPendingSkill(threadID, skill.name); |\n|\nawait ctx.ui.notify( |\n|\n`${statusText(pending)} selected for next message. Run “Skill: Cancel pending skills” to cancel.`, |\n|\n); |\n|\n}, |\n|\n), |\n|\n); |\n|\n} |\n|\n} |\n|\n|\n|\namp.activeThread.subscribe(() => updatePendingStatus()); |\n|\n|\n|\namp.registerCommand( |\n|\n'skill-invoker.select', |\n|\n{ |\n|\ntitle: 'Select skill…', |\n|\ncategory: 'Skill', |\n|\ndescription: 'Choose a skill to invoke with the next message.', |\n|\n}, |\n|\nasync (ctx) => { |\n|\nrefreshSkills(); |\n|\nconst threadID = activeThreadID(ctx); |\n|\nif (skills.length === 0) { |\n|\nawait ctx.ui.notify( |\n|\n'No skills found in ~/.config/agents/skills, ~/.codex/skills, or ~/.claude/skills.', |\n|\n); |\n|\nreturn; |\n|\n} |\n|\n|\n|\nconst skillName = await chooseSkill(ctx, skills); |\n|\nif (!skillName) { |\n|\nreturn; |\n|\n} |\n|\n|\n|\nconst pending = addPendingSkill(threadID, skillName); |\n|\nawait ctx.ui.notify( |\n|\n`${statusText(pending)} selected for next message. Run “Skill: Cancel pending skills” to cancel.`, |\n|\n); |\n|\n}, |\n|\n); |\n|\n|\n|\ncancelCommand = amp.registerCommand( |\n|\n'skill-invoker.cancel', |\n|\n{ |\n|\ntitle: 'Cancel pending skills', |\n|\ncategory: 'Skill', |\n|\ndescription: 'Clear skills selected from the command palette before sending.', |\n|\navailability: { type: 'disabled', reason: 'No pending skills' }, |\n|\n}, |\n|\nasync (ctx) => { |\n|\nconst threadID = activeThreadID(ctx); |\n|\nif (threadID) { |\n|\npendingSkillsByThread.delete(threadID); |\n|\n} |\n|\npendingSkillsForNextThread = []; |\n|\nupdatePendingCommandAvailability(); |\n|\nupdatePendingStatus(); |\n|\nawait ctx.ui.notify('Pending skills cleared.'); |\n|\n}, |\n|\n); |\n|\n|\n|\nshowPendingCommand = amp.registerCommand( |\n|\n'skill-invoker.show-pending', |\n|\n{ |\n|\ntitle: 'Show pending skills', |\n|\ncategory: 'Skill', |\n|\ndescription: 'Show skills selected for the next message.', |\n|\navailability: { type: 'disabled', reason: 'No pending skills' }, |\n|\n}, |\n|\nasync (ctx) => { |\n|\nawait ctx.ui.notify(`${statusText(pendingSkills(ctx))} selected for next message.`); |\n|\n}, |\n|\n); |\n|\n|\n|\namp.registerCommand( |\n|\n'skill-invoker.reload', |\n|\n{ |\n|\ntitle: 'Reload skill list', |\n|\ncategory: 'Skill', |\n|\ndescription: 'Rescan local skill directories.', |\n|\n}, |\n|\nasync (ctx) => { |\n|\nrefreshSkills(); |\n|\nawait ctx.ui.notify(`Loaded ${skills.length} skills.`); |\n|\n}, |\n|\n); |\n|\n|\n|\nregisterSkillCommands(); |\n|\n|\n|\namp.on('agent.start', async (event: AgentStartEvent, _ctx: PluginEventContext<'agent.start'>) => { |\n|\nconst pending = unique([ |\n|\n...pendingSkillsForNextThread, |\n|\n...(pendingSkillsByThread.get(event.thread.id) ?? []), |\n|\n]); |\n|\nconst names = selectedSkillNames(skillsByName, event.message, pending); |\n|\npendingSkillsForNextThread = []; |\n|\npendingSkillsByThread.delete(event.thread.id); |\n|\nupdatePendingCommandAvailability(); |\n|\nupdatePendingStatus(); |\n|\n|\n|\nif (names.length === 0) { |\n|\nreturn {}; |\n|\n} |\n|\n|\n|\nconst invokedSkills = names |\n|\n.map((name) => skillsByName.get(name)) |\n|\n.filter((skill): skill is Skill => Boolean(skill)); |\n|\n|\n|\nreturn { |\n|\nmessage: { |\n|\ncontent: buildSkillInvocationRequest(invokedSkills), |\n|\ndisplay: false, |\n|\n}, |\n|\n}; |\n|\n}); |\n|\n|\n|\namp.logger.log(`skill-invoker loaded ${skills.length} skills.`); |\n|\n} |", "url": "https://wpnews.pro/news/amp-review-a-deterministic-amp-review-skill-invoker-plugin", "canonical_source": "https://gist.github.com/jelenv/d370fda262b49c0337047f1f961d505e", "published_at": "2026-06-27 21:30:39+00:00", "updated_at": "2026-07-13 12:40:14.471629+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Amp"], "alternates": {"html": "https://wpnews.pro/news/amp-review-a-deterministic-amp-review-skill-invoker-plugin", "markdown": "https://wpnews.pro/news/amp-review-a-deterministic-amp-review-skill-invoker-plugin.md", "text": "https://wpnews.pro/news/amp-review-a-deterministic-amp-review-skill-invoker-plugin.txt", "jsonld": "https://wpnews.pro/news/amp-review-a-deterministic-amp-review-skill-invoker-plugin.jsonld"}}