Template: a feed mod — /feed lists Hacker News stories in the chat and a pane, with tabs, save buttons and a tool Claude can read

/feed で Hacker News の記事をチャットとペインに表示する、最小構成の mod です。x-timeline と同じ作りを 1 ファイル(hooks/register.tsx)にまとめています。
/feed(top / new): 文章で答えます。この文章は会話に残り、Claude も読めます。/feed の出力を、タブと「☆ save」ボタンが押せる UI として描きます(ui.render の CommandOutput)。Pane に出します。atom / read / update。書き込むと、それを読んでいる画面が描き直されます。read_feed(読み取り専用)。tests/feed.test.ts。HTTP をモックし、端末とデスクトップの両方でボタンを押して確かめます。hn-feed を自分の名前に置き換えます。置き換える場所は plugin.json、types/index.d.ts、register.tsx の 3 つです。load() を自分のデータ源に差し替えます。asText()(チャットに残る文章)と draw()(画面)を自分の表示にします。claude plugin validate .
claude plugin test .
claude --plugin-dir .
$ を受け取る関数は、register.tsx の中でトップレベルに宣言します。エンジンは $ の使われ方をソースから読み取るため、別ファイルの関数には渡せません。$ を使わない純粋な関数は別ファイルに分けて構いません。$.state のキーは types/index.d.ts の PluginState に宣言します。$.ui.resolve(e) から取り出します。描ける要素は面ごとに違います(例: Svg はデスクトップのみ、Image は端末のみ)。onPress などから書きます。hooks/register.tsx 135 lines1// A feed mod in one file: the patterns x-timeline is built from, small.
2//
3// /feed a slash command that answers with text (the transcript
4// keeps it, the model reads it) ...
5// CommandOutput ... and draws its newest answer live in the chat:
6// tabs and buttons that work in place
7// Pane the same drawing in a side pane
8// $.state atoms what the drawings read; a write redraws them
9// $.tool.register a read-only tool, so Claude can read what is on screen
10//
11// Rename `hn-feed` (here, in plugin.json and types/index.d.ts), point
12// `load` at your own source, and draw your own rows.
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register, RenderInput } from 'claude-code'
15
16import type { Feed, Story, Tab } from '../types'
17
18const PANE = 'hn-feed'
19const API = 'https://hacker-news.firebaseio.com/v0'
20const SHOWN = 10
21
22const EMPTY: Feed = { tab: 'top', stories: [], saved: [], error: null, liveTail: null }
23const feed = atom({ plugin: 'hn-feed', key: 'feed' } as const, EMPTY)
24
25type Engine = EngineInterface
26
27// Every function handed `$` is declared at the top of this file: the engine
28// reads the module's source to list what it calls, and follows `$` only here.
29async function load($: Engine, tab: Tab): Promise<void> {
30 try {
31 const ids = JSON.parse((await $.http.fetch(`${API}/${tab}stories.json`)).text) as number[]
32 const items = await Promise.all(ids.slice(0, SHOWN).map(id => $.http.fetch(`${API}/item/${id}.json`).then(res => JSON.parse(res.text))))
33 const stories: Story[] = items.map(item => ({
34 id: item.id,
35 title: item.title ?? '(untitled)',
36 url: item.url ?? null,
37 by: item.by ?? '',
38 score: item.score ?? 0,
39 comments: item.descendants ?? 0,
40 }))
41 await update($, feed, f => ({ ...EMPTY, ...f, tab, stories, error: null }))
42 } catch (error) {
43 await update($, feed, f => ({ ...EMPTY, ...f, error: error instanceof Error ? error.message : String(error) }))
44 }
45}
46
47function asText(f: Feed): string {
48 if (f.error) return `⚠ ${f.error}`
49 const rows = f.stories.map((story, i) => `\`${i + 1}\` **${story.title}** · ▲${story.score} · 💬${story.comments}${story.url ? ` \n${story.url}` : ''}`)
50
51 return [`**${f.tab === 'top' ? 'Top' : 'New'}** stories`, ...rows].join('\n\n')
52}
53
54// The answer `/feed` prints; its tail marks the row the chat draws live.
55async function show($: Engine, tab: Tab): Promise<string> {
56 await load($, tab)
57 const text = asText(await read($, feed))
58 await update($, feed, f => ({ ...f, liveTail: text.slice(-120) }))
59 void $.ui.open({ id: PANE, title: 'HN' })
60
61 return text
62}
63
64function toggleSaved($: Engine, id: number) {
65 return update($, feed, f => ({ ...f, saved: f.saved.includes(id) ? f.saved.filter(one => one !== id) : [...f.saved, id] }))
66}
67
68// One drawing for both sites; `$.ui.resolve(e)` gives the surface's elements.
69async function draw($: Engine, e: RenderInput<'Pane' | 'CommandOutput'>) {
70 const { Box, Text, Button, Link } = $.ui.resolve(e)
71 const f = await read($, feed)
72 const tab = (name: Tab, label: string, hotkey: string) => (
73 <Button key={`tab-${name}`} plain hotkey={hotkey} onPress={() => void load($, name)}>
74 <Text bold={f.tab === name} underline={f.tab === name} dimColor={f.tab !== name}>
75 {label}
76 </Text>
77 </Button>
78 )
79
80 return (
81 <Box flexDirection="column">
82 <Box columnGap={2}>
83 {tab('top', 'Top', '1')}
84 {tab('new', 'New', '2')}
85 </Box>
86 {f.error && <Text color="error">⚠ {f.error}</Text>}
87 {f.stories.map(story => (
88 <Box key={String(story.id)} flexDirection="column" marginTop={1}>
89 <Text bold>{story.title}</Text>
90 <Box columnGap={2}>
91 <Text dimColor>
92 ▲{story.score} · 💬{story.comments} · {story.by}
93 </Text>
94 <Button key={`save-${story.id}`} plain dimColor={!f.saved.includes(story.id)} onPress={() => void toggleSaved($, story.id)}>
95 {f.saved.includes(story.id) ? '★ saved' : '☆ save'}
96 </Button>
97 {story.url && <Link href={story.url} label="↗" />}
98 </Box>
99 </Box>
100 ))}
101 </Box>
102 )
103}
104
105export const register: Register = on => {
106 on('session.start', async ($, e, next) => {
107 await $.command.register({ name: 'feed', description: 'Hacker News stories (top | new)', argumentHint: '[top|new]' })
108 await $.tool.register({
109 name: 'read_feed',
110 description: 'Read the stories the hn-feed mod has loaded for the user. Read-only; makes no request.',
111 inputSchema: { type: 'object', properties: {} },
112 })
113
114 return next(e)
115 })
116
117 on('command.run', { command: 'feed' }, async ($, e) => ({ text: await show($, e.args.trim() === 'new' ? 'new' : 'top') }))
118
119 // The tool's arguments ride the event itself; this one takes none.
120 on('tool.call', { tool: /^mcp__hn-feed__read_feed$/ }, async $ => {
121 const text = asText(await read($, feed))
122
123 return { result: { text }, text }
124 })
125
126 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => draw($, e))
127
128 // Only the newest `/feed` answer is live; older ones stay the text they were.
129 on('ui.render', { component: 'CommandOutput', props: { command: 'feed' } }, async ($, e, next) => {
130 const f = await read($, feed)
131
132 return f.liveTail && e.props.text.endsWith(f.liveTail) ? draw($, e) : next(e)
133 })
134}
135types/index.d.ts 12 lines1export type Story = { id: number; title: string; url: string | null; by: string; score: number; comments: number }
2
3export type Tab = 'top' | 'new'
4
5export type Feed = { tab: Tab; stories: Story[]; saved: number[]; error: string | null; liveTail: string | null }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'hn-feed': { feed: Feed }
10 }
11}
12