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

/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.
claude --plugin-dir ./ship-log
or add this repo as a marketplace and install it with /plugin.
Set them in /config (or pluginConfigs["ship-log"].options in settings):
| option | default | what it does | |
|---|---|---|---|
model | haiku | The model that writes the post: an alias (haiku, sonnet) or a full model id. | |
webhookUrl | empty | If 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. |
webhookToken | empty | Sent as Authorization: Bearer <token> with that POST. Stored as a secret. |
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.
hooks/register.ts 132 lines1import { 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}
132hooks/post.ts 162 lines1// 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}
162types/index.d.ts 18 lines1export 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