SLOPSHOPPER

linkify

Make bare URLs and GitHub references (#123, PR#123, repo#123, owner/repo#123) in Claude's replies clickable

newrows
★ 1v0.2.0MITupdated 2026-10-06babarot/claude-linkify
A shopper browsing a rack in a slop shop
README

claude-linkify

A Claude Code mod that makes links in Claude's replies clickable: bare URLs, and GitHub issue and pull request references like #123, PR#123, repo#123 and owner/repo#123.

<img alt="Claude Code replying #1 and babarot/gh-infra#123, both drawn as links" src="docs/screenshot.png" width="600">

Install

claude plugin marketplace add babarot/claude-linkify
claude plugin install linkify@claude-linkify

Then start a new session, or run /reload-plugins in one.

It needs a Claude Code version with mods (TypeScript plugin hooks). Tested with Claude Code 2.1.291 on macOS.

What it links

In a session in acme/web:

Written in a replyDrawn as a link to
#4https://github.com/acme/web/issues/4
PR#1, pull#1https://github.com/acme/web/pull/1
issue#2, issues#2https://github.com/acme/web/issues/2
api#3https://github.com/acme/api/issues/3, a repository of the same owner
babarot/hoge#1https://github.com/babarot/hoge/issues/1, from any directory
https://example.com/pathitself
  • The session repository is read from the origin remote of the session's working directory, again whenever the directory changes. Outside a git repository, or when origin is not on github.com, only owner/repo#123 and URLs are linked.
  • PR, pull, issue and issues before #123 (any case) say what #123 is in the session repository. Any other name is taken as a repository of the session repository's owner, except words no repository is named after, such as step, no, line or version, which stay text.
  • A link to /issues/123 also works for a pull request: GitHub redirects to it.
  • A URL stops before punctuation that ends the sentence (., ,, an unbalanced )) and before non-ASCII text that follows it directly.

These are left alone:

  • Fenced code blocks and inline code
  • Existing markdown links and autolinks (<https://...>)
  • References with a word character right after them (#12abc, #1-2), and a # right after & (a&#123;)
  • A # inside a URL (https://example.com/a#12)

Only the drawing changes. The stored message and what the model reads stay as written.

Settings

OptionDefaultWhat it does
repoRefstrueLink repo#123 to a repository of the session repository's owner. Turn it off in /config if names before # in your replies link to repositories that don't exist. PR#, issue# and #123 are linked either way

How it works

The mod hooks ui.render for AssistantMessage, rewrites the block's markdown with the links added, and hands it on to Claude Code's own renderer. It runs no commands and makes no network requests: the repository comes from $.session.repo().

The rewriting is a plain function in hooks/linkify.ts, with no dependency on Claude Code.

Develop

git clone https://github.com/babarot/claude-linkify
cd claude-linkify
claude --plugin-dir "$PWD"
claude plugin validate .
claude plugin test .

The editor types live in .claude-plugin/types/. Claude Code generates them when it loads the mod, and they are not committed.

Releases are made with tagpr. Merging its release PR bumps version in .claude-plugin/plugin.json, which is what makes claude plugin update pick up the new version, then tags vX.Y.Z and publishes a GitHub Release. Label a PR kind/feature or release/minor for a minor release, kind/breaking-change or release/major for a major one.

License

MIT

Source 2 files
hooks/register.tsx 42 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { linkify, parseGitHubRepo } from './linkify'
4
5// Replies are drawn again on every scroll and resize, so the result for a
6// text is kept; the oldest entries go once there are this many.
7const CACHE_SIZE = 500
8
9let repoFor: { cwd: string; repo: string | null } | undefined
10const cache = new Map<string, string>()
11
12// The repository #123 points into: the session's, read again when the
13// working directory moves (into another worktree or repository).
14async function currentRepo($: EngineInterface): Promise<string | null> {
15  const cwd = await $.session.cwd()
16  if (repoFor?.cwd !== cwd) {
17    const repo = await $.session.repo().catch(() => null)
18    repoFor = { cwd, repo: parseGitHubRepo(repo?.remote) }
19  }
20  return repoFor.repo
21}
22
23export const register: Register = (on, options) => {
24  // A change in /config reloads the module, so this is read once.
25  const linkifyOptions = { repoRefs: options.repoRefs !== false }
26
27  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
28    const repo = await currentRepo($)
29    const key = `${repo ?? ''}\n${e.props.text}`
30    let text = cache.get(key)
31    if (text === undefined) {
32      text = linkify(e.props.text, repo, linkifyOptions)
33      cache.set(key, text)
34      if (cache.size > CACHE_SIZE) cache.delete(cache.keys().next().value!)
35    }
36    if (text === e.props.text) return next(e)
37    // Only the drawing changes; the stored message and what the model
38    // reads stay as written.
39    return next({ ...e, props: { ...e.props, text } })
40  })
41}
42
hooks/linkify.ts 174 lines
1// Turns bare URLs and GitHub references in markdown into markdown links,
2// leaving code (fenced blocks, inline spans) and existing links alone.
3
4const FENCE = /^ {0,3}(`{3,}|~{3,})/
5
6// What must not be touched on a line: inline code, markdown links and
7// images, and autolinks. Each is copied through as written.
8const PROTECTED = /(`+)[^`]*?\1|!?\[[^\]\n]*\]\([^)\n]*\)|<[a-z][a-z0-9+.-]*:[^>\s]*>/gi
9
10// A bare URL, ASCII only so it stops at Japanese text that follows it
11// ("https://example.comを"), or a reference: #123, owner/repo#123 or
12// name#123, not preceded by a word character (so a&#123; and the repo in
13// owner/repo#1 stay out) and not followed by one.
14const TOKEN =
15  /(https?:\/\/[A-Za-z0-9\-._~:/?#@!$&'()*+,;=%]+)|(^|[^A-Za-z0-9_/#&@.-])(?:([A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+)|([A-Za-z0-9][A-Za-z0-9._-]*))?#(\d+)(?![A-Za-z0-9_-])/g
16
17// name#123 where the name says what kind of thing #123 is in the session
18// repository, rather than naming another repository.
19const KINDS: Record<string, 'pull' | 'issues'> = {
20  pr: 'pull',
21  pull: 'pull',
22  issue: 'issues',
23  issues: 'issues',
24}
25
26// Words written before #123 that are no repository's name; those stay text
27// instead of linking to a repository that does not exist.
28const NOT_REPOS = new Set([
29  'case',
30  'chapter',
31  'fig',
32  'figure',
33  'id',
34  'item',
35  'line',
36  'mr',
37  'no',
38  'number',
39  'option',
40  'page',
41  'part',
42  'phase',
43  'round',
44  'row',
45  'section',
46  'step',
47  'task',
48  'test',
49  'ticket',
50  'v',
51  'version',
52])
53
54export type LinkifyOptions = {
55  /** name#123 links to the repository `name` of the session repository's owner. */
56  repoRefs: boolean
57}
58
59/** The `owner/repo` of a github.com remote URL, or null for any other host. */
60export function parseGitHubRepo(remote: string | null | undefined): string | null {
61  if (!remote) return null
62  const match =
63    /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?github\.com[:/](?::\d+\/)?([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/.exec(
64      remote.trim(),
65    )
66  return match ? `${match[1]}/${match[2]}` : null
67}
68
69/** A URL without the punctuation that ends the sentence around it. */
70function trimUrl(url: string): string {
71  let end = url.length
72  for (;;) {
73    const last = url[end - 1] ?? ''
74    if (/[.,;:!?'*]/.test(last)) {
75      end--
76      continue
77    }
78    if (last === ')') {
79      const body = url.slice(0, end)
80      if (body.split('(').length < body.split(')').length) {
81        end--
82        continue
83      }
84    }
85    return url.slice(0, end)
86  }
87}
88
89function link(text: string, href: string): string {
90  const label = text.replace(/([\\*_~[\]])/g, '\\$1')
91  const dest = /[()\s]/.test(href) ? `<${href}>` : href
92  return `[${label}](${dest})`
93}
94
95/** Where a reference points, or null when it stays text. */
96function resolveRef(
97  qualified: string | undefined,
98  name: string | undefined,
99  num: string,
100  repo: string | null,
101  options: LinkifyOptions,
102): string | null {
103  // /issues/<n> redirects to the pull request when the number is one.
104  if (qualified !== undefined) return `https://github.com/${qualified}/issues/${num}`
105  if (repo === null) return null
106  if (name === undefined) return `https://github.com/${repo}/issues/${num}`
107  const lower = name.toLowerCase()
108  const kind = KINDS[lower]
109  if (kind !== undefined) return `https://github.com/${repo}/${kind}/${num}`
110  if (!options.repoRefs || NOT_REPOS.has(lower) || /^\d+$/.test(name)) return null
111  const owner = repo.slice(0, repo.indexOf('/'))
112  return `https://github.com/${owner}/${name}/issues/${num}`
113}
114
115function linkifyPlain(text: string, repo: string | null, options: LinkifyOptions): string {
116  return text.replace(
117    TOKEN,
118    (
119      whole,
120      url: string | undefined,
121      before: string,
122      qualified: string | undefined,
123      name: string | undefined,
124      num: string,
125    ) => {
126      if (url !== undefined) {
127        const trimmed = trimUrl(url)
128        return link(trimmed, trimmed) + url.slice(trimmed.length)
129      }
130      const href = resolveRef(qualified, name, num, repo, options)
131      if (href === null) return whole
132      return before + link(`${qualified ?? name ?? ''}#${num}`, href)
133    },
134  )
135}
136
137function linkifyLine(line: string, repo: string | null, options: LinkifyOptions): string {
138  let out = ''
139  let last = 0
140  for (const match of line.matchAll(PROTECTED)) {
141    out += linkifyPlain(line.slice(last, match.index), repo, options) + match[0]
142    last = match.index + match[0].length
143  }
144  return out + linkifyPlain(line.slice(last), repo, options)
145}
146
147/**
148 * The markdown with bare URLs and issue references made links. `repo` is
149 * the session's `owner/repo`, which #123, PR#123 and name#123 point into;
150 * null leaves them as text.
151 */
152export function linkify(markdown: string, repo: string | null, options: LinkifyOptions = { repoRefs: true }): string {
153  if (!markdown.includes('http') && !markdown.includes('#')) return markdown
154  let fence: string | null = null
155  return markdown
156    .split('\n')
157    .map(line => {
158      const marker = FENCE.exec(line)?.[1]
159      if (fence !== null) {
160        // A fence closes with the same character, at least as many times.
161        if (marker !== undefined && marker[0] === fence[0] && marker.length >= fence.length) fence = null
162        return line
163      }
164      if (marker !== undefined) {
165        fence = marker
166        return line
167      }
168      // Indented code blocks are not told apart from nested list items,
169      // which replies use far more; both are linkified.
170      return linkifyLine(line, repo, options)
171    })
172    .join('\n')
173}
174