cd /news/developer-tools/amp-review-a-deterministic-amp-revie… · home topics developer-tools article
[ARTICLE · art-57300] src=gist.github.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

amp-review — a deterministic Amp review skill + invoker plugin

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.

read9 min views23 publishedJun 27, 2026

| 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(/[1]|["']$/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.); | | } |


  1. "' ↩︎

── more in #developer-tools 4 stories · sorted by recency
── more on @amp 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/amp-review-a-determi…] indexed:0 read:9min 2026-06-27 ·