SLOPSHOPPER

linear

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

newpanecommandtoastprocesstimer
★ 3v0.1.0MITupdated 2026-10-15omeerdvrn/o-mods/mods/linear
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · linear
│ ┃ Linear: my issues ✕ › fix the failing auth test and add an audit log call │ ┃ My issues syncing… ⟳ │ ┃ ⏺ Read(src/auth.ts) │ ┃ All 0 Todo 0 In progress 0 In review 0 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Couldn't sync with Linear. ⎿ Added 2 lines, removed 1 line │ ┃ undefined is not an object (evaluating ⏺ Bash(bun test) │ ┃ 'result.content.map') ⎿ 3 pass, 1 fail │ ┃ Check /mcp, then press ⟳. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Loading your issues from Linear… │ ✻ Worked for 42s · done 4:20 PM │ │ › /linear │ ⎿ linear: Linear issues sidebar opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Linear: my issues
My issues syncing… ⟳ All 0 Todo 0 In progress 0 In review 0 Done 0 Couldn't sync with Linear. undefined is not an object (evaluating 'result.content.map') Check /mcp, then press ⟳. Loading your issues from Linear…
README

linear

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.

A sidebar lists assigned Linear issues as cards grouped by status, each with a Start button, beside the Claude conversation.

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 │
╰────────────────────────────────────────────────╯

Features

  • Cards for your assigned issues, grouped by Linear status and colored like Linear: priority, ID, estimate, title, status, project.
  • Filters: All open issues, Todo, In progress, In review, and Done (finished in the last 30 days).
  • Click a card's title to see the full issue: status, priority, estimate, project, team, due date, labels, branch, and the description.
  • Start creates a worktree at <repo>/.claude/worktrees/<issue-id> on the branch name Linear suggests, moves the issue to In Progress, and starts the implementation.
  • Syncs on start and every 5 minutes. /linear refresh syncs on demand.

Requirements

  • Claude Code with function-hook mods (2.1.29x or later). The mod API is in early access, so check the mod after major Claude Code updates.
  • Linear connected to Claude: the claude.ai Linear connector, or your own Linear MCP server. Run /mcp to check.
  • For the docked sidebar: "tui": "fullscreen" in ~/.claude/settings.json and a terminal at least 144 columns wide. On narrower terminals, type /linear to open it.

Install

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.

Use

ActionHow
Open the sidebarIt opens by itself on wide terminals, or type /linear
Sync nowPress ⟳, or type /linear refresh
FilterPress All, Todo, In progress, In review, or Done
See an issue in fullClick its title. Press ✕ to close it.
Start workPress ▶ Start on the card

Settings

Change these in /config under linear.

SettingDefaultWhat it does
repoPathsession folderThe git repository to create worktrees in.
baseRefHEADThe branch or commit that new issue branches start from.
startModesessionsession: this session switches into the worktree and implements the issue. agent: a background agent implements it in the worktree.
moveToInProgresstrueSets the issue to In Progress in Linear when you press Start.
linearServerclaude.ai LinearThe name of your Linear MCP server, as /mcp lists it.
statusColorsemptyColors 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.

Development

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 .
FileContents
hooks/register.tsxHooks: sync, the /linear command, Start, and the sidebar UI
hooks/linear.tsLinear data parsing, worktree paths, and the implementation brief
hooks/look.tsStatus colors and icons, priority icons, estimates, and due dates
types/index.d.tsThe mod's state contract
tests/sidebar.test.tsTests 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.

Source 4 files
hooks/register.tsx 618 lines
1import { 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}
618
hooks/linear.ts 128 lines
1import 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}
128
hooks/look.ts 123 lines
1import 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}
123
types/index.d.ts 42 lines
1export 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