SLOPSHOPPER

dev-mods

malanjp が使う Claude Code の mod 集です。作業中のブランチの PR と CI 状態をパネルに表示する pr-status と、セッション内で起動した開発サーバを一覧表示する dev-servers を含みます。

newpaneguardcommandstatusprocess
★ 1v0.2.0MITupdated 2026-10-08malanjp/skills/plugins/dev-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dev-mods
│ ┃ PR / CI ✕ › fix the failing auth test and add an audit log call │ ┃ branch: feat/auth-refresh │ ┃ エラー: undefined is not an object ⏺ Read(src/auth.ts) │ ┃ (evaluating 's.replace') ⎿ Read 6 lines │ ┃ 更新 15:53:31 ⏺ 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 │ │ › /pr-status │ ⎿ dev-mods: 取得失敗: undefined is not an object (evaluating 's.replac │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · PR / CI
branch: feat/auth-refresh エラー: undefined is not an object (evaluating 's.replace') 更新 15:53:31
Pane · 開発サーバ
エラー: エンジンの PID を取得できません 更新 15:53:31
README

malanjp/skills

疲れ切った同僚が、読み返さずに理解して次の行動を選べる技術文書を目指すスキル集です。 日本語の Issue・PR・レビュー指摘・調査報告を書くための tech-writing と、PR と CI の状態や開発サーバの一覧を表示する mod 集 dev-mods を収録しています。

新しい文書の作成にも、既存の原稿の推敲にも使えます。 規約とテンプレート、記入例で、文章の組み立て方を確認できます。

使うエージェントに合わせて導入します

文章の規約は共通です。 規約を読み込ませる仕組みと、PR の投稿前検査は環境によって異なります。

環境導入するもの利用できる機能
Claude Codetired-dev プラグインスキル、文章作成の依頼に応じた規約の提示、PR と Linear の投稿前検査、読み手の検査
OpenCode V2tired-dev のローカルプラグインスキルの登録、文章作成の依頼に応じた規約の提示、PR の投稿前検査
Cursortired-dev-cursor プラグインスキルと規約の要約

Claude Code

Claude Code で、マーケットプレイスとプラグインを追加します。

/plugin marketplace add malanjp/skills
/plugin install tired-dev@malanjp

明示的に使う場合は、/tired-dev:tech-writing を呼び出して原稿や依頼を渡します。 スキルだけの導入や動作の設定も選べます。

OpenCode V2

このリポジトリをローカルに置き、plugins/tired-dev/opencode の絶対パスを設定します。 プラグインはリポジトリの規約を直接読むため、配置したディレクトリを残して使います。

任意の作業ディレクトリで実行します。 取得済みの場合は、そのリポジトリを使えます。

git clone https://github.com/malanjp/skills.git
cd skills
pwd

~/.config/opencode/opencode.json の plugins 配列にパスを追加します。 次の /absolute/path/to/skills は、pwd が表示したパスに置き換えてください。 既存の設定やプラグインは残します。

{
  "plugins": ["/absolute/path/to/skills/plugins/tired-dev/opencode"]
}

V1 形式の設定を引き継ぎ、plugin(単数形)を使っている場合は、その配列に追加します。 追加後に OpenCode を再起動するか、次を実行します。

opencode reload
opencode plugin list

一覧に tired-dev が表示されることを確認してください。 プラグインが tech-writing を登録するため、スキルの手動コピーは不要です。 動作と環境変数の説明に、Claude Code 版との違いを記載しています。

Cursor

Cursor には tired-dev-cursor を追加します。

  1. Dashboard の Plugins から Team Marketplaces を開きます。
  2. Import from Repo に https://github.com/malanjp/skills を指定します。
  3. tired-dev-cursor を追加します。

導入後は /tech-writing で呼び出せます。 Customize から追加する方法や Cloud Agents への配布は、Cursor 版の READMEを参照してください。

原稿と、読者にしてほしいことを渡します

たとえば、次のように依頼します。

この調査報告を、担当者が次の確認に着手できる形に推敲してください。
確認済みの事実と仮説を分け、判断に必要な根拠を近くに置いてください。

[原稿を貼る]

対象は、仕事で共有する技術文書です。 短いチャット返答や、記事・エッセイには文書の形式を強制しません。

PR と CI の状態をパネルに表示します

dev-mods の pr-status は、現在のブランチの PR、CI の結果、関連する GitHub Issue と Linear Issue を Claude Code のパネルに表示する mod です。 Claude Code のターミナルで次を実行して導入します。

/plugin marketplace add malanjp/skills
/plugin install dev-mods@malanjp

パネルは 10 秒ごとに gh pr view の結果で更新されるため、gh へのログインが必要です。 /pr-status を実行すると、パネルを開いて即座に更新します。 claude.ai Linear の MCP サーバーが接続されている場合は、Linear Issue のタイトルと状態も表示します。

セッション内で起動した開発サーバを一覧表示します

dev-mods の dev-servers は、Claude Code の Bash ツールが起動したプロセスのうち、TCP ポートで待ち受けているものをパネルに表示します。 ポートは http://localhost:<port> へのリンクになり、ステータス行にも dev :3000 :5173 の形で表示します。 一覧は 10 秒ごとに ps と lsof の結果で更新し、/dev-servers を実行すると即座に更新します。

MCP サーバーなど Bash ツール以外の子プロセスと、別のターミナルで起動したサーバは表示しません。 nohup や setsid で親プロセスから切り離したサーバも、親子関係をたどれないため表示しません。

変更後にテストと規約チェックを実行します

Node.js と pnpm を用意し、リポジトリルートで実行します。

pnpm install
pnpm test
pnpm lint

ルートのコマンドは、全パッケージのテストと規約チェックを実行します。 dev-mods のテストと検査には claude コマンドを使います。 個別に確認する場合は、対象の plugins/ 配下へ移動して同じコマンドを使います。

文章の読み心地は自動チェックだけでは判定できません。 記入例を読み、結論・根拠・確認方法を探して戻る箇所がないか確認してください。 生成結果を比較する手順も用意しています。

ライセンス

MIT

Source 5 files
hooks/register.tsx 194 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DevServerInfo, LinearIssue, PrInfo } from '../types'
5import { clean, extractLinear, parseChecks, parseGithubIssues, parseLinearIssue } from './parse'
6import { bashDescendants, parseLsof, parsePs, statusText, summarize, toServers } from './servers'
7import { devServersView, prStatusView } from './views'
8
9const PANE = 'pr-status'
10const POLL_MS = 10_000
11const info = atom({ plugin: 'dev-mods', key: 'info' } as const, null)
12
13const LINEAR_SERVER = 'claude.ai Linear'
14// Linear の状態は頻繁に変わらないので、10 秒ごとの更新で毎回 MCP を呼ばない
15const LINEAR_TTL_MS = 5 * 60_000
16const linearCache = new Map<string, { at: number; issue: LinearIssue }>()
17
18// Linear MCP が使えればタイトルと状態を補う。未接続や失敗時は ID だけで表示する
19async function enrichLinear($: EngineInterface, issue: LinearIssue): Promise<LinearIssue> {
20  const hit = linearCache.get(issue.id)
21  if (hit && Date.now() - hit.at < LINEAR_TTL_MS) return { ...hit.issue, url: hit.issue.url ?? issue.url }
22  try {
23    const r = await $.mcp.call(LINEAR_SERVER, 'get_issue', { id: issue.id, fields: ['title', 'url', 'status'] })
24    const got = r.isError ? null : parseLinearIssue(r.content)
25    const merged = got ? { ...issue, ...got, url: got.url ?? issue.url } : issue
26    linearCache.set(issue.id, { at: Date.now(), issue: merged })
27    return merged
28  } catch {
29    return issue
30  }
31}
32
33// gh が 10 秒以上かかったとき、前回の取得と重ならないようにする
34let inFlight: Promise<PrInfo> | null = null
35
36function refresh($: EngineInterface): Promise<PrInfo> {
37  inFlight ??= fetchInfo($).finally(() => {
38    inFlight = null
39  })
40  return inFlight
41}
42
43async function fetchInfo($: EngineInterface): Promise<PrInfo> {
44  const now = new Date().toLocaleTimeString('ja-JP')
45  const next: PrInfo = { branch: '', pr: null, checks: [], githubIssues: [], linearIssues: [], error: null, updatedAt: now }
46  try {
47    const b = await $.process.run(['git', 'branch', '--show-current'])
48    next.branch = b.exitCode === 0 ? clean(b.stdout.trim()) : '(git 外)'
49    let body = ''
50    const r = await $.process.run(
51      ['gh', 'pr', 'view', '--json', 'number,title,state,url,body,statusCheckRollup,closingIssuesReferences'],
52      { timeoutMs: 20_000 },
53    )
54    if (r.exitCode !== 0) {
55      // PR が無いブランチは gh が非 0 で終わる。エラーではなく「PR なし」として表示する
56      if (!/no pull requests found/i.test(r.stderr)) next.error = clean(r.stderr.trim().split('\n')[0] ?? 'gh 失敗')
57    } else {
58      const j = JSON.parse(r.stdout) as {
59        number: number
60        title: string
61        state: string
62        url: string
63        body: string
64        statusCheckRollup: unknown
65        closingIssuesReferences: unknown
66      }
67      next.pr = { number: j.number, title: clean(j.title), state: clean(j.state), url: clean(j.url) }
68      next.checks = parseChecks(j.statusCheckRollup)
69      next.githubIssues = parseGithubIssues(j.closingIssuesReferences)
70      body = j.body ?? ''
71    }
72    next.linearIssues = await Promise.all(extractLinear(next.branch, next.pr?.title ?? '', body).map(i => enrichLinear($, i)))
73  } catch (err) {
74    next.error = clean(err instanceof Error ? err.message : String(err))
75  }
76  await update($, info, () => next)
77  return next
78}
79
80const SERVERS_PANE = 'dev-servers'
81const SERVERS_TITLE = '開発サーバ'
82// バックグラウンド起動の直後はまだ待受していないことが多いので、少し待ってから取り直す
83const SETTLE_MS = 3_000
84const servers = atom({ plugin: 'dev-mods', key: 'servers' } as const, null)
85
86// エンジンの PID はセッション中に変わらない。モジュールの再読み込みで消えてよい
87let enginePid: number | null = null
88let serversInFlight: Promise<DevServerInfo> | null = null
89
90async function getEnginePid($: EngineInterface): Promise<number> {
91  if (enginePid !== null) return enginePid
92  // $.process.run が起動した子の親がエンジン本体になる
93  const r = await $.process.run(['sh', '-c', 'echo $PPID'])
94  const pid = Number(r.stdout.trim())
95  if (r.exitCode !== 0 || !Number.isInteger(pid) || pid <= 1) throw new Error('エンジンの PID を取得できません')
96  enginePid = pid
97  return pid
98}
99
100async function fetchServers($: EngineInterface): Promise<DevServerInfo> {
101  const next: DevServerInfo = { list: [], error: null, updatedAt: new Date().toLocaleTimeString('ja-JP') }
102  try {
103    const pid = await getEnginePid($)
104    const ps = await $.process.run(['ps', '-axo', 'pid=,ppid=,command='])
105    if (ps.exitCode !== 0) throw new Error(ps.stderr.trim() || 'ps 失敗')
106    const commands = bashDescendants(parsePs(ps.stdout), pid)
107    if (commands.size > 0) {
108      // -p で絞らないと全プロセスを走査して数秒かかる
109      const pids = [...commands.keys()].join(',')
110      const ls = await $.process.run(['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-a', '-p', pids, '-Fpcn'])
111      // 待受が 1 件も無いときも lsof は 1 で終わるので、stderr があるときだけ失敗とする
112      if (ls.exitCode !== 0 && ls.stderr.trim()) throw new Error(ls.stderr.trim().split('\n')[0])
113      next.list = toServers(commands, parseLsof(ls.stdout))
114    }
115  } catch (err) {
116    next.error = clean(err instanceof Error ? err.message : String(err))
117  }
118  let before = 0
119  await update($, servers, old => {
120    before = old?.list.length ?? 0
121    return next
122  })
123  $.ui.status(statusText(next))
124  // 0 件から増えたときだけ開く。毎回開くと、閉じたパネルが何度も戻ってくる
125  if (before === 0 && next.list.length > 0) void $.ui.open({ id: SERVERS_PANE, title: SERVERS_TITLE })
126  return next
127}
128
129function refreshServers($: EngineInterface): Promise<DevServerInfo> {
130  serversInFlight ??= fetchServers($).finally(() => {
131    serversInFlight = null
132  })
133  return serversInFlight
134}
135
136export const register: Register = on => {
137  on('session.start', async ($, e, next) => {
138    await $.command.register({ name: 'pr-status', description: 'PR と CI 状態のパネルを開いて更新する' })
139    void $.ui.open({ id: PANE, title: 'PR / CI' })
140    void refresh($).catch(() => undefined)
141    $.clock.every(POLL_MS, () => void refresh($).catch(() => undefined))
142    await $.command.register({ name: 'dev-servers', description: 'このセッションで起動した開発サーバの一覧を開く' })
143    void refreshServers($).catch(() => undefined)
144    $.clock.every(POLL_MS, () => void refreshServers($).catch(() => undefined))
145    return next(e)
146  })
147
148  on('command.run', { command: 'pr-status' }, async $ => {
149    await $.ui.open({ id: PANE, title: 'PR / CI' })
150    const r = await refresh($)
151    if (r.error) return { text: `取得失敗: ${r.error}` }
152    return { text: r.pr ? `#${r.pr.number} を更新しました` : `${r.branch} に PR はありません` }
153  })
154
155  // push や gh 操作の直後は CI 状態が変わるので即時更新する。バックグラウンド起動ではサーバ一覧も取り直す
156  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
157    const ran = await next(e)
158    try {
159      const cmd = e.command
160      if (/\bgit (push|checkout|switch)\b|\bgh pr\b/.test(cmd)) void refresh($).catch(() => undefined)
161      if (e.run_in_background) $.clock.after(SETTLE_MS, () => void refreshServers($).catch(() => undefined))
162    } catch {
163      // パネル更新の失敗でツール結果を壊さない
164    }
165    return ran
166  })
167
168  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
169    const ui = $.ui.resolve(e)
170    const v = await read($, info)
171    if (!v) return <ui.Text dimColor>読み込み中…</ui.Text>
172    return prStatusView(ui, v, e.viewport?.rows ?? 24)
173  })
174
175  on('command.run', { command: 'dev-servers' }, async $ => {
176    await $.ui.open({ id: SERVERS_PANE, title: SERVERS_TITLE })
177    return { text: summarize(await refreshServers($)) }
178  })
179
180  // サーバを止めたら一覧からすぐ消す
181  on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
182    const ran = await next(e)
183    void refreshServers($).catch(() => undefined)
184    return ran
185  })
186
187  on('ui.render', { component: 'Pane', requestId: SERVERS_PANE }, async ($, e) => {
188    const ui = $.ui.resolve(e)
189    const v = await read($, servers)
190    if (!v) return <ui.Text dimColor>読み込み中…</ui.Text>
191    return devServersView(ui, v)
192  })
193}
194
hooks/parse.ts 88 lines
1import type { Check, GithubIssue, LinearIssue } from '../types'
2
3type RawCheck = {
4  __typename?: string
5  name?: string
6  context?: string
7  status?: string
8  conclusion?: string
9  state?: string
10}
11
12const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g
13
14// PR タイトルやチェック名は他人が書けるので、端末の表示を書き換える ESC などの制御文字を除く。
15// 改行とタブは 1 行の表示を崩さないよう空白にする
16export function clean(s: string): string {
17  return s.replace(/[\t\n\r]/g, ' ').replace(CONTROL, '')
18}
19
20// gh pr view --json statusCheckRollup の 1 件を pass / fail / pending / skip に分類する
21export function classify(raw: RawCheck): Check {
22  const name = clean(raw.name ?? raw.context ?? '(unknown)')
23  // StatusContext (外部 CI) は state だけを持つ
24  if (raw.__typename === 'StatusContext') {
25    const s = raw.state ?? ''
26    if (s === 'SUCCESS') return { name, result: 'pass' }
27    if (s === 'PENDING' || s === 'EXPECTED') return { name, result: 'pending' }
28    return { name, result: 'fail' }
29  }
30  if (raw.status !== 'COMPLETED') return { name, result: 'pending' }
31  const c = raw.conclusion ?? ''
32  if (c === 'SUCCESS') return { name, result: 'pass' }
33  if (c === 'SKIPPED' || c === 'NEUTRAL') return { name, result: 'skip' }
34  return { name, result: 'fail' }
35}
36
37export function parseChecks(rollup: unknown): Check[] {
38  if (!Array.isArray(rollup)) return []
39  return rollup.map(r => classify(r as RawCheck))
40}
41
42// Linear の Issue ID (例: ABC-123)。ブランチ名は小文字 (feature/abc-123-...) が慣例なので大文字小文字を区別しない
43const LINEAR_ID = /\b([a-z]{2,6})-(\d+)\b/gi
44const LINEAR_URL = /https:\/\/linear\.app\/[\w-]+\/issue\/([A-Za-z]{2,6}-\d+)/g
45
46// ブランチ名・PR タイトル・本文の Linear URL から Linear Issue を集める。
47// 本文に URL があればそれを使い、無い ID は URL なしで表示する
48export function extractLinear(branch: string, title: string, body: string): LinearIssue[] {
49  const found = new Map<string, string | null>()
50  for (const m of body.matchAll(LINEAR_URL)) {
51    const id = m[1]!.toUpperCase()
52    found.set(id, `${m[0].slice(0, m[0].length - m[1]!.length)}${id}`)
53  }
54  for (const text of [branch, title]) {
55    for (const m of text.matchAll(LINEAR_ID)) {
56      const id = `${m[1]!.toUpperCase()}-${m[2]}`
57      if (!found.has(id)) found.set(id, null)
58    }
59  }
60  return [...found].map(([id, url]) => ({ id, url, title: null, status: null }))
61}
62
63export function parseGithubIssues(refs: unknown): GithubIssue[] {
64  if (!Array.isArray(refs)) return []
65  return refs.flatMap(r => {
66    const { number, url } = r as { number?: unknown; url?: unknown }
67    return typeof number === 'number' && typeof url === 'string' ? [{ number, url: clean(url) }] : []
68  })
69}
70
71// Linear MCP get_issue の結果 (text ブロックの JSON) を読む。読めなければ null
72export function parseLinearIssue(content: unknown): { title: string; url: string | null; status: string | null } | null {
73  if (!Array.isArray(content)) return null
74  const text = content.find(b => (b as { type?: unknown }).type === 'text') as { text?: unknown } | undefined
75  if (typeof text?.text !== 'string') return null
76  try {
77    const j = JSON.parse(text.text) as { title?: unknown; url?: unknown; status?: unknown }
78    if (typeof j.title !== 'string') return null
79    return {
80      title: clean(j.title),
81      url: typeof j.url === 'string' ? clean(j.url) : null,
82      status: typeof j.status === 'string' ? clean(j.status) : null,
83    }
84  } catch {
85    return null
86  }
87}
88
hooks/servers.ts 87 lines
1import type { DevServer, DevServerInfo } from '../types'
2import { clean } from './parse'
3
4export type Proc = { pid: number; ppid: number; command: string }
5export type Listen = { name: string; addrs: string[] }
6
7// Bash ツールのコマンドはエンジン直下の `<shell> -c ...` で動く。
8// MCP サーバなどエンジンの他の子プロセスも待受することがあるので、シェルの配下だけを対象にする
9const SHELL = /^(\S*\/)?(zsh|bash|sh) -c /
10const MAX_COMMAND = 200
11
12// `ps -axo pid=,ppid=,command=` の出力を読む
13export function parsePs(text: string): Proc[] {
14  const out: Proc[] = []
15  for (const line of text.split('\n')) {
16    const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)
17    if (m) out.push({ pid: Number(m[1]), ppid: Number(m[2]), command: m[3] ?? '' })
18  }
19  return out
20}
21
22// エンジン直下のシェルとその子孫を pid からコマンドへの対応で返す
23export function bashDescendants(procs: Proc[], enginePid: number): Map<number, string> {
24  const children = new Map<number, Proc[]>()
25  for (const p of procs) children.set(p.ppid, [...(children.get(p.ppid) ?? []), p])
26  const found = new Map<number, string>()
27  const queue = (children.get(enginePid) ?? []).filter(p => SHELL.test(p.command))
28  while (queue.length > 0) {
29    const p = queue.shift()!
30    if (found.has(p.pid)) continue
31    found.set(p.pid, p.command)
32    queue.push(...(children.get(p.pid) ?? []))
33  }
34  return found
35}
36
37// `lsof -Fpcn` の出力を pid ごとのプロセス名と待受アドレスにまとめる
38export function parseLsof(text: string): Map<number, Listen> {
39  const out = new Map<number, Listen>()
40  let cur: Listen | null = null
41  for (const line of text.split('\n')) {
42    const tag = line[0]
43    const val = line.slice(1)
44    if (tag === 'p') {
45      const pid = Number(val)
46      cur = out.get(pid) ?? { name: '', addrs: [] }
47      out.set(pid, cur)
48    } else if (tag === 'c' && cur) {
49      cur.name = val
50    } else if (tag === 'n' && cur && !cur.addrs.includes(val)) {
51      cur.addrs.push(val)
52    }
53  }
54  return out
55}
56
57// `127.0.0.1:3000` や `[::1]:3000` や `*:3000` からポート番号を取る
58export function portOf(addr: string): number | null {
59  const port = Number(addr.slice(addr.lastIndexOf(':') + 1))
60  return Number.isInteger(port) && port > 0 && port < 65536 ? port : null
61}
62
63export function toServers(commands: Map<number, string>, listens: Map<number, Listen>): DevServer[] {
64  const out: DevServer[] = []
65  for (const [pid, l] of listens) {
66    const command = commands.get(pid)
67    if (command === undefined) continue
68    const ports = [...new Set(l.addrs.map(portOf).filter((p): p is number => p !== null))].sort((a, b) => a - b)
69    if (ports.length === 0) continue
70    out.push({ pid, name: clean(l.name), command: clean(command).slice(0, MAX_COMMAND), ports })
71  }
72  return out.sort((a, b) => (a.ports[0] ?? 0) - (b.ports[0] ?? 0))
73}
74
75export const NO_SERVERS = 'このセッションで起動した開発サーバはありません'
76
77export function summarize(v: DevServerInfo): string {
78  if (v.error) return `取得失敗: ${v.error}`
79  if (v.list.length === 0) return NO_SERVERS
80  return v.list.map(s => `${s.ports.map(p => `:${p}`).join(' ')} ${s.name} (pid ${s.pid})`).join('\n')
81}
82
83// ステータス行に出す文字列。サーバが無ければ undefined で消す
84export function statusText(v: DevServerInfo): string | undefined {
85  return v.list.length > 0 ? `dev ${v.list.flatMap(s => s.ports.map(p => `:${p}`)).join(' ')}` : undefined
86}
87
hooks/views.tsx 84 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { Check, DevServerInfo, PrInfo } from '../types'
4import { NO_SERVERS } from './servers'
5
6// $ はインポート先へ渡せないので、描画は $.ui.resolve の結果だけを受け取る
7type Ui = ReturnType<EngineInterface['ui']['resolve']>
8
9const ICON: Record<Check['result'], string> = { pass: '✓', fail: '✗', pending: '…', skip: '-' }
10
11export function prStatusView({ Box, Text, Link }: Ui, v: PrInfo, rows: number) {
12  const count = (k: Check['result']) => v.checks.filter(c => c.result === k).length
13  const failed = v.checks.filter(c => c.result === 'fail')
14  const pending = v.checks.filter(c => c.result === 'pending')
15  const room = Math.max(1, rows - 8)
16
17  return (
18    <Box flexDirection="column">
19      <Text dimColor>branch: {v.branch}</Text>
20      {v.linearIssues.length > 0 && (
21        <Box flexDirection="column">
22          {v.linearIssues.map(i => (
23            <Text>
24              {i.url ? <Link href={i.url} label={i.id} /> : i.id}
25              {i.status && <Text color="cyan"> [{i.status}]</Text>}
26              {i.title && <Text dimColor> {i.title}</Text>}
27            </Text>
28          ))}
29        </Box>
30      )}
31      {v.githubIssues.length > 0 && (
32        <Text>
33          Issue: {v.githubIssues.map(i => <Link href={i.url} label={`#${i.number} `} />)}
34        </Text>
35      )}
36      {v.error && <Text color="red">エラー: {v.error}</Text>}
37      {!v.error && !v.pr && <Text dimColor>PR なし</Text>}
38      {v.pr && (
39        <Box flexDirection="column">
40          <Text bold>
41            #{v.pr.number} [{v.pr.state}]
42          </Text>
43          <Text>{v.pr.title}</Text>
44          <Text>
45            <Text color="green">✓{count('pass')}</Text> <Text color="red">✗{count('fail')}</Text>{' '}
46            <Text color="yellow">…{count('pending')}</Text> <Text dimColor>-{count('skip')}</Text>
47          </Text>
48          {[...failed, ...pending].slice(0, room).map(c => (
49            <Text color={c.result === 'fail' ? 'red' : 'yellow'}>
50              {ICON[c.result]} {c.name}
51            </Text>
52          ))}
53        </Box>
54      )}
55      <Text dimColor>更新 {v.updatedAt}</Text>
56    </Box>
57  )
58}
59
60export function devServersView({ Box, Text, Link }: Ui, v: DevServerInfo) {
61  return (
62    <Box flexDirection="column">
63      {v.error && <Text color="red">エラー: {v.error}</Text>}
64      {!v.error && v.list.length === 0 && <Text dimColor>{NO_SERVERS}</Text>}
65      {v.list.map(s => (
66        <Box flexDirection="column">
67          <Text>
68            {s.ports.map(p => (
69              <Link href={`http://localhost:${p}`} label={`:${p} `} />
70            ))}
71            <Text bold>{s.name}</Text>
72            <Text dimColor> pid {s.pid}</Text>
73          </Text>
74          <Text dimColor wrap="truncate-end">
75            {'  '}
76            {s.command}
77          </Text>
78        </Box>
79      ))}
80      <Text dimColor>更新 {v.updatedAt}</Text>
81    </Box>
82  )
83}
84
types/index.d.ts 21 lines
1export type Check = { name: string; result: 'pass' | 'fail' | 'pending' | 'skip' }
2export type GithubIssue = { number: number; url: string }
3export type LinearIssue = { id: string; url: string | null; title: string | null; status: string | null }
4export type PrInfo = {
5  branch: string
6  pr: { number: number; title: string; state: string; url: string } | null
7  checks: Check[]
8  githubIssues: GithubIssue[]
9  linearIssues: LinearIssue[]
10  error: string | null
11  updatedAt: string
12}
13export type DevServer = { pid: number; name: string; command: string; ports: number[] }
14export type DevServerInfo = { list: DevServer[]; error: string | null; updatedAt: string }
15
16declare module 'claude-code' {
17  interface PluginState {
18    'dev-mods': { info: PrInfo | null; servers: DevServerInfo | null }
19  }
20}
21