SLOPSHOPPER

wheel-finder

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…

newbandguardtool
A shopper browsing a rack in a slop shop
README

wheel-finder

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.

Install

/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.

What it does

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.

  • It does not search by itself. It registers one tool, 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.
  • The mod answers calls to its own tool, 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.
  • It draws above whatever other plugins show in the band above the prompt instead of replacing it, so it can sit next to other plugins that do the same.
  • The tool never installs or runs anything; it only displays links.
  • Links must be clean https URLs; anything else is dropped before drawing.
  • The tool description is fixed for the whole session, so using it does not change the cached prompt prefix.
  • Drawn with SVG on the desktop app; the terminal gets a plain-text fallback that I have not tested.

中文简介

对话里一旦要“做一个新东西”或需要某种能力,模型会先去 GitHub 搜有没有现成的项目、MCP、Skill 或插件,把最合适的 1 到 3 个放在输入框上方,可翻页、可点开、可清除。它只显示链接,不会安装或运行任何东西。

License

MIT

Source 2 files
hooks/register.tsx 180 lines
1import { 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}
180
types/index.d.ts 18 lines
1export 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