Sidebar of your assigned Linear issues; Start creates a git worktree and begins implementing the issue.

A Claude Code mod that shows the Linear issues assigned to you in a sidebar. Press Start on an issue to create a git worktree for it and have Claude implement it there.

My issues 15:47 ⟳
All 51 Todo 25 In progress 9 In review 4 Done 44
◐ In Progress 9
╭────────────────────────────────────────────────╮
│ ▮▮▮ ENG-1930 3 pts │
│ Login page ignores the remember-me checkb… │
│ ◐ In Progress Acme ▶ Start │
╰────────────────────────────────────────────────╯
<repo>/.claude/worktrees/<issue-id> on the branch name Linear suggests, moves the issue to In Progress, and starts the implementation./linear refresh syncs on demand./mcp to check."tui": "fullscreen" in ~/.claude/settings.json and a terminal at least 144 columns wide. On narrower terminals, type /linear to open it.In a terminal, run:
claude plugin marketplace add omeerdvrn/o-mods
claude plugin install linear@o-mods
The first command adds the marketplace and is needed only once. Mods install at user scope, so they load in every session, including the desktop app's Code tab; add --scope project to install one for the current repository only. Run /reload-plugins in any open session to load it there.
| Action | How |
|---|---|
| Open the sidebar | It opens by itself on wide terminals, or type /linear |
| Sync now | Press ⟳, or type /linear refresh |
| Filter | Press All, Todo, In progress, In review, or Done |
| See an issue in full | Click its title. Press ✕ to close it. |
| Start work | Press ▶ Start on the card |
Change these in /config under linear.
| Setting | Default | What it does |
|---|---|---|
repoPath | session folder | The git repository to create worktrees in. |
baseRef | HEAD | The branch or commit that new issue branches start from. |
startMode | session | session: this session switches into the worktree and implements the issue. agent: a background agent implements it in the worktree. |
moveToInProgress | true | Sets the issue to In Progress in Linear when you press Start. |
linearServer | claude.ai Linear | The name of your Linear MCP server, as /mcp lists it. |
statusColors | empty | Colors for custom statuses, as Name=#HEX pairs: Blocked=#EB5757, Test=#26B5CE. |
Linear's MCP server does not return status colors. The mod uses Linear's default colors for the standard statuses (Triage, Backlog, Todo, In Progress, In Review, Done, Canceled). Set statusColors for any custom statuses.
Add .claude/worktrees/ to the .gitignore of the repositories you use this with.
Run the mod from a local clone:
claude --plugin-dir /path/to/o-mods/mods/linear
The session reloads the mod when you save a file. To check your changes, run these from this folder:
claude plugin validate .
claude plugin test .
| File | Contents |
|---|---|
hooks/register.tsx | Hooks: sync, the /linear command, Start, and the sidebar UI |
hooks/linear.ts | Linear data parsing, worktree paths, and the implementation brief |
hooks/look.ts | Status colors and icons, priority icons, estimates, and due dates |
types/index.d.ts | The mod's state contract |
tests/sidebar.test.ts | Tests that run with Linear and git faked |
To release an update, increase version in .claude-plugin/plugin.json and push. Users then run claude plugin update linear and /reload-plugins.
hooks/register.tsx 618 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { IssueFilter, LinearIssue, StartState } from '../types'
5import {
6 ISSUE_FIELDS,
7 branchFor,
8 cleanDescription,
9 implementationBrief,
10 openIssues,
11 parseMcpJson,
12 toIssue,
13 worktreePathFor,
14} from './linear'
15import {
16 dueLook,
17 isInProgress,
18 isInReview,
19 parseStatusColors,
20 previewOf,
21 priorityLook,
22 shortEstimate,
23 statusColor,
24 statusGlyph,
25 statusGroups,
26} from './look'
27
28const PANE = 'linear-issues'
29const TITLE = 'Linear: my issues'
30const REFRESH_MS = 5 * 60 * 1000
31const PAGE_SIZE = 50
32const MAX_PAGES = 4
33const DONE_WINDOW = '-P30D'
34const OPEN_STATE_TYPES = ['started', 'unstarted', 'triage', 'backlog']
35const ACCENT = '#5E6AD2'
36const CONNECT_RETRY_MS = 3000
37const CONNECT_TRIES = 20
38
39const issues = atom({ plugin: 'linear', key: 'issues' } as const, [])
40const doneIssues = atom({ plugin: 'linear', key: 'done' } as const, [])
41const isLoading = atom({ plugin: 'linear', key: 'isLoading' } as const, false)
42const error = atom({ plugin: 'linear', key: 'error' } as const, null)
43const fetchedAt = atom({ plugin: 'linear', key: 'fetchedAt' } as const, null)
44const expanded = atom({ plugin: 'linear', key: 'expanded' } as const, {})
45const fullDescriptions = atom({ plugin: 'linear', key: 'fullDescriptions' } as const, {})
46const starts = atom({ plugin: 'linear', key: 'starts' } as const, {})
47const filter = atom({ plugin: 'linear', key: 'filter' } as const, 'all' as IssueFilter)
48const selected = atom({ plugin: 'linear', key: 'selected' } as const, null as string | null)
49const isConnecting = atom({ plugin: 'linear', key: 'isConnecting' } as const, false)
50
51type Options = {
52 repoPath: string
53 baseRef: string
54 startMode: 'session' | 'agent'
55 moveToInProgress: boolean
56 linearServer: string
57 statusColors?: string
58}
59
60function isFinished(issue: LinearIssue): boolean {
61 return issue.statusType === 'completed' || issue.statusType === 'canceled'
62}
63
64function fit(text: string, room: number): string {
65 return text.length <= room ? text : `${text.slice(0, Math.max(1, room - 1)).trimEnd()}…`
66}
67
68/**
69 * The names a Linear MCP server goes by: the claude.ai connector in the CLI, `linear` when it was added
70 * with `claude mcp add`, as is common in the desktop app. The configured name is tried first.
71 */
72const SERVER_NAMES = ['claude.ai Linear', 'linear', 'Linear']
73let connected: string | null = null
74
75function serversFor(o: Options): string[] {
76 return [...new Set([connected, o.linearServer, ...SERVER_NAMES].filter((name): name is string => Boolean(name)))]
77}
78
79/** Calls a Linear tool on the first server that has it connected, and remembers which one answered. */
80async function linearCall($: EngineInterface, o: Options, tool: string, args: Record<string, unknown>) {
81 const tried = serversFor(o)
82 for (const server of tried) {
83 try {
84 const result = await $.mcp.call(server, tool, args)
85 connected = server
86 return result
87 } catch (err) {
88 const message = err instanceof Error ? err.message : String(err)
89 if (!/no connected MCP/i.test(message)) throw err
90 if (connected === server) connected = null
91 }
92 }
93 throw new Error(
94 `No connected MCP server has Linear's ${tool} (tried ${tried.map(name => `"${name}"`).join(', ')}). ` +
95 'Connect Linear in /mcp, or set linearServer in /config to its name.',
96 )
97}
98
99/** One state type's issues, paged in small batches so no single reply passes the MCP output cap. */
100const CALL_TIMEOUT_MS = 20_000
101
102/**
103 * A Linear call that gives up after 20 seconds, so a connection that never answers (a desktop session
104 * whose connector is still starting, or waiting on a sign-in) ends in a message instead of waiting forever.
105 */
106async function withTimeout<T>($: EngineInterface, call: Promise<T>): Promise<T> {
107 const startedAt = await $.clock.now()
108 return new Promise<T>((resolve, reject) => {
109 // Gives up only once the time has truly passed, whenever the timer itself fires.
110 const timer = $.clock.after(CALL_TIMEOUT_MS, () =>
111 void $.clock.now().then(now => {
112 if (now - startedAt < CALL_TIMEOUT_MS) return
113 reject(new Error('Linear did not answer within 20 seconds. Check /mcp: the Linear connection may need you to sign in.'))
114 }),
115 )
116 call.then(
117 value => {
118 timer.cancel()
119 resolve(value)
120 },
121 error => {
122 timer.cancel()
123 reject(error)
124 },
125 )
126 })
127}
128
129async function listByState(
130 $: EngineInterface,
131 o: Options,
132 state: string,
133 extra: Record<string, unknown> = {},
134 pages = MAX_PAGES,
135): Promise<LinearIssue[]> {
136 const found: LinearIssue[] = []
137 let cursor: string | undefined
138 for (let page = 0; page < pages; page++) {
139 const result = await withTimeout($, linearCall($, o, 'list_issues', {
140 assignee: 'me',
141 state,
142 limit: PAGE_SIZE,
143 orderBy: 'updatedAt',
144 fields: ISSUE_FIELDS,
145 ...extra,
146 ...(cursor ? { cursor } : {}),
147 }))
148 const data = parseMcpJson(result) as { issues?: Record<string, unknown>[]; hasNextPage?: boolean; cursor?: string }
149 found.push(...(data.issues ?? []).map(toIssue))
150 if (!data.hasNextPage || !data.cursor) break
151 cursor = data.cursor
152 }
153 return found
154}
155
156async function refresh($: EngineInterface, o: Options) {
157 await update($, isLoading, () => true)
158 try {
159 const all: LinearIssue[] = []
160 for (const state of OPEN_STATE_TYPES) all.push(...(await listByState($, o, state)))
161 const list = openIssues(all)
162 await update($, issues, () => list)
163 // Done work is only the recent past: one page of what was finished in the last 30 days.
164 const done = await listByState($, o, 'completed', { updatedAt: DONE_WINDOW }, 1)
165 await update($, doneIssues, () => done)
166 await update($, error, () => null)
167 const now = await $.clock.now()
168 await update($, fetchedAt, () => now)
169 } catch (err) {
170 const message = err instanceof Error ? err.message : String(err)
171 await update($, error, () => message)
172 } finally {
173 await update($, isLoading, () => false)
174 }
175}
176
177async function fullDescription($: EngineInterface, o: Options, issue: LinearIssue): Promise<string> {
178 const cached = (await read($, fullDescriptions))[issue.id]
179 if (cached !== undefined) return cached
180 try {
181 const result = await linearCall($, o, 'get_issue', { id: issue.id, fields: ['id', 'description'] })
182 const data = parseMcpJson(result) as { description?: string; issue?: { description?: string } }
183 const text = data.description ?? data.issue?.description ?? issue.description
184 await update($, fullDescriptions, all => ({ ...all, [issue.id]: text }))
185 return text
186 } catch {
187 return issue.description
188 }
189}
190
191async function setStart($: EngineInterface, o: Options, id: string, state: StartState) {
192 await update($, starts, all => ({ ...all, [id]: state }))
193}
194
195async function git($: EngineInterface, o: Options, args: string[], cwd: string) {
196 return $.process.run(['git', ...args], { cwd, timeoutMs: 120_000 })
197}
198
199async function createWorktree($: EngineInterface, o: Options, issue: LinearIssue) {
200 const base = o.repoPath || (await $.session.cwd())
201 const top = await git($, o, ['rev-parse', '--show-toplevel'], base)
202 if (top.exitCode !== 0) {
203 throw new Error(`${base} is not a git repository. Set repoPath in /config (linear).`)
204 }
205 const root = top.stdout.trim()
206 const path = worktreePathFor(root, issue)
207 const branch = branchFor(issue)
208
209 const listed = await git($, o, ['worktree', 'list', '--porcelain'], root)
210 if (listed.stdout.split('\n').includes(`worktree ${path}`)) {
211 return { path, branch, isNew: false }
212 }
213
214 const hasBranch = await git($, o, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], root)
215 const add =
216 hasBranch.exitCode === 0
217 ? await git($, o, ['worktree', 'add', path, branch], root)
218 : await git($, o, ['worktree', 'add', '-b', branch, path, o.baseRef || 'HEAD'], root)
219 if (add.exitCode !== 0) {
220 throw new Error(`git worktree add failed: ${add.stderr.trim()}`)
221 }
222
223 return { path, branch, isNew: true }
224}
225
226async function start($: EngineInterface, o: Options, issue: LinearIssue) {
227 const current = (await read($, starts))[issue.id]
228 if (current?.phase === 'working') return
229
230 await setStart($, o, issue.id, { phase: 'working', message: 'Creating worktree...' })
231 try {
232 const tree = await createWorktree($, o, issue)
233 const description = await fullDescription($, o, issue)
234 const brief = implementationBrief(issue, description, tree.path, tree.branch)
235
236 if (o.moveToInProgress && issue.statusType !== 'started') {
237 await linearCall($, o, 'save_issue', { id: issue.id, state: 'In Progress' }).catch(() => undefined)
238 }
239
240 if (o.startMode === 'agent') {
241 await $.agent.spawn({
242 prompt: brief,
243 cwd: tree.path,
244 subagentType: 'general-purpose',
245 description: `Implement ${issue.id}`,
246 })
247 } else {
248 await $.prompt.submit({
249 text: `First call the EnterWorktree tool with path "${tree.path}" to switch this session into the issue's worktree. Then do the task below.\n\n${brief}`,
250 })
251 }
252
253 const verb = tree.isNew ? 'Created' : 'Reused'
254 const who = o.startMode === 'agent' ? 'background agent started' : 'session will start'
255 await setStart($, o, issue.id, { phase: 'started', message: `${verb} worktree; ${who}`, worktree: tree.path })
256 $.ui.toast(`${issue.id}: ${verb.toLowerCase()} worktree ${tree.branch}`)
257 void refresh($, o)
258 } catch (err) {
259 const message = err instanceof Error ? err.message : String(err)
260 await setStart($, o, issue.id, { phase: 'error', message })
261 }
262}
263
264/** The claude.ai connectors attach a few seconds after start: keep trying until Linear answers. */
265async function syncWhenConnected($: EngineInterface, o: Options, triesLeft: number) {
266 await update($, isConnecting, () => true)
267 await refresh($, o)
268 const failed = await read($, error)
269 if (failed !== null && /no connected MCP/i.test(failed) && triesLeft > 0) {
270 $.clock.after(CONNECT_RETRY_MS, () => void syncWhenConnected($, o, triesLeft - 1))
271 return
272 }
273 await update($, isConnecting, () => false)
274}
275
276export const register: Register = (on, rawOptions) => {
277 const options = rawOptions as unknown as Options
278
279 on('session.start', async ($, e, next) => {
280 // Not awaited: registering makes Claude Code re-describe every command (~0.7 s), which would hold startup.
281 // Typed as /linear in the terminal; the command file is what the desktop app lists in its menu.
282 void $.command.register({
283 name: 'linear',
284 description: 'Show your assigned Linear issues in a sidebar (`/linear refresh` to reload)',
285 argumentHint: '[refresh]',
286 }).catch(() => undefined)
287 void $.ui.open({ id: PANE, title: TITLE })
288 void syncWhenConnected($, options, CONNECT_TRIES)
289 $.clock.every(REFRESH_MS, () => void refresh($, options))
290
291 return next(e)
292 })
293
294 on('command.run', { command: ['linear', 'linear:linear'] }, async ($, e) => {
295 if (e.args.trim() === 'refresh') {
296 await refresh($, options)
297 const count = (await read($, issues)).length
298 const failed = await read($, error)
299 return { text: failed ? `Linear refresh failed: ${failed}` : `Loaded ${count} open Linear issues.` }
300 }
301 await $.ui.open({ id: PANE, title: TITLE })
302 void refresh($, options)
303
304 return { text: 'Linear issues sidebar opened.' }
305 })
306
307 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
308 const { Box, Text, Button, Link, Markdown } = $.ui.resolve(e)
309 const all = await read($, issues)
310 const done = await read($, doneIssues)
311 const shown = await read($, filter)
312 const isTodo = (issue: LinearIssue) => issue.statusType === 'unstarted'
313 const list =
314 shown === 'started'
315 ? all.filter(isInProgress)
316 : shown === 'review'
317 ? all.filter(isInReview)
318 : shown === 'todo'
319 ? all.filter(isTodo)
320 : shown === 'done'
321 ? done
322 : all
323 const loading = await read($, isLoading)
324 const connecting = await read($, isConnecting)
325 const failed = await read($, error)
326 const at = await read($, fetchedAt)
327 const open = await read($, expanded)
328 const full = await read($, fullDescriptions)
329 const started = await read($, starts)
330 const focus = await read($, selected)
331 const columns = Math.max(30, e.props.bodyColumns)
332 const time = at === null ? '' : new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
333 const inProgress = all.filter(isInProgress).length
334 const custom = parseStatusColors(options.statusColors ?? '')
335 // inside a card: border and padding take two columns each side
336 const titleRoom = columns - 4
337
338 const field = (name: string, value: RenderChildren) => (
339 <Box flexDirection="row">
340 <Box width={10} flexShrink={0}>
341 <Text dimColor>{name}</Text>
342 </Box>
343 {value}
344 </Box>
345 )
346
347 const card = (issue: LinearIssue) => {
348 const color = statusColor(issue, custom)
349 const priority = priorityLook(issue.priority)
350 const estimate = shortEstimate(issue.estimate)
351 const due = dueLook(issue.dueDate, at)
352 const state = started[issue.id]
353 const isFull = open[issue.id] === true
354 const description = cleanDescription(full[issue.id] ?? issue.description)
355
356 return (
357 <Box
358 key={`card:${issue.id}`}
359 flexDirection="column"
360 borderStyle="round"
361 borderColor={state?.phase === 'error' ? 'error' : state?.phase === 'started' ? 'success' : ACCENT}
362 paddingX={1}
363 >
364 <Box flexDirection="row" justifyContent="space-between">
365 <Text color={ACCENT}>{issue.id}</Text>
366 <Button
367 key={`close:${issue.id}`}
368 label="✕"
369 plain
370 dimColor
371 onPress={() => update($, selected, () => null)}
372 />
373 </Box>
374 <Text bold wrap="wrap">
375 {issue.title}
376 </Text>
377 <Box flexDirection="column" marginTop={1}>
378 {field('Status', <Text color={color}>{`${statusGlyph(issue)} ${issue.status}`}</Text>)}
379 {field('Priority', <Text>{issue.priority ?? 'No priority'}</Text>)}
380 {estimate !== null && field('Estimate', <Text>{estimate}</Text>)}
381 {field('Project', <Text dimColor={issue.project === null}>{issue.project ?? 'No project'}</Text>)}
382 {issue.team !== null && field('Team', <Text>{issue.team}</Text>)}
383 {due !== null &&
384 field(
385 'Due',
386 <Text color={due.isOverdue ? 'error' : 'text'}>
387 {due.isOverdue ? `${due.text}, overdue` : due.text}
388 </Text>,
389 )}
390 {issue.labels.length > 0 && field('Labels', <Text color="ide">{issue.labels.join(' ')}</Text>)}
391 {issue.branch !== null && field('Branch', <Text dimColor>{issue.branch}</Text>)}
392 </Box>
393 {description !== '' && (
394 <Box flexDirection="column" marginTop={1}>
395 {isFull ? (
396 <Markdown text={description} />
397 ) : (
398 <Text dimColor wrap="wrap">
399 {previewOf(description, (columns - 4) * 4)}
400 </Text>
401 )}
402 </Box>
403 )}
404 {state !== undefined && (
405 <Box marginTop={1}>
406 <Text
407 color={state.phase === 'error' ? 'error' : state.phase === 'started' ? 'success' : 'warning'}
408 wrap="wrap"
409 >
410 {state.phase === 'error' ? '✕ ' : state.phase === 'started' ? '✓ ' : '⟳ '}
411 {state.message}
412 {state.worktree ? `\n⎇ ${state.worktree}` : ''}
413 </Text>
414 </Box>
415 )}
416 <Box flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1}>
417 {!isFinished(issue) && (
418 <Button
419 key={`start:${issue.id}`}
420 label={
421 state?.phase === 'working'
422 ? '⟳ Starting…'
423 : state?.phase === 'started'
424 ? '▶ Start again'
425 : '▶ Start in worktree'
426 }
427 variant="primary"
428 autoFocus
429 onPress={() => start($, options, issue)}
430 />
431 )}
432 {description.length > (columns - 4) * 4 && (
433 <Button
434 key={`more:${issue.id}`}
435 label={isFull ? 'Less' : 'Full description'}
436 plain
437 dimColor
438 onPress={async () => {
439 if (!isFull) await fullDescription($, options, issue)
440 await update($, expanded, all => ({ ...all, [issue.id]: !all[issue.id] }))
441 }}
442 />
443 )}
444 <Link href={issue.url} label="Open in Linear ↗" />
445 </Box>
446 </Box>
447 )
448 }
449
450 const row = (issue: LinearIssue) => {
451 const priority = priorityLook(issue.priority)
452 const estimate = shortEstimate(issue.estimate)
453 const state = started[issue.id]
454 const color = statusColor(issue, custom)
455 const where = issue.project ?? issue.team
456 const canStart = !isFinished(issue)
457 const startLabel = !canStart
458 ? ''
459 : state?.phase === 'working'
460 ? '⟳'
461 : state?.phase === 'started'
462 ? '✓ Started'
463 : '▶ Start'
464
465 return (
466 <Box
467 key={`row:${issue.id}`}
468 flexDirection="column"
469 borderStyle="round"
470 borderColor={state?.phase === 'error' ? 'error' : state?.phase === 'started' ? 'success' : color}
471 borderDimColor
472 hover={{ borderDimColor: false }}
473 paddingX={1}
474 >
475 <Box flexDirection="row" justifyContent="space-between">
476 <Box flexDirection="row" gap={1}>
477 <Text color={priority.color}>{priority.glyph}</Text>
478 <Text dimColor>{issue.id}</Text>
479 </Box>
480 {estimate !== null && <Text dimColor>{estimate}</Text>}
481 </Box>
482 <Button
483 key={`pick:${issue.id}`}
484 label={fit(issue.title, titleRoom)}
485 plain
486 hover={{ color: ACCENT }}
487 onPress={() => update($, selected, () => issue.id)}
488 />
489 <Box flexDirection="row" justifyContent="space-between">
490 <Text color={color}>
491 {fit(
492 `${statusGlyph(issue)} ${issue.status}${where ? ` ${where}` : ''}`,
493 titleRoom - startLabel.length - 2,
494 )}
495 </Text>
496 {canStart && (
497 <Button
498 key={`quick:${issue.id}`}
499 label={startLabel}
500 plain
501 dimColor={state?.phase !== 'started'}
502 hover={{ color: ACCENT }}
503 onPress={() => start($, options, issue)}
504 />
505 )}
506 </Box>
507 {state?.phase === 'error' && (
508 <Text color="error" wrap="wrap">
509 ✕ {state.message}
510 </Text>
511 )}
512 </Box>
513 )
514 }
515
516 return (
517 <Box flexDirection="column">
518 <Box flexDirection="row" justifyContent="space-between">
519 <Text bold>My issues</Text>
520 <Box flexDirection="row" gap={1}>
521 {connecting && <Text dimColor>connecting…</Text>}
522 {!connecting && loading && <Text dimColor>syncing…</Text>}
523 {!connecting && !loading && time !== '' && <Text dimColor>{time}</Text>}
524 <Button key="refresh" label="⟳" plain dimColor onPress={() => refresh($, options)} />
525 </Box>
526 </Box>
527 <Box flexDirection="row" flexWrap="wrap" columnGap={3} marginTop={1}>
528 <Button
529 key="filter:all"
530 label={`All ${all.length}`}
531 plain
532 variant={shown === 'all' ? 'primary' : undefined}
533 dimColor={shown !== 'all'}
534 onPress={() => update($, filter, () => 'all')}
535 />
536 <Button
537 key="filter:todo"
538 label={`Todo ${all.filter(isTodo).length}`}
539 plain
540 variant={shown === 'todo' ? 'primary' : undefined}
541 dimColor={shown !== 'todo'}
542 onPress={() => update($, filter, () => 'todo')}
543 />
544 <Button
545 key="filter:started"
546 label={`In progress ${inProgress}`}
547 plain
548 variant={shown === 'started' ? 'primary' : undefined}
549 dimColor={shown !== 'started'}
550 onPress={() => update($, filter, () => 'started')}
551 />
552 <Button
553 key="filter:review"
554 label={`In review ${all.filter(isInReview).length}`}
555 plain
556 variant={shown === 'review' ? 'primary' : undefined}
557 dimColor={shown !== 'review'}
558 onPress={() => update($, filter, () => 'review')}
559 />
560 <Button
561 key="filter:done"
562 label={`Done ${done.length}`}
563 plain
564 variant={shown === 'done' ? 'primary' : undefined}
565 dimColor={shown !== 'done'}
566 onPress={() => update($, filter, () => 'done')}
567 />
568 </Box>
569
570 {failed !== null && !connecting && (
571 <Box flexDirection="column" marginTop={1}>
572 <Text color="error">Couldn't sync with Linear.</Text>
573 <Text dimColor wrap="wrap">
574 {failed}
575 </Text>
576 <Text dimColor>Check /mcp, then press ⟳.</Text>
577 </Box>
578 )}
579 {(connecting || (loading && all.length === 0)) && (
580 <Box marginTop={1}>
581 <Text dimColor>Loading your issues from Linear…</Text>
582 </Box>
583 )}
584 {!loading && !connecting && failed === null && all.length === 0 && (
585 <Box flexDirection="column" marginTop={1}>
586 <Text color="success">Nothing assigned to you.</Text>
587 <Text dimColor>New assignments show up here on the next sync.</Text>
588 </Box>
589 )}
590 {!loading && failed === null && all.length > 0 && list.length === 0 && (
591 <Box flexDirection="column" marginTop={1}>
592 <Text dimColor>
593 {shown === 'review'
594 ? 'Nothing is waiting for review.'
595 : shown === 'done'
596 ? 'Nothing finished in the last 30 days.'
597 : shown === 'todo'
598 ? 'No Todo issues. Your queue is clear.'
599 : 'Nothing is in the In Progress status. Pick an issue under All and start it.'}
600 </Text>
601 </Box>
602 )}
603
604 {statusGroups(list).map(group => (
605 <Box key={`section:${group.status}`} flexDirection="column" marginTop={1}>
606 <Box flexDirection="row" gap={1}>
607 <Text color={statusColor(group.issues[0]!, custom)}>{statusGlyph(group.issues[0]!)}</Text>
608 <Text bold>{group.status}</Text>
609 <Text dimColor>{group.issues.length}</Text>
610 </Box>
611 {group.issues.map(issue => (issue.id === focus ? card(issue) : row(issue)))}
612 </Box>
613 ))}
614 </Box>
615 )
616 })
617}
618hooks/linear.ts 128 lines1import type { LinearIssue } from '../types'
2
3export const ISSUE_FIELDS = [
4 'id',
5 'title',
6 'description',
7 'estimate',
8 'priority',
9 'status',
10 'statusType',
11 'project',
12 'team',
13 'url',
14 'gitBranchName',
15 'labels',
16 'dueDate',
17]
18
19const OPEN_TYPES = ['started', 'unstarted', 'backlog', 'triage']
20const TYPE_ORDER: Record<string, number> = { started: 0, unstarted: 1, triage: 2, backlog: 3 }
21const PRIORITY_ORDER: Record<string, number> = { Urgent: 0, High: 1, Medium: 2, Low: 3 }
22
23type McpText = { content: { type: string; text?: string }[]; isError: boolean }
24
25/** Joins an MCP result's text blocks and parses them as JSON; throws with the server's words on error. */
26export function parseMcpJson(result: McpText): unknown {
27 const text = result.content.map(block => block.text ?? '').join('')
28 if (result.isError) throw new Error(text || 'Linear call failed')
29 try {
30 return JSON.parse(text)
31 } catch {
32 throw new Error(`Linear returned non-JSON: ${text.slice(0, 200)}`)
33 }
34}
35
36function nameOf(value: unknown): string | null {
37 if (value === null || value === undefined) return null
38 if (typeof value === 'string') return value
39 if (typeof value === 'number') return String(value)
40 if (typeof value === 'object' && 'name' in value) return String((value as { name: unknown }).name)
41 return null
42}
43
44export function toIssue(raw: Record<string, unknown>): LinearIssue {
45 const labels = Array.isArray(raw.labels) ? raw.labels.map(nameOf).filter((l): l is string => !!l) : []
46
47 return {
48 id: String(raw.id),
49 title: String(raw.title ?? ''),
50 description: String(raw.description ?? ''),
51 estimate: nameOf(raw.estimate),
52 priority: nameOf(raw.priority),
53 status: String(raw.status ?? ''),
54 statusType: String(raw.statusType ?? ''),
55 project: nameOf(raw.project),
56 team: nameOf(raw.team),
57 url: String(raw.url ?? ''),
58 branch: typeof raw.gitBranchName === 'string' ? raw.gitBranchName : null,
59 labels,
60 dueDate: typeof raw.dueDate === 'string' ? raw.dueDate : null,
61 }
62}
63
64/** Open issues only (not done or canceled), in progress first, then by priority. */
65export function openIssues(issues: LinearIssue[]): LinearIssue[] {
66 const seen = new Set<string>()
67 return issues
68 .filter(issue => OPEN_TYPES.includes(issue.statusType) && !seen.has(issue.id) && !!seen.add(issue.id))
69 .sort(
70 (a, b) =>
71 (TYPE_ORDER[a.statusType] ?? 9) - (TYPE_ORDER[b.statusType] ?? 9) ||
72 (PRIORITY_ORDER[a.priority ?? ''] ?? 4) - (PRIORITY_ORDER[b.priority ?? ''] ?? 4),
73 )
74}
75
76export function slug(text: string): string {
77 return text
78 .toLowerCase()
79 .replace(/[^a-z0-9]+/g, '-')
80 .replace(/^-+|-+$/g, '')
81 .slice(0, 40)
82}
83
84export function branchFor(issue: LinearIssue): string {
85 return issue.branch ?? `${issue.id.toLowerCase()}-${slug(issue.title)}`
86}
87
88export function worktreePathFor(repoRoot: string, issue: LinearIssue): string {
89 return `${repoRoot}/.claude/worktrees/${issue.id.toLowerCase()}`
90}
91
92/** Strips Linear's `<issue href>` tags down to their link text. */
93export function cleanDescription(text: string): string {
94 return text.replace(/<issue[^>]*href="([^"]*)"[^>]*>([^<]*)<\/issue>/g, '[$2]($1)')
95}
96
97export function implementationBrief(issue: LinearIssue, description: string, worktree: string, branch: string): string {
98 const facts = [
99 `Status: ${issue.status}`,
100 issue.priority && `Priority: ${issue.priority}`,
101 issue.estimate && `Estimate: ${issue.estimate}`,
102 issue.project && `Project: ${issue.project}`,
103 issue.labels.length > 0 && `Labels: ${issue.labels.join(', ')}`,
104 `Link: ${issue.url}`,
105 ].filter(Boolean)
106
107 return [
108 `Implement Linear issue ${issue.id}: ${issue.title}`,
109 '',
110 facts.join('\n'),
111 '',
112 `Worktree: ${worktree}`,
113 `Branch: ${branch}`,
114 '',
115 '## Description',
116 '',
117 cleanDescription(description) || '(no description)',
118 '',
119 '## How to work',
120 '',
121 '- Work only inside the worktree above.',
122 '- Read the codebase and plan before editing; follow the repository CLAUDE.md.',
123 '- Implement the issue, add or update tests, and run them.',
124 '- Commit on the branch with messages that reference the issue id. Do not push or open a PR unless asked.',
125 '- Finish with a short summary of what changed and anything left open.',
126 ].join('\n')
127}
128hooks/look.ts 123 lines1import type { LinearIssue } from '../types'
2
3/** Linear's default workflow colors; custom statuses come from the `statusColors` setting. */
4const DEFAULT_STATUS_COLORS: Record<string, string> = {
5 triage: '#FC7840',
6 backlog: '#BEC2C8',
7 todo: '#E2E2E2',
8 'in progress': '#F2C94C',
9 'in review': '#0F783C',
10 done: '#5E6AD2',
11 canceled: '#95A2B3',
12}
13
14const TYPE_FALLBACK_COLORS: Record<string, string> = {
15 triage: '#FC7840',
16 backlog: '#BEC2C8',
17 unstarted: '#E2E2E2',
18 started: '#F2C94C',
19 completed: '#5E6AD2',
20 canceled: '#95A2B3',
21}
22
23/** Parses "Blocked=#EB5757, Test=#26B5CE" into a lower-cased name -> hex map. */
24export function parseStatusColors(setting: string): Record<string, string> {
25 const colors: Record<string, string> = {}
26 for (const pair of setting.split(',')) {
27 const [name, hex] = pair.split('=').map(part => part.trim())
28 if (name && hex && /^#[0-9a-f]{6}$/i.test(hex)) colors[name.toLowerCase()] = hex
29 }
30 return colors
31}
32
33export function statusColor(issue: LinearIssue, custom: Record<string, string>): string {
34 const name = issue.status.toLowerCase()
35 return custom[name] ?? DEFAULT_STATUS_COLORS[name] ?? TYPE_FALLBACK_COLORS[issue.statusType] ?? '#95A2B3'
36}
37
38/** Linear's status icons: dashed for backlog, empty for todo, filling up through the started states. */
39export function statusGlyph(issue: LinearIssue): string {
40 if (issue.statusType === 'backlog') return '◌'
41 if (issue.statusType === 'triage') return '◇'
42 if (issue.statusType === 'unstarted') return '○'
43 if (issue.statusType === 'completed') return '✓'
44 if (issue.statusType === 'canceled') return '⊘'
45 const name = issue.status.toLowerCase()
46 if (name === 'in progress') return '◐'
47 if (name === 'in review') return '◕'
48 return '◑'
49}
50
51export function isInProgress(issue: LinearIssue): boolean {
52 return issue.status.toLowerCase() === 'in progress'
53}
54
55export function isInReview(issue: LinearIssue): boolean {
56 return issue.status.toLowerCase() === 'in review'
57}
58
59const TYPE_RANK: Record<string, number> = { unstarted: 0, started: 1, triage: 2, backlog: 3, completed: 4 }
60const STARTED_RANK: Record<string, number> = { 'in progress': 0, 'in review': 1 }
61
62/** Groups issues by their Linear status, in workflow order: Todo, In Progress, In Review, other active states, Triage, Backlog, Done. */
63export function statusGroups(issues: LinearIssue[]): { status: string; issues: LinearIssue[] }[] {
64 const groups = new Map<string, LinearIssue[]>()
65 for (const issue of issues) groups.set(issue.status, [...(groups.get(issue.status) ?? []), issue])
66 const rank = (issue: LinearIssue) =>
67 (TYPE_RANK[issue.statusType] ?? 9) * 100 + (STARTED_RANK[issue.status.toLowerCase()] ?? 50)
68
69 return [...groups.entries()]
70 .map(([status, members]) => ({ status, issues: members }))
71 .sort((a, b) => rank(a.issues[0]!) - rank(b.issues[0]!) || a.status.localeCompare(b.status))
72}
73
74/** Linear's signal-bar priority icons. */
75export function priorityLook(priority: string | null): { glyph: string; color: string } {
76 switch (priority) {
77 case 'Urgent':
78 return { glyph: '[!]', color: '#F2994A' }
79 case 'High':
80 return { glyph: '▮▮▮', color: '#95A2B3' }
81 case 'Medium':
82 return { glyph: '▮▮▯', color: '#95A2B3' }
83 case 'Low':
84 return { glyph: '▮▯▯', color: '#95A2B3' }
85 default:
86 return { glyph: '---', color: '#6B6F76' }
87 }
88}
89
90/** "8 Points" -> "8 pts", "1 Point" -> "1 pt"; other names (T-shirt sizes) kept. */
91export function shortEstimate(estimate: string | null): string | null {
92 if (estimate === null) return null
93 const points = /^(\d+(?:\.\d+)?)\s*points?$/i.exec(estimate)
94 if (!points) return estimate
95 return `${points[1]} ${points[1] === '1' ? 'pt' : 'pts'}`
96}
97
98const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
99
100/** "2026-10-12" -> { text: "Oct 12", isOverdue } relative to `now`. */
101export function dueLook(dueDate: string | null, now: number | null): { text: string; isOverdue: boolean } | null {
102 if (dueDate === null) return null
103 const [year, month, day] = dueDate.split('-').map(Number)
104 if (!year || !month || !day) return { text: dueDate, isOverdue: false }
105 const due = Date.UTC(year, month - 1, day, 23, 59)
106 return { text: `${MONTHS[month - 1]} ${day}`, isOverdue: now !== null && due < now }
107}
108
109/** Plain-text preview: markdown marks stripped, whitespace folded, cut at a word. */
110export function previewOf(description: string, max: number): string {
111 const plain = description
112 .replace(/```[\s\S]*?```/g, ' ')
113 .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
114 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
115 .replace(/[#>*_`|]+/g, '')
116 .replace(/\s+/g, ' ')
117 .trim()
118 if (plain.length <= max) return plain
119 const cut = plain.slice(0, max)
120 const space = cut.lastIndexOf(' ')
121 return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`
122}
123types/index.d.ts 42 lines1export type LinearIssue = {
2 id: string
3 title: string
4 description: string
5 estimate: string | null
6 priority: string | null
7 status: string
8 statusType: string
9 project: string | null
10 team: string | null
11 url: string
12 branch: string | null
13 labels: string[]
14 dueDate: string | null
15}
16
17export type IssueFilter = 'all' | 'started' | 'review' | 'todo' | 'done'
18
19export type StartState = {
20 phase: 'working' | 'started' | 'error'
21 message: string
22 worktree?: string
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'linear': {
28 issues: LinearIssue[]
29 done: LinearIssue[]
30 isLoading: boolean
31 error: string | null
32 fetchedAt: number | null
33 expanded: Record<string, boolean>
34 fullDescriptions: Record<string, string>
35 starts: Record<string, StartState>
36 filter: IssueFilter
37 selected: string | null
38 isConnecting: boolean
39 }
40 }
41}
42