SLOPSHOPPER

jr-post

/jrpost: a short "what I built today" post drafted from this session, shown in a pane and copied to your clipboard.

newpanebandcommandtoastmodel
★ 2v0.1.0MITupdated 2026-10-15jrlprost/claude-code-mods/plugins/jr-post
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-post
│ ┃ jr post ✕ › fix the failing auth test and add an audit log call │ ┃ jr post LinkedIn · English │ ┃ Run /jrpost to write one. ⏺ 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 │ │ › /jrpost │ ⎿ jr-post: jr post · writing a English post from this session… │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jr post
jr post LinkedIn · English Run /jrpost to write one.
README

jr-post

At the end of a session, /jrpost drafts a short "what I built today" post from what actually happened in it, shows it in a pane and copies it to your clipboard.

jr post   LinkedIn · English · 74 words · copied

Today I shipped a billable-time tracker for my coding sessions. Every minute
is sorted into what I was doing: prompting, watching the agent work, reading,
away. The tricky part was making sure no minute is ever counted twice when
prompts overlap.

#buildinpublic

[ Copy (c) ]  [ Write another (r) ]
  • Written from the session's own conversation: concrete, first person, 60 to 140 words.
  • Plain style by design: no hype words, no em dashes, at most two hashtags, AI is not mentioned unless the work was about it.
  • /jrpost French writes it in French this once; set your default language and platform in /config.
  • Nothing is published: you paste it where you want.

Commands

Command
/jrpostDraft a post in your default language
/jrpost <language>Draft it in another language

In the pane: c copies again, r writes another version.

Settings (/config)

OptionDefault
LanguageEnglishDefault language of the post
PlatformLinkedInLinkedIn, X, Bluesky or Mastodon: sets tone and length

Cost

One model call per post, on the session's own model, over the conversation as it stands (mostly served from the prompt cache).

Install

/plugin install jr-post --marketplace jrlprost/claude-code-mods

Access

Asks the model one question over the session's own conversation (no tools) and puts the post on your clipboard. Reads no files, runs nothing, publishes nothing.

Source 5 files
hooks/register.tsx 90 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { cleanPost, postPrompt, wordCount } from './post'
5import { gradientText } from './jr/draw'
6import { GRADIENTS } from './jr/gradient'
7
8const PANE = 'jr-post'
9const draft = atom({ plugin: 'jr-post', key: 'draft' } as const, { text: '', language: 'English', status: 'idle' })
10const commands = { isRegistered: false }
11
12async function ensureCommand($: EngineInterface) {
13  if (commands.isRegistered) return
14  commands.isRegistered = true
15  await $.command.register({ name: 'jrpost', description: 'Draft a "what I built today" post from this session: /jrpost [language]' })
16}
17
18async function copy($: EngineInterface) {
19  const { text } = await read($, draft)
20  if (!text) return
21  const copied = await $.ui.copy({ text })
22  $.ui.toast(copied.isCopied ? 'jr post · copied to the clipboard' : 'jr post · could not copy here; select the text in the pane')
23}
24
25/** Asks the session's own conversation for the post, then copies it. */
26async function write($: EngineInterface, language: string, platform: string) {
27  await update($, draft, () => ({ text: '', language, status: 'writing' }))
28  const reply = await $.model.fork({ prompt: postPrompt(language, platform) })
29  if (!reply.isAnswered) {
30    const reason = reply.reason === 'nothing-to-fork' ? 'nothing has happened in this session yet' : reply.reason
31    await update($, draft, () => ({ text: '', language, status: 'failed' as const, reason }))
32    return
33  }
34  await update($, draft, () => ({ text: cleanPost(reply.text), language, status: 'ready' }))
35  await copy($)
36}
37
38export const register: Register = (on, options) => {
39  const defaultLanguage = String(options.language ?? 'English')
40  const platform = String(options.platform ?? 'LinkedIn')
41
42  on('session.start', async ($, e, next) => {
43    const started = await next(e)
44    await ensureCommand($)
45    return started
46  })
47
48  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
49    if (!commands.isRegistered) {
50      $.clock.after(0, () => {
51        void ensureCommand($)
52      })
53    }
54    return next(e)
55  })
56
57  on('command.run', { command: 'jrpost' }, async ($, e) => {
58    const language = e.args.trim() || defaultLanguage
59    await $.ui.open({ id: PANE, title: 'jr post', rows: 16 })
60    $.clock.after(0, () => {
61      void write($, language, platform)
62    })
63    return { text: `jr post · writing a ${language} post from this session…` }
64  })
65
66  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
67    const { Box, Text, Button } = $.ui.resolve(e)
68    const post = await read($, draft)
69    const header = (
70      <Box>
71        {gradientText(Text, 'jr post', GRADIENTS.brand)}
72        <Text dimColor>{`   ${platform} · ${post.language}${post.status === 'ready' ? ` · ${wordCount(post.text)} words · copied` : ''}`}</Text>
73      </Box>
74    )
75    if (post.status === 'writing') return <Box flexDirection="column">{header}<Text dimColor>Writing from this session…</Text></Box>
76    if (post.status === 'failed') return <Box flexDirection="column">{header}<Text color="#ef4565">{`No post: ${post.reason ?? 'no answer'}.`}</Text></Box>
77    if (post.status === 'idle') return <Box flexDirection="column">{header}<Text dimColor>Run /jrpost to write one.</Text></Box>
78    return (
79      <Box flexDirection="column">
80        {header}
81        <Text>{post.text}</Text>
82        <Box columnGap={2}>
83          <Button key="copy" label="Copy" hotkey="c" onPress={() => void copy($)} />
84          <Button key="again" label="Write another" hotkey="r" onPress={() => void write($, post.language, platform)} />
85        </Box>
86      </Box>
87    )
88  })
89}
90
hooks/post.ts 21 lines
1/** The fork's question: a short "what I built today" post from this session. */
2export const postPrompt = (language: string, platform: string) => `Write a short social post, in ${language}, for ${platform}, about what was built in this conversation today.
3
4Rules:
5- First person, as the developer who did the work. Concrete: name what was built and one detail that made it interesting or hard.
6- 60 to 140 words. Short paragraphs. No headings.
7- Plain and specific. No hype words, no em dashes, no "delve", no "not X, but Y", no "here's the thing", no rhetorical questions as openers.
8- At most two hashtags at the very end, or none. No emoji, or one at most.
9- Do not mention Claude, AI or assistants unless the conversation was about them.
10- Reply with the post only.`
11
12/** The post as the fork returned it, trimmed of fences or wrapping quotes. */
13export const cleanPost = (text: string) =>
14  text
15    .trim()
16    .replace(/^```[a-z]*\n?|\n?```$/g, '')
17    .replace(/^"([\s\S]*)"$/, '$1')
18    .trim()
19
20export const wordCount = (text: string) => (text.trim() ? text.trim().split(/\s+/).length : 0)
21
hooks/jr/draw.tsx 35 lines
1// Drawing helpers shared by every jr mod. Synced into each plugin's hooks/jr/.
2import type { Elements } from 'claude-code'
3
4import { bar, paint } from './gradient'
5
6type TextElement = Elements['terminal']['Text']
7
8/** `text` in a gradient, one color per character, bold by default. */
9export const gradientText = (Text: TextElement, text: string, stops: readonly string[], bold = true) => (
10  <Text bold={bold}>
11    {paint(text, stops).map(({ char, color }) => (
12      <Text color={color}>{char}</Text>
13    ))}
14  </Text>
15)
16
17/** A gauge: filled cells in the gradient, empty cells dim. */
18export const gauge = (Text: TextElement, ratio: number, width: number, stops: readonly string[]) => (
19  <Text>
20    {bar(ratio, width, stops).map(cell =>
21      cell.empty ? (
22        <Text color={cell.color} dimColor>
23          {cell.char}
24        </Text>
25      ) : (
26        <Text color={cell.color}>{cell.char}</Text>
27      ),
28    )}
29  </Text>
30)
31
32/** The jr tag that opens every band line: a small gradient label. */
33export const tag = (Text: TextElement, label: string, stops: readonly string[]) =>
34  gradientText(Text, `${label} `, stops)
35
hooks/jr/gradient.ts 75 lines
1// Shared look of every jr mod: one palette, gradients drawn one cell at a time.
2// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh: edit it here only.
3
4export type Rgb = readonly [number, number, number]
5
6const hexToRgb = (hex: string): Rgb => {
7  const n = Number.parseInt(hex.replace('#', ''), 16)
8  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
9}
10
11const rgbToHex = ([r, g, b]: Rgb) =>
12  `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
13
14export const GRADIENTS = {
15  // The jr signature: violet to pink to amber.
16  brand: ['#7f5af0', '#ff5ca8', '#ffb86b'],
17  // Calm to alarm, for gauges where full is bad.
18  heat: ['#2cb67d', '#f4d35e', '#ff8c42', '#ef4565'],
19  // Cool, for neutral figures.
20  ice: ['#3da9fc', '#7f5af0'],
21  // Money and growth.
22  money: ['#2cb67d', '#a8e6cf'],
23} as const satisfies Record<string, readonly string[]>
24
25export type GradientName = keyof typeof GRADIENTS
26
27/** The color at `t` (0..1) along the stops. */
28export const colorAt = (stops: readonly string[], t: number): string => {
29  if (stops.length === 1) return stops[0]!
30  const clamped = Math.min(1, Math.max(0, t))
31  const span = clamped * (stops.length - 1)
32  const i = Math.min(stops.length - 2, Math.floor(span))
33  const a = hexToRgb(stops[i]!)
34  const b = hexToRgb(stops[i + 1]!)
35  const local = span - i
36  return rgbToHex([0, 1, 2].map(k => a[k]! + (b[k]! - a[k]!) * local) as unknown as Rgb)
37}
38
39/** One color per character of `text`, spread across the stops. */
40export const paint = (text: string, stops: readonly string[]) => {
41  const chars = [...text]
42  return chars.map((char, i) => ({ char, color: colorAt(stops, chars.length > 1 ? i / (chars.length - 1) : 0) }))
43}
44
45const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
46
47/**
48 * A bar `width` cells wide filled to `ratio` (0..1), with eighth-cell precision.
49 * Each filled cell takes the gradient color of its position along the whole bar,
50 * so a nearly full heat bar ends red and a low one stays green.
51 */
52export const bar = (ratio: number, width: number, stops: readonly string[]) => {
53  const filled = Math.min(1, Math.max(0, ratio)) * width
54  const full = Math.floor(filled)
55  const partial = EIGHTHS[Math.round((filled - full) * 7)] ?? ''
56  const cells: { char: string; color: string; empty?: true }[] = []
57  for (let i = 0; i < width; i++) {
58    const color = colorAt(stops, width > 1 ? i / (width - 1) : 0)
59    if (i < full) cells.push({ char: '█', color })
60    else if (i === full && partial) cells.push({ char: partial, color })
61    else cells.push({ char: '·', color, empty: true })
62  }
63  return cells
64}
65
66export const formatTokens = (n: number) =>
67  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
68
69export const formatDuration = (ms: number) => {
70  const minutes = Math.max(0, Math.round(ms / 60_000))
71  const h = Math.floor(minutes / 60)
72  const m = minutes % 60
73  return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`
74}
75
types/index.d.ts 8 lines
1export type JrPostDraft = { text: string; language: string; status: 'idle' | 'writing' | 'ready' | 'failed'; reason?: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'jr-post': { draft: JrPostDraft }
6  }
7}
8