SLOPSHOPPER

ship-log

/ship turns what you built this session into a build-in-public post for X or LinkedIn

newcommandpromptmodelprocessnetwork
v0.1.0MITupdated 2026-10-04ShriD5/claude-mods/ship-log
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ship-log
› fix the failing auth test and add an audit log call ⏺ 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 › /ship ⎿ ship-log: ok ⎿ ship-log: ⎿ ship-log: · copied to clipboard · 2/280 chars ⎿ ship-log: · /ship again for another take · /ship linkedin for a longer version ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ship-log

/ship turns what you built in this Claude Code session into a build-in-public post.

It reads the commits and the diffstat since the session started, plus a short list of what you asked Claude for, and has a fast model write one X post: lowercase, first person, one concrete number, under 280 characters, no hashtags, no em dashes, none of the "officially / game-changer / excited to announce" voice. The draft shows up as the command's output and goes to your clipboard.

/ship            an X post
/ship again      another take, a different angle from the last one
/ship linkedin   a longer LinkedIn version (sentence case, short paragraphs, up to 1,300 chars)

A draft that still breaks a rule (too long, a banned phrase, emoji spam) is sent back once with the problems named. If it is still too long after that, it gets cut at the last sentence that fits.

Install

claude --plugin-dir ./ship-log

or add this repo as a marketplace and install it with /plugin.

Options

Set them in /config (or pluginConfigs["ship-log"].options in settings):

optiondefaultwhat it does
modelhaikuThe model that writes the post: an alias (haiku, sonnet) or a full model id.
webhookUrlemptyIf set, every draft is POSTed here as `{ "platform": "x" \"linkedin", "text": "...", "source": "ship-log" }`, so it can drop straight into a content calendar or a drafts queue.
webhookTokenemptySent as Authorization: Bearer <token> with that POST. Stored as a secret.

How it works

It saves HEAD in $.state on session.start and keeps every prompt you type (prompt.submit). /ship runs git log <start>..HEAD and git diff --stat <start> through $.process.run, so uncommitted work counts too, then writes the post with $.model.complete and copies it with $.ui.copy.

Source 3 files
hooks/register.ts 132 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ShipDraft, ShipPlatform, ShipStart } from '../types'
5import { buildPrompt, clip, clipStat, fit, hasWork, LIMITS, parseArgs, problemsOf, squeeze, tidy } from './post'
6import type { ShipContext } from './post'
7
8const start = atom({ plugin: 'ship-log', key: 'start' } as const, null)
9const prompts = atom({ plugin: 'ship-log', key: 'prompts' } as const, [])
10const last = atom({ plugin: 'ship-log', key: 'last' } as const, null)
11
12const USER_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
13
14async function git($: EngineInterface, args: string[]): Promise<string> {
15  const ran = await $.process.run(['git', ...args]).catch(() => undefined)
16
17  return ran?.exitCode === 0 ? ran.stdout.trim() : ''
18}
19
20async function gather($: EngineInterface, since: ShipStart | null): Promise<ShipContext> {
21  const sha = since?.sha ?? null
22  const commits = sha === null ? await git($, ['log', '--oneline', '--no-merges', '-n', '15']) : await git($, ['log', '--oneline', '--no-merges', `${sha}..HEAD`])
23  const diffStat = sha === null ? await git($, ['diff', '--stat', 'HEAD']) : await git($, ['diff', '--stat', sha])
24  const now = await $.clock.now()
25
26  return {
27    commits: clip(commits, 30),
28    diffStat: clipStat(diffStat, 25),
29    prompts: (await read($, prompts)).slice(-25),
30    minutes: since === null ? 0 : Math.max(1, Math.round((now - since.at) / 60_000)),
31  }
32}
33
34async function write($: EngineInterface, model: string, platform: ShipPlatform, context: ShipContext, avoid?: string) {
35  let problems: string[] = []
36  let text = ''
37
38  for (let attempt = 0; attempt < 2; attempt += 1) {
39    const reply = await $.model.complete({
40      model,
41      prompt: buildPrompt(platform, context, avoid, attempt === 0 ? undefined : problems),
42      maxTokens: platform === 'x' ? 300 : 900,
43      timeoutMs: 45_000,
44    })
45
46    if (!reply.isAnswered) {
47      return { error: reply.reason === 'api-error' ? `the model call failed (HTTP ${reply.status})` : `no reply (${reply.reason})` }
48    }
49
50    text = tidy(reply.text, platform)
51    problems = problemsOf(text, platform)
52
53    if (problems.length === 0) {
54      break
55    }
56  }
57
58  return { text: fit(text, LIMITS[platform]) }
59}
60
61export const register: Register = (on, options) => {
62  const model = String(options.model ?? 'haiku') || 'haiku'
63  const webhookUrl = String(options.webhookUrl ?? '').trim()
64  const webhookToken = String(options.webhookToken ?? '').trim()
65
66  on('session.start', async ($, e, next) => {
67    await $.command.register({
68      name: 'ship',
69      description: 'Draft a build-in-public post from this session (again: another take, linkedin: longer)',
70      argumentHint: '[again] [linkedin]',
71    })
72
73    // A hot reload raises session.start again: keep the start we recorded first.
74    if ((await read($, start)) === null) {
75      const sha = (await git($, ['rev-parse', '--verify', '-q', 'HEAD'])) || null
76      const at = await $.clock.now()
77      await update($, start, () => ({ sha, at }))
78    }
79
80    return next(e)
81  })
82
83  on('prompt.submit', async ($, e, next) => {
84    if (USER_ORIGINS.has(e.origin.kind) && !e.text.trimStart().startsWith('/')) {
85      const line = squeeze(e.text)
86      await update($, prompts, list => [...list, line].slice(-40))
87    }
88
89    return next(e)
90  })
91
92  on('command.run', { command: 'ship' }, async ($, e) => {
93    const previous = await read($, last)
94    const { platform, isAgain } = parseArgs(e.args, previous?.platform)
95    const context = await gather($, await read($, start))
96
97    if (!hasWork(context)) {
98      return { text: 'Nothing to ship yet: no commits, no changes and no prompts this session.' }
99    }
100
101    const avoid = isAgain && previous?.platform === platform ? previous.text : undefined
102    const written = await write($, model, platform, context, avoid)
103
104    if (written.text === undefined) {
105      return { text: `ship-log: ${written.error}. Try /ship again.` }
106    }
107
108    const draft: ShipDraft = { platform, text: written.text }
109    await update($, last, () => draft)
110
111    const copied = await $.ui.copy({ text: draft.text })
112    const notes = [
113      `${copied.isCopied ? 'copied to clipboard' : `not copied (${copied.reason})`} · ${draft.text.length}/${LIMITS[platform]} chars`,
114      platform === 'x' ? '/ship again for another take · /ship linkedin for a longer version' : '/ship again for another take · /ship for the X version',
115    ]
116
117    if (webhookUrl !== '') {
118      const sent = await $.http
119        .fetch(webhookUrl, {
120          method: 'POST',
121          headers: { 'content-type': 'application/json', ...(webhookToken === '' ? {} : { authorization: `Bearer ${webhookToken}` }) },
122          body: JSON.stringify({ platform, text: draft.text, source: 'ship-log' }),
123        })
124        .then(response => (response.ok ? `sent to webhook (${response.status})` : `webhook answered ${response.status}`))
125        .catch(() => 'webhook unreachable')
126      notes.push(sent)
127    }
128
129    return { text: `${draft.text}\n\n${notes.map(note => `· ${note}`).join('\n')}` }
130  })
131}
132
hooks/post.ts 162 lines
1// Pure helpers for /ship: what the model is asked, and how its draft is checked.
2import type { ShipPlatform } from '../types'
3
4export const LIMITS: Record<ShipPlatform, number> = { x: 280, linkedin: 1300 }
5
6const BANNED = [
7  'officially',
8  'game-changer',
9  'game changer',
10  'excited to announce',
11  'thrilled to',
12  'delighted to',
13  'just dropped',
14  'stay tuned',
15]
16
17export type ShipArgs = { platform: ShipPlatform; isAgain: boolean }
18
19/** `/ship`, `/ship again`, `/ship linkedin`, `/ship again linkedin`, `/ship x`. */
20export function parseArgs(args: string, lastPlatform: ShipPlatform | undefined): ShipArgs {
21  const words = args.toLowerCase().split(/\s+/).filter(Boolean)
22  const isAgain = words.includes('again')
23  const named = words.find(word => word === 'linkedin' || word === 'li' || word === 'x' || word === 'twitter')
24  const platform: ShipPlatform =
25    named === undefined ? (isAgain ? (lastPlatform ?? 'x') : 'x') : named === 'linkedin' || named === 'li' ? 'linkedin' : 'x'
26
27  return { platform, isAgain }
28}
29
30export type ShipContext = {
31  commits: string
32  diffStat: string
33  prompts: readonly string[]
34  minutes: number
35}
36
37/** Keeps the head of a list of lines and says how many were cut. */
38export function clip(text: string, maxLines: number): string {
39  const lines = text.trim().split('\n').filter(Boolean)
40
41  return lines.length <= maxLines ? lines.join('\n') : [...lines.slice(0, maxLines), `(+${lines.length - maxLines} more)`].join('\n')
42}
43
44/** The diffstat's per-file rows cut down, its summary line ("3 files changed, ...") always kept. */
45export function clipStat(stat: string, maxFiles: number): string {
46  const lines = stat.trim().split('\n').filter(Boolean)
47  const summary = lines.at(-1)?.includes('changed') ? lines.pop() : undefined
48  const files = lines.length <= maxFiles ? lines : [...lines.slice(0, maxFiles), ` (+${lines.length - maxFiles} more files)`]
49
50  return [...files, ...(summary === undefined ? [] : [summary])].join('\n')
51}
52
53export function hasWork(context: ShipContext): boolean {
54  return context.commits.trim() !== '' || context.diffStat.trim() !== '' || context.prompts.length > 0
55}
56
57const VOICE: Record<ShipPlatform, string> = {
58  x: [
59    'Write ONE post for X about what I built in this coding session.',
60    'Rules:',
61    '- all lowercase, casual, first person ("i"), like a dev texting a friend',
62    '- concrete: say what got built and include one specific number or detail from the material (files, lines, a function, a bug, minutes)',
63    '- under 260 characters',
64    '- no hashtags, at most one emoji, no em dashes or en dashes',
65    '- never use: "officially", "game-changer", "excited to announce", "thrilled", "stay tuned"',
66    '- no lists of three things, no tidy aphorism or moral at the end, no call to action',
67    '- use only facts from the material, never invent backstory, feelings or how long something took',
68    '- reply with the post text only, no quotes, no preface',
69  ].join('\n'),
70  linkedin: [
71    'Write ONE LinkedIn post about what I built in this coding session.',
72    'Rules:',
73    '- sentence case, first person, plain and direct, 3 to 6 short paragraphs of one or two sentences',
74    '- concrete: what got built, why it was needed, one specific number or detail from the material, one thing that was harder than expected',
75    '- at most 1,200 characters',
76    '- no hashtags, no emojis, no em dashes or en dashes, no bullet lists',
77    '- never use: "officially", "game-changer", "excited to announce", "thrilled", "delighted", "stay tuned"',
78    '- no lists of three things, no tidy aphorism or moral at the end, no "what do you think?" call to action',
79    '- use only facts from the material, never invent backstory, feelings or how long something took',
80    '- reply with the post text only, no quotes, no preface',
81  ].join('\n'),
82}
83
84export function buildPrompt(platform: ShipPlatform, context: ShipContext, avoid?: string, problems?: readonly string[]): string {
85  const material = [
86    `Session length: about ${context.minutes} minutes.`,
87    context.commits.trim() === '' ? 'Commits this session: none yet.' : `Commits this session:\n${context.commits}`,
88    context.diffStat.trim() === '' ? '' : `Files changed since the session started (git diff --stat):\n${context.diffStat}`,
89    context.prompts.length === 0 ? '' : `What I asked the coding agent for, in order:\n${context.prompts.map(prompt => `- ${prompt}`).join('\n')}`,
90  ].filter(Boolean)
91
92  return [
93    VOICE[platform],
94    avoid === undefined ? '' : `\nTake a different angle from this earlier draft, do not reuse its opening:\n"""${avoid}"""`,
95    problems === undefined || problems.length === 0 ? '' : `\nYour last draft broke these rules, fix them: ${problems.join('; ')}.`,
96    `\n<material>\n${material.join('\n\n')}\n</material>`,
97  ].join('\n')
98}
99
100/** Cleans what models tend to add around a post: quotes, a "Here's a post:" line, hashtags, dashes. */
101export function tidy(raw: string, platform: ShipPlatform): string {
102  let text = raw.trim()
103
104  text = text.replace(/^(here('|’)?s|here is|sure|okay|ok)\b[^\n]*:\s*\n+/i, '')
105  text = text.replace(/^["“'`]+|["”'`]+$/g, '').trim()
106  text = text.replace(/(^|\s)#[\p{L}\p{N}_]+/gu, '').trim()
107  text = text.replace(/\s*[—–]\s*/g, ', ')
108  text = text.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n')
109
110  return platform === 'x' ? text.toLowerCase() : text
111}
112
113/** The rules a draft still breaks, as short phrases the retry prompt quotes back. */
114export function problemsOf(text: string, platform: ShipPlatform): string[] {
115  const found: string[] = []
116  const lower = text.toLowerCase()
117  const limit = LIMITS[platform]
118
119  if (text.length > limit) {
120    found.push(`it is ${text.length} characters, the limit is ${limit}`)
121  }
122
123  for (const phrase of BANNED) {
124    if (lower.includes(phrase)) {
125      found.push(`it uses "${phrase}"`)
126    }
127  }
128
129  if ((text.match(/\p{Extended_Pictographic}/gu) ?? []).length > 1) {
130    found.push('it has more than one emoji')
131  }
132
133  if (text.trim() === '') {
134    found.push('it is empty')
135  }
136
137  return found
138}
139
140/** Last resort for a draft still over its limit: cut at the last sentence (or word) that fits. */
141export function fit(text: string, limit: number): string {
142  if (text.length <= limit) {
143    return text
144  }
145
146  const head = text.slice(0, limit)
147  const sentence = Math.max(head.lastIndexOf('. '), head.lastIndexOf('! '), head.lastIndexOf('? '), head.lastIndexOf('\n'))
148
149  if (sentence > limit * 0.5) {
150    return head.slice(0, sentence + 1).trim()
151  }
152
153  return head.slice(0, head.lastIndexOf(' ')).trim()
154}
155
156/** A user prompt cut to one line for the summary. */
157export function squeeze(prompt: string, max = 160): string {
158  const line = prompt.replace(/\s+/g, ' ').trim()
159
160  return line.length <= max ? line : `${line.slice(0, max - 1)}…`
161}
162
types/index.d.ts 18 lines
1export type ShipPlatform = 'x' | 'linkedin'
2
3/** Where the session started: the HEAD it started on (null in a repo with no commits yet) and when. */
4export type ShipStart = { sha: string | null; at: number }
5
6/** The last draft /ship wrote, so `/ship again` knows what to move away from. */
7export type ShipDraft = { platform: ShipPlatform; text: string }
8
9declare module 'claude-code' {
10  interface PluginState {
11    'ship-log': {
12      start: ShipStart | null
13      prompts: string[]
14      last: ShipDraft | null
15    }
16  }
17}
18