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

내게 할당된 미완료 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 로만 연다. |
refreshMinutes | 5 | 새로고침 간격(분). 최대 1440, 1 미만이면 5 |
mcpServer | linear | claude 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 로 띄우면 저장할 때마다 다시 불러온다.
hooks/register.tsx 189 lines1import { 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}
189hooks/parse.ts 60 lines1import 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
60types/index.d.ts 8 lines1export 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