SLOPSHOPPER

linear-issues

내게 할당된 미완료 Linear 이슈를 패널에 띄운다

paneguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · linear-issues
│ ┃ Linear ✕ › fix the failing auth test and add an audit log call │ ┃ 불러오기 실패: undefined is not an object │ ┃ (evaluating 'result.content.find') ⏺ Read(src/auth.ts) │ ┃ 4:26:16 PM 갱신 · /linear 새로고침 · ⎿ Read 6 lines │ ┃ 클릭하면 프롬프트에 넣기 · ctrl+x x 닫기 ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /linear │ ⎿ linear-issues: Linear 이슈를 새로고침했어요. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Linear
불러오기 실패: undefined is not an object (evaluating 'result.content.find') 4:26:16 PM 갱신 · /linear 새로고침 · 클릭하면 프롬프트에 넣기 · ctrl+x x 닫기
README

linear-issues

내게 할당된 미완료 Linear 이슈를 Claude Code 패널에 띄우는 mod. 프로젝트 → 상태 순으로 묶고, 이슈를 누르면 ABC-123 제목이 프롬프트에 들어간다.

설치

먼저 Linear MCP 가 연결돼 있어야 한다(claude mcp list 에 Linear 서버가 보이는지).

터미널 세션 프롬프트에서:

/plugin install linear-issues --marketplace chat-prompt/gpters-ai-toolkit

셸에서:

claude plugin install linear-issues --marketplace chat-prompt/gpters-ai-toolkit

이미 gpters-ai-toolkit 플러그인을 쓰고 있으면 마켓플레이스가 추가돼 있어 /plugin install linear-issues@chat-prompt-gpters-ai-toolkit 로도 된다.

데스크톱 앱(/plugin 이 안 되는 곳): 프롬프트 옆 + → Plugins → Add marketplace 에 chat-prompt/gpters-ai-toolkit → Add plugin.

쓰기

  • /linear — 패널 열기·새로고침
  • 한 번 연 뒤로는 세션이 끝날 때까지 정해진 간격으로 다시 불러오고, 세션이 save_issue 로 이슈 상태를 바꾸면 바로 갱신한다.

개인 설정

설치할 때 나오는 화면이나 /config 에서 바꾼다. ~/.claude/settings.json 에 직접 쓸 때는 pluginConfigs["linear-issues@chat-prompt-gpters-ai-toolkit"].options.

설정기본값뜻
autoOpenPath(비어 있음)이 폴더 아래에서 세션을 시작하면 패널을 저절로 연다. 예: ~/Projects/my-team. ~ 나 절대경로로 쓴다. 비우면 /linear 로만 연다.
refreshMinutes5새로고침 간격(분). 최대 1440, 1 미만이면 5
mcpServerlinearclaude mcp list 에 보이는 Linear 서버 이름 그대로.

개발

claude plugin validate apps/claude-code-mods/linear-issues
claude plugin test apps/claude-code-mods/linear-issues

.claude-plugin/types/(tsconfig 가 extends 하는 타입)는 엔진이 mod 를 불러올 때 만든다. claude --plugin-dir apps/claude-code-mods/linear-issues 로 띄우면 저장할 때마다 다시 불러온다.

Source 3 files
hooks/register.tsx 189 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput } from 'claude-code'
3
4import { fit, groupIssues, parseIssues } from './parse'
5import type { Issue } from '../types'
6
7const PANE = 'linear-issues'
8const OPEN_STATES = ['started', 'unstarted', 'backlog', 'triage']
9const issues = atom({ plugin: 'linear-issues', key: 'issues' } as const, [])
10const error = atom({ plugin: 'linear-issues', key: 'error' } as const, null)
11const updatedAt = atom({ plugin: 'linear-issues', key: 'updatedAt' } as const, null)
12// 개인 설정(userConfig). /config 또는 settings.json pluginConfigs 에서 바꾼다.
13let REFRESH_MS = 5 * 60 * 1000
14let SERVER = 'linear'
15// 세션을 이 아래에서 시작했을 때만 패널을 저절로 연다(비우면 안 연다). /linear 는 어디서나 된다.
16let AUTO_OPEN = ''
17// 세션 시작 직후엔 Linear MCP 가 아직 붙지 않았다. 그동안은 오류 대신 불러오는 중으로 두고 다시 시도한다.
18const RETRY_MS = 3000
19const RETRIES = 20
20
21let polling = false
22
23async function refresh($: EngineInterface, tries = 0) {
24  try {
25    // 최근 수정순 N개를 받아 거르면 끝난 이슈에 밀려 오래된 미완료가 빠진다. 열린 상태별로 따로 받는다.
26    const results = await Promise.all(
27      OPEN_STATES.map(state =>
28        $.mcp.call(SERVER, 'list_issues', {
29          assignee: 'me',
30          state,
31          limit: 250,
32          fields: ['id', 'title', 'status', 'statusType', 'project', 'url'],
33        }),
34      ),
35    )
36    await update($, issues, () => results.flatMap(parseIssues))
37    await update($, error, () => null)
38  } catch (err) {
39    const message = String((err as Error).message ?? err)
40    if (message.includes('no connected MCP') && tries < RETRIES) {
41      await $.clock.sleep(RETRY_MS)
42      return refresh($, tries + 1)
43    }
44    await update($, error, () => message)
45  }
46  await update($, updatedAt, () => Date.now())
47}
48
49async function show($: EngineInterface, focus: boolean) {
50  await $.ui.open(focus ? { id: PANE, title: 'Linear', focus: true } : { id: PANE, title: 'Linear' })
51  if (!polling) {
52    polling = true
53    $.clock.every(REFRESH_MS, () => void refresh($))
54  }
55  await refresh($)
56}
57
58const insert = ($: EngineInterface, issue: { id: string; title: string }) =>
59  void $.prompt.fill({ text: `${issue.id} ${issue.title} `, mode: 'insert' })
60
61// 앱 패널에서 접어 둔 프로젝트. 다시 불러오면 모두 펼친다.
62const collapsed = new Set<string>()
63const DOT: Record<string, string> = { started: 'yellow', unstarted: 'blue', backlog: 'gray', triage: 'red' }
64
65function toggle($: EngineInterface, project: string) {
66  if (!collapsed.delete(project)) collapsed.add(project)
67  $.ui.invalidate('ui.render')
68}
69
70// 앱(데스크톱 등)용: 들여쓰기는 여백으로, 자르기는 앱에 맡기고, 프로젝트는 접을 수 있게.
71function desktopPane($: EngineInterface, e: RenderInput<'Pane'>, list: Issue[], err: string | null, at: number | null) {
72  const { Box, Button, Link, Text } = $.ui.resolve(e)
73
74  return (
75    <Box flexDirection="column" gap={1} paddingX={1}>
76      {err && <Text color="red" wrap="wrap">불러오기 실패: {err}</Text>}
77      {at === null && <Text dimColor>불러오는 중…</Text>}
78      {at !== null && !err && list.length === 0 && <Text dimColor>열린 이슈가 없어요.</Text>}
79      {groupIssues(list).map(g => {
80        const open = !collapsed.has(g.project)
81        const count = g.statuses.reduce((n, s) => n + s.issues.length, 0)
82        return (
83          <Box key={`p:${g.project}`} flexDirection="column">
84            <Button plain key={`p:${g.project}`} onPress={() => toggle($, g.project)}>
85              <Text bold>{open ? '▾' : '▸'} {g.project}</Text> <Text dimColor>{count}</Text>
86            </Button>
87            {open && g.statuses.map(s => (
88              <Box key={`s:${g.project}:${s.status}`} flexDirection="column" paddingLeft={2} marginTop={1}>
89                <Text wrap="truncate-end">
90                  <Text color={DOT[s.issues[0]?.statusType ?? ''] ?? 'gray'}>●</Text> <Text dimColor>{s.status} · {s.issues.length}</Text>
91                </Text>
92                {s.issues.map(issue => (
93                  <Box key={issue.id} flexDirection="row" columnGap={1} paddingLeft={2}>
94                    <Box flexGrow={1} flexShrink={1}>
95                      <Button plain key={issue.id} onPress={() => insert($, issue)}>
96                        <Text color="blue">{issue.id}</Text> <Text>{issue.title}</Text>
97                      </Button>
98                    </Box>
99                    <Link href={issue.url} label="↗" />
100                  </Box>
101                ))}
102              </Box>
103            ))}
104          </Box>
105        )
106      })}
107      {at !== null && (
108        <Box flexDirection="row" justifyContent="space-between">
109          <Text dimColor>{new Date(at).toLocaleTimeString()} 갱신 · 누르면 프롬프트에 넣기</Text>
110          <Button plain dimColor key="refresh" onPress={() => void refresh($)}>새로고침</Button>
111        </Box>
112      )}
113    </Box>
114  )
115}
116
117// ~ 를 HOME 으로 바꾸고 끝의 / 를 뗀다. HOME 을 모르면 ~ 경로는 '' (자동 열기 안 함).
118function expand(path: string, home: string) {
119  if (/^~(\/|$)/.test(path)) {
120    if (!home) return ''
121    path = home + path.slice(1)
122  }
123  return path.length > 1 ? path.replace(/\/+$/, '') : path
124}
125const under = (root: string, dir: string) => root === '/' || dir === root || dir.startsWith(`${root}/`)
126
127export const register: Register = (on, options) => {
128  const minutes = Number(options.refreshMinutes)
129  REFRESH_MS = (Number.isFinite(minutes) && minutes >= 1 ? Math.min(minutes, 1440) : 5) * 60 * 1000
130  SERVER = String(options.mcpServer ?? '').trim() || 'linear'
131  AUTO_OPEN = String(options.autoOpenPath ?? '').trim()
132
133  on('session.start', async ($, e, next) => {
134    await $.command.register({ name: 'linear', description: '내 Linear 이슈 패널 열기·새로고침' })
135    if (AUTO_OPEN) {
136      const root = await $.session.root()
137      const home = (await $.env.get('HOME')) ?? ''
138      const dir = expand(AUTO_OPEN, home)
139      if (dir && under(dir, root)) void show($, false)
140    }
141
142    return next(e)
143  })
144
145  on('command.run', { command: 'linear' }, async $ => {
146    await show($, true)
147
148    return { text: 'Linear 이슈를 새로고침했어요.' }
149  })
150
151  // 세션이 이슈 상태를 바꾸면(착수·완료 확정) 새로고침 간격을 기다리지 않고 바로 갱신한다. 패널이 열린 세션에서만.
152  on('tool.call', async ($, e, next) => {
153    const result = await next(e)
154    if (polling && String(e.tool) === `mcp__${SERVER.replace(/[^A-Za-z0-9_-]/g, '_')}__save_issue`) void refresh($)
155
156    return result
157  })
158
159  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
160    const list = await read($, issues)
161    const err = await read($, error)
162    const at = await read($, updatedAt)
163    if (e.surface !== 'terminal') return desktopPane($, e, list, err, at)
164    const { Box, Button, Text } = $.ui.resolve(e)
165    // Button은 안쪽 Text의 wrap을 따르지 않아 제목을 직접 패널 본문 폭에 맞춘다.
166    const cols = e.props.bodyColumns
167
168    return (
169      <Box flexDirection="column">
170        {err && <Text color="red" wrap="wrap">불러오기 실패: {err}</Text>}
171        {at === null && <Text dimColor>불러오는 중…</Text>}
172        {at !== null && !err && list.length === 0 && <Text dimColor>열린 이슈가 없어요.</Text>}
173        {groupIssues(list).flatMap(g => [
174          <Text bold color="cyan" wrap="truncate">▸ {g.project} ({g.statuses.reduce((n, s) => n + s.issues.length, 0)})</Text>,
175          ...g.statuses.flatMap(s => [
176            <Text dimColor wrap="truncate">  {s.status} · {s.issues.length}</Text>,
177            ...s.issues.map(issue => (
178              <Button plain key={issue.id} onPress={() => insert($, issue)}>
179                <Text wrap="truncate">{'    '}<Text color="blue">{issue.id}</Text> {fit(issue.title, cols - 5 - issue.id.length)}</Text>
180              </Button>
181            )),
182          ]),
183        ])}
184        {at !== null && <Text dimColor>{new Date(at).toLocaleTimeString()} 갱신 · /linear 새로고침 · 클릭하면 프롬프트에 넣기 · ctrl+x x 닫기</Text>}
185      </Box>
186    )
187  })
188}
189
hooks/parse.ts 60 lines
1import type { McpToolResult } from 'claude-code'
2
3import type { Issue } from '../types'
4
5export const NO_PROJECT = '프로젝트 없음'
6// 진행에 가까운 상태가 위로 온다.
7const RANK: Record<string, number> = { started: 0, unstarted: 1, backlog: 2, triage: 3 }
8const rank = (i: Issue) => RANK[i.statusType] ?? 9
9
10// list_issues 결과(JSON 텍스트 블록)에서 끝나지 않은 이슈만 남긴다.
11export function parseIssues(result: McpToolResult): Issue[] {
12  if (result.isError) throw new Error(result.content.map(b => b.text ?? '').join(' ') || 'Linear 호출 실패')
13  const text = result.content.find(b => b.type === 'text')?.text ?? '{"issues":[]}'
14  const { issues } = JSON.parse(text) as { issues: Issue[] }
15
16  return issues
17    .filter(i => i.statusType !== 'completed' && i.statusType !== 'canceled')
18    .map(({ id, title, status, statusType, project, url }) => ({ id, title, status, statusType, project: project ?? null, url }))
19}
20
21export type Group = { project: string; statuses: { status: string; issues: Issue[] }[] }
22
23// 프로젝트 → 상태 순으로 묶는다. 진행 중 이슈가 많은 프로젝트가 위, 프로젝트 없음은 맨 아래.
24export function groupIssues(list: Issue[]): Group[] {
25  const byProject = new Map<string, Issue[]>()
26  for (const i of [...list].sort((a, b) => rank(a) - rank(b) || a.status.localeCompare(b.status))) {
27    const key = i.project ?? NO_PROJECT
28    byProject.set(key, [...(byProject.get(key) ?? []), i])
29  }
30  const started = (g: Issue[]) => g.filter(i => i.statusType === 'started').length
31
32  return [...byProject]
33    .sort(([a, ga], [b, gb]) => Number(a === NO_PROJECT) - Number(b === NO_PROJECT) || started(gb) - started(ga) || a.localeCompare(b))
34    .map(([project, issues]) => {
35      const byStatus = new Map<string, Issue[]>()
36      for (const i of issues) byStatus.set(i.status, [...(byStatus.get(i.status) ?? []), i])
37      return { project, statuses: [...byStatus].map(([status, issues]) => ({ status, issues })) }
38    })
39}
40
41// 한글·한자·이모지는 터미널 두 칸.
42const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]|\p{Extended_Pictographic}/u
43
44const width = (c: string) => (WIDE.test(c) ? 2 : 1)
45
46// 칸 수 cols 안에 들도록 자르고, 잘랐으면 끝에 …를 붙인다.
47export function fit(text: string, cols: number): string {
48  const chars = [...text]
49  if (chars.reduce((n, c) => n + width(c), 0) <= cols) return text
50  let out = ''
51  let used = 0
52  for (const c of chars) {
53    if (used + width(c) > cols - 1) break
54    out += c
55    used += width(c)
56  }
57  return `${out}…`
58}
59
60
types/index.d.ts 8 lines
1export type Issue = { id: string; title: string; status: string; statusType: string; project: string | null; url: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'linear-issues': { issues: Issue[]; error: string | null; updatedAt: number | null }
6  }
7}
8