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

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">
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.
In a session in acme/web:
| Written in a reply | Drawn as a link to |
|---|---|
#4 | https://github.com/acme/web/issues/4 |
PR#1, pull#1 | https://github.com/acme/web/pull/1 |
issue#2, issues#2 | https://github.com/acme/web/issues/2 |
api#3 | https://github.com/acme/api/issues/3, a repository of the same owner |
babarot/hoge#1 | https://github.com/babarot/hoge/issues/1, from any directory |
https://example.com/path | itself |
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./issues/123 also works for a pull request: GitHub redirects to it.., ,, an unbalanced )) and before non-ASCII text that follows it directly.These are left alone:
<https://...>)#12abc, #1-2), and a # right after & (a{)# inside a URL (https://example.com/a#12)Only the drawing changes. The stored message and what the model reads stay as written.
| Option | Default | What it does |
|---|---|---|
repoRefs | true | Link 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 |
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.
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.
MIT
hooks/register.tsx 42 lines1import 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}
42hooks/linkify.ts 174 lines1// 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{ 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