SLOPSHOPPER

hn-feed

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

newpanerowsguardcommandtool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hn-feed
│ ┃ HN ✕ › fix the failing auth test and add an audit log call │ ┃ 1: tab-top 2: tab-new │ ┃ ⚠ JSON Parse error: Unexpected EOF ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ 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 │ │ › /feed │ 1: tab-top 2: tab-new │ ⚠ JSON Parse error: Unexpected EOF │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · HN
1: tab-top 2: tab-new ⚠ JSON Parse error: Unexpected EOF
Command output
1: tab-top 2: tab-new ⚠ JSON Parse error: Unexpected EOF
README

hn-feed — Claude Code mod のテンプレート

/feed で Hacker News の記事をチャットとペインに表示する、最小構成の mod です。x-timeline と同じ作りを 1 ファイル(hooks/register.tsx)にまとめています。

入っているもの

  • スラッシュコマンド /feed(top / new): 文章で答えます。この文章は会話に残り、Claude も読めます。
  • チャット内のライブ表示: 一番新しい /feed の出力を、タブと「☆ save」ボタンが押せる UI として描きます(ui.render の CommandOutput)。
  • ペイン: 同じ描画を Pane に出します。
  • 状態: atom / read / update。書き込むと、それを読んでいる画面が描き直されます。
  • Claude が読めるツール: read_feed(読み取り専用)。
  • テスト: tests/feed.test.ts。HTTP をモックし、端末とデスクトップの両方でボタンを押して確かめます。

自分の mod にする

  1. フォルダをコピーし、hn-feed を自分の名前に置き換えます。置き換える場所は plugin.json、types/index.d.ts、register.tsx の 3 つです。
  2. load() を自分のデータ源に差し替えます。
  3. asText()(チャットに残る文章)と draw()(画面)を自分の表示にします。
  4. 確認します:
claude plugin validate .
claude plugin test .
claude --plugin-dir .

決まりごと(validate が確かめます)

  • $ を受け取る関数は、register.tsx の中でトップレベルに宣言します。エンジンは $ の使われ方をソースから読み取るため、別ファイルの関数には渡せません。$ を使わない純粋な関数は別ファイルに分けて構いません。
  • $.state のキーは types/index.d.ts の PluginState に宣言します。
  • 画面の要素は $.ui.resolve(e) から取り出します。描ける要素は面ごとに違います(例: Svg はデスクトップのみ、Image は端末のみ)。
  • 描画中に状態を書き込まないでください。ボタンの onPress などから書きます。
Source 2 files
hooks/register.tsx 135 lines
1// 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}
135
types/index.d.ts 12 lines
1export 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