Don't reinvent the wheel: finds existing GitHub projects, MCP servers, skills and plugins that fit the conversation and shows them in a small pager above the…

Don't reinvent the wheel. A Claude Code mod that finds existing projects before you rebuild them.
A mod is code that runs inside your Claude Code session with the same access Claude Code has. Read the source before you install it; this one is a single short file, plugins/wheel-finder/hooks/register.tsx.
/plugin marketplace add YanZiBin/wheel-finder
/plugin install wheel-finder@wheel-finder
/reload-plugins
Needs a Claude Code build that supports mods (2.1.287 or later). The mod API can change between releases.
Stops you rebuilding what already exists. Whenever the conversation touches something a GitHub project, MCP server, skill or plugin might plausibly already do, the model searches for it (for example with gh search repos) and puts the best one to three candidates in a small pager above the prompt.
Each candidate shows a kind tag (Project, MCP, Skill, Plugin, Other), the name as a link, stars, last update, license, and a one-line reason. ‹ 1/3 › pages through them and × clears the list. Links you clear are remembered for the session and are filtered out if the model suggests them again.
mcp__wheel-finder__set_links, and its description tells the model when to search and to call it. The model decides, so how often it fires depends on how well the model follows that description.mcp__wheel-finder__set_links, itself: it stores the list and returns a short status line. It does not stand in for any other tool.https URLs; anything else is dropped before drawing.对话里一旦要“做一个新东西”或需要某种能力,模型会先去 GitHub 搜有没有现成的项目、MCP、Skill 或插件,把最合适的 1 到 3 个放在输入框上方,可翻页、可点开、可清除。它只显示链接,不会安装或运行任何东西。
MIT
hooks/register.tsx 180 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Kind, Suggestion } from '../types'
5
6const TOOL = 'mcp__wheel-finder__set_links'
7const items = atom({ plugin: 'wheel-finder', key: 'items' } as const, [] as Suggestion[])
8const page = atom({ plugin: 'wheel-finder', key: 'page' } as const, 0)
9const dismissed = atom({ plugin: 'wheel-finder', key: 'dismissed' } as const, [] as string[])
10
11// the description is fixed for the whole session, so registering it never changes the cached prompt prefix
12const DESCRIPTION = `Show the user existing open-source options above their prompt, so they do not rebuild what already exists. The user clicks a link to read more; this tool never installs or runs anything.
13
14Be proactive. Whenever the conversation touches something an open-source project, library, tool, MCP server, skill or plugin might plausibly already do, run a quick search (for example \`gh search repos\` or a web search; one or two queries, do not let it hold up your answer), check what you found, then call this tool with the 1 to 3 best candidates. That includes: a feature or tool the user wants built, a file format or conversion, an integration with a service, a workflow or automation, a capability you are about to hand-write code for, and a problem that many people have probably solved. Also search when the user only mentions the need in passing.
15
16Skip it for: small edits or bug fixes in the user's own code, questions that need only an explanation, writing and editing text, and logic that is specific to the user's private project. Do not search the same topic twice, and do not call it when nothing good turns up.
17
18Replace the whole list on every call, and call it with an empty list when the topic has moved on. Prefer projects that are maintained and have a clear license; fill stars, updated and license only from what you saw. Only use URLs you actually saw in search results or on the page; never write a URL from memory. Keep "why" to one short line about why it fits this conversation. If the result says some links were skipped because the user dismissed them, do not bring them back.`
19
20const SCHEMA = {
21 type: 'object',
22 properties: {
23 items: {
24 type: 'array',
25 maxItems: 3,
26 items: {
27 type: 'object',
28 properties: {
29 name: { type: 'string', description: 'Short name, e.g. "owner/repo".' },
30 url: { type: 'string', description: 'https URL you actually saw in a search result.' },
31 kind: { type: 'string', enum: ['mcp', 'skill', 'plugin', 'project', 'other'] },
32 why: { type: 'string', description: 'One short line: why it fits this conversation.' },
33 stars: { type: 'string', description: 'e.g. "12.3k", only if seen.' },
34 updated: { type: 'string', description: 'e.g. "2026-09", only if seen.' },
35 license: { type: 'string', description: 'e.g. "MIT", only if seen.' }
36 },
37 required: ['name', 'url', 'kind', 'why']
38 }
39 }
40 },
41 required: ['items']
42}
43
44const KINDS: Kind[] = ['mcp', 'skill', 'plugin', 'project', 'other']
45const KIND_COLOR: Record<Kind, string> = { mcp: 'magenta', skill: 'cyan', plugin: 'blue', project: 'green', other: 'gray' }
46const KIND_HEX: Record<Kind, string> = { mcp: '#8B6CD9', skill: '#1F9EB0', plugin: '#3B7FD8', project: '#2FA062', other: '#7D7D78' }
47
48// a soft pill: the colour at low opacity behind a bold sans label; an image, so it reads in light and dark
49const chip = (label: string, hex: string) => {
50 const w = Math.round(label.length * 7.4 + 18)
51 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="20" viewBox="0 0 ${w} 20"><rect width="${w}" height="20" rx="6" fill="${hex}" fill-opacity="0.16" stroke="${hex}" stroke-opacity="0.55"/><text x="${w / 2}" y="14" text-anchor="middle" font-family="-apple-system, 'PingFang SC', 'Helvetica Neue', Arial, sans-serif" font-size="11.5" font-weight="700" letter-spacing="0.3" fill="${hex}">${label}</text></svg>`
52}
53
54const KIND_LABEL: Record<Kind, string> = { mcp: 'MCP', skill: 'Skill', plugin: 'Plugin', project: 'Project', other: 'Other' }
55
56// a Link with a bad href refuses the whole tree, so the URL is normalised the way `new URL(href).href` spells it:
57// https only, no user info, ASCII only (non-ASCII is percent-encoded by URL, a raw "@" is encoded here)
58const cleanUrl = (raw: unknown): string | null => {
59 if (typeof raw !== 'string') return null
60 try {
61 const u = new URL(raw.trim())
62 if (u.protocol !== 'https:' || u.username || u.password) return null
63 const href = u.href.replace(/@/g, '%40')
64 return href.length <= 2048 && /^[\x21-\x7e]+$/.test(href) ? href : null
65 } catch {
66 return null
67 }
68}
69
70// the host is shown next to any link that is not on github.com, so a look-alike address is visible before the click
71const hostOf = (href: string) => {
72 try {
73 return new URL(href).hostname.replace(/^www\./, '')
74 } catch {
75 return ''
76 }
77}
78
79// links the user cleared are compared by a loose key, so a trailing slash or a fragment does not bring one back
80const keyOf = (href: string) => href.replace(/[?#].*$/, '').replace(/\/+$/, '').toLowerCase()
81
82const str = (v: unknown, max: number) => (typeof v === 'string' ? v.trim().slice(0, max) : '')
83
84export const register: Register = on => {
85 on('session.start', async ($, e, next) => {
86 const result = await next(e)
87 await $.tool.register({ name: 'set_links', description: DESCRIPTION, inputSchema: SCHEMA })
88 return result
89 })
90
91 on('tool.call', { tool: TOOL }, async ($, e) => {
92 let given: unknown = e.items
93 if (typeof given === 'string') {
94 try {
95 given = JSON.parse(given) // some models send the array as a JSON string
96 } catch {
97 given = []
98 }
99 }
100 const raw = (Array.isArray(given) ? given : []).filter(x => x && typeof x === 'object') as Record<string, unknown>[]
101 const clean: Suggestion[] = []
102 for (const r of raw.slice(0, 3)) {
103 const href = cleanUrl(r.url)
104 const name = str(r.name, 80)
105 if (!href || !name) continue
106 const kind = KINDS.includes(r.kind as Kind) ? (r.kind as Kind) : 'other'
107 clean.push({
108 name,
109 href,
110 kind,
111 why: str(r.why, 140),
112 stars: str(r.stars, 12) || undefined,
113 updated: str(r.updated, 12) || undefined,
114 license: str(r.license, 24) || undefined
115 })
116 }
117 const gone = new Set(await read($, dismissed))
118 const shown = clean.filter(c => !gone.has(keyOf(c.href)))
119 const skipped = clean.length - shown.length
120 await update($, items, () => shown)
121 await update($, page, () => 0)
122 const dropped = raw.length - clean.length
123 return {
124 result:
125 `Shown: ${shown.length}.` +
126 (skipped > 0 ? ` Skipped ${skipped} the user already dismissed; do not suggest them again.` : '') +
127 (dropped > 0 ? ` Dropped ${dropped} with a missing name or a URL that is not a clean https link.` : '')
128 }
129 })
130
131 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
132 // what the plugins beneath drew (or nothing): ours goes above it instead of replacing it
133 const below = await next(e)
134 const list = await read($, items)
135 if (e.props.hasSurvey || list.length === 0) return below
136
137 const { Box, Button, Link, Svg, Text } = $.ui.resolve(e)
138 const at = Math.min(Math.max(await read($, page), 0), list.length - 1)
139 const s = list[at]
140 const host = hostOf(s.href)
141 const meta = [host !== 'github.com' && host !== '' ? `↗ ${host}` : '', s.updated, s.license].filter(Boolean).join(' · ')
142 const dismiss = async () => {
143 await update($, dismissed, (old: string[]) => [...old, ...list.map(x => keyOf(x.href))])
144 await update($, items, () => [])
145 }
146 const go = (to: number) => update($, page, () => (to + list.length) % list.length)
147
148 const mine = (
149 <Box flexDirection="column" paddingX={1}>
150 <Box flexDirection="row" alignItems="center" justifyContent="space-between">
151 <Box flexDirection="row" alignItems="center" gap={1}>
152 {Svg ? (
153 <Svg
154 source={chip(KIND_LABEL[s.kind], KIND_HEX[s.kind])}
155 alt={KIND_LABEL[s.kind]}
156 width={Math.round(KIND_LABEL[s.kind].length * 7.4 + 18)}
157 height={20}
158 />
159 ) : (
160 <Text inverse bold color={KIND_COLOR[s.kind]}>{` ${KIND_LABEL[s.kind]} `}</Text>
161 )}
162 <Link href={s.href} label={s.name} />
163 {s.stars && <Text color="#C4923A">{`★ ${s.stars}`}</Text>}
164 {meta !== '' && <Text dimColor>{meta}</Text>}
165 </Box>
166 <Box flexDirection="row" alignItems="center" gap={1}>
167 {list.length > 1 && <Button key="prev" plain dimColor label="‹" onPress={() => go(at - 1)} />}
168 {list.length > 1 && <Text dimColor>{`${at + 1}/${list.length}`}</Text>}
169 {list.length > 1 && <Button key="next" plain dimColor label="›" onPress={() => go(at + 1)} />}
170 <Button key="clear" plain dimColor label="×" onPress={() => dismiss()} />
171 </Box>
172 </Box>
173 {s.why !== '' && <Text dimColor wrap="truncate">{`↳ ${s.why}`}</Text>}
174 </Box>
175 )
176
177 return below ? <Box flexDirection="column">{mine}{below}</Box> : mine
178 })
179}
180types/index.d.ts 18 lines1export type Kind = 'mcp' | 'skill' | 'plugin' | 'project' | 'other'
2
3export type Suggestion = {
4 name: string
5 href: string
6 kind: Kind
7 why: string
8 stars?: string
9 updated?: string
10 license?: string
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'wheel-finder': { items: Suggestion[]; page: number; dismissed: string[] }
16 }
17}
18