SLOPSHOPPER

snippet

/snippet drafts your daily Slack update from Linear + GitHub and posts it on one press

newpanecommandtoastmodelprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · snippet
│ ┃ Daily snippet → Slack ✕ › fix the failing auth test and add an audit log call │ ┃ pulling Linear + GitHub from the last 24h… │ ┃ ⏺ 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 │ │ › /snippet │ ⎿ snippet: Drafting snippet… │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Daily snippet → Slack
pulling Linear + GitHub from the last 24h…
README

claude-composio-mods

Seven Claude Code mods (function-hook plugins) that wire your terminal into Slack, Linear, GitHub and Datadog through the Composio CLI.

Demo: video/out.mp4. The video is built with fframes; its source is in video/.

  • ticket-band: Shows the Linear ticket for the current branch above the prompt, with one-press Done once every PR has merged.
  • review-chaser: /reviews lists your open PRs waiting on reviewers, with a Slack nudge button per reviewer.
  • snippet: /snippet drafts your daily Slack update from the last 24h of Linear + GitHub and posts it on one press.
  • composio-daily: /standup pane, looks up failing-test errors in Datadog ("seen before"), and sets your Slack status while the agent is busy.
  • link-prefetch: Paste a Slack thread, Linear issue or GitHub PR link and Claude gets its contents as context before it answers.
  • composio-pulse: Times every composio CLI call into a status-line sparkline (/pulse for details) and blocks re-linking a toolkit that's already connected.
  • review-lint: When an edit adds a cache or a brittle test assertion, hands Claude a cache-review checklist or rules against implementation-coupled tests.

Install

/plugin marketplace add tridha643/claude-composio-mods
/plugin install ticket-band@claude-composio-mods

Install whichever mods you want the same way. To hack on them instead, clone the repo and run claude --plugin-dir plugins/<mod>.

Requirements

  • The Composio CLI, installed at ~/.composio/composio and logged in, with the toolkits each mod uses connected (Slack, Linear, GitHub, Datadog).
  • gh, logged in, for review-chaser and snippet.
  • review-chaser and snippet search PRs in the ComposioHQ org; change the query in hooks/register.tsx for your own org.

Settings

Set these from the /plugin config menu:

  • composio-daily → standup_channel: the Slack channel the standup Post button sends to.
  • snippet → channel: the Slack channel /snippet posts to.
  • ticket-band → team_keys: comma-separated Linear team keys to look for in branch names. Empty matches any KEY-123.
  • composio-pulse → search_budget_ms: marks the median search time ✓ or ✗ against this budget. 0 hides the mark.

An empty channel hides that Post button.

Source 2 files
hooks/register.tsx 113 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const GH = 'gh'
5let channel = '' // userConfig.channel, set in register; empty hides the Post button
6const PANE = 'snippet'
7
8const draft = atom({ plugin: 'snippet', key: 'draft' } as const, null)
9const phase = atom({ plugin: 'snippet', key: 'phase' } as const, 'drafting')
10const note = atom({ plugin: 'snippet', key: 'note' } as const, '')
11
12const LINEAR_TODAY = `query { viewer { assignedIssues(first: 30, filter: { updatedAt: { gt: "-P1D" } }) {
13  nodes { identifier title url state { name } } } } }`
14
15const system = () => `You write the user's daily Slack update for ${channel || 'their team channel'}.
16Style: lowercase, casual, terse, like an engineer posting in their team channel. No headings, no emoji walls, no fluff, no "excited to".
17Shape: 2-5 short bullet lines covering shipped, in progress, and next or blockers. Skip a category with nothing in it.
18Link PRs as [repo#N](url) and tickets as [TEAM-N](url). Only state facts present in the input; never invent numbers or outcomes.
19Output the message text only.`
20
21async function composio($: EngineInterface, slug: string, data: object) {
22  const { stdout } = await $.process.run([`${await $.env.get('HOME')}/.composio/composio`, 'execute', slug, '-d', JSON.stringify(data)], { timeoutMs: 60_000 })
23  const out = JSON.parse(stdout)
24  if (!out.successful) throw new Error(`${slug}: ${out.error}`)
25  return out.data
26}
27
28async function gather($: EngineInterface) {
29  const since = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)
30  const query = `query { search(query: "is:pr author:@me org:ComposioHQ updated:>=${since}", type: ISSUE, first: 30) {
31    nodes { ... on PullRequest { number title url state isDraft mergedAt repository { name } } } } }`
32  const [gh, linear] = await Promise.all([
33    $.process.run([GH, 'api', 'graphql', '-f', `query=${query}`]),
34    composio($, 'LINEAR_RUN_QUERY_OR_MUTATION', { query_or_mutation: LINEAR_TODAY }),
35  ])
36  const prs = gh.exitCode === 0 ? JSON.parse(gh.stdout).data.search.nodes : []
37  return { prs, tickets: linear.data.viewer.assignedIssues.nodes }
38}
39
40async function redraft($: EngineInterface, extra: string) {
41  await update($, phase, () => 'drafting')
42  await update($, note, () => 'pulling Linear + GitHub from the last 24h…')
43  try {
44    const facts = await gather($)
45    const prompt = `PRs touched in the last 24h:\n${JSON.stringify(facts.prs, null, 1)}\n\nLinear tickets updated in the last 24h:\n${JSON.stringify(facts.tickets, null, 1)}${extra ? `\n\nExtra notes from the user (include these):\n${extra}` : ''}`
46    const reply = await $.model.complete({ model: 'sonnet', system: system(), prompt, maxTokens: 800, effort: 'low' })
47    if (!reply.isAnswered) throw new Error(`model: ${reply.reason}`)
48    await update($, draft, () => reply.text.trim())
49    await update($, phase, () => 'ready')
50    await update($, note, () => `${facts.prs.length} PRs, ${facts.tickets.length} tickets`)
51  } catch (err) {
52    await update($, phase, () => 'failed')
53    await update($, note, () => String(err).slice(0, 200))
54  }
55}
56
57async function post($: EngineInterface) {
58  const text = await read($, draft)
59  if (!text || (await read($, phase)) !== 'ready') return
60  await update($, phase, () => 'posting')
61  try {
62    await composio($, 'SLACK_SEND_MESSAGE', { channel, markdown_text: text })
63    await update($, phase, () => 'posted')
64    $.ui.toast(`Posted to ${channel}`)
65  } catch (err) {
66    await update($, phase, () => 'ready')
67    $.ui.toast(String(err).slice(0, 160))
68  }
69}
70
71export const register: Register = (on, options) => {
72  channel = String(options.channel ?? '').trim()
73  let lastExtra = ''
74
75  on('session.start', async ($, e, next) => {
76    await $.command.register({ name: 'snippet', description: `Draft today's ${channel || 'Slack'} update from Linear + GitHub; optional notes as args` })
77    return next(e)
78  })
79
80  on('command.run', { command: 'snippet' }, async ($, e) => {
81    lastExtra = e.args.trim()
82    await $.ui.open({ id: PANE, title: `Daily snippet → ${channel || 'Slack'}`, focus: true, closeOnEscape: true })
83    void redraft($, lastExtra)
84    return { text: 'Drafting snippet…' }
85  })
86
87  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
88    const { Box, Button, Markdown, Text } = $.ui.resolve(e)
89    const text = await read($, draft)
90    const now = await read($, phase)
91    const status = await read($, note)
92
93    return (
94      <Box flexDirection="column" gap={1}>
95        {now === 'drafting' || text === null ? <Text dimColor>{status || 'drafting…'}</Text> : <Markdown text={text} />}
96        <Box gap={1}>
97          {now === 'ready' && channel && <Button key="post" hotkey="p" variant="primary" label={`Post to ${channel}`} onPress={() => post($)} />}
98          {now === 'posting' && <Text color="yellow">posting…</Text>}
99          {now === 'posted' && <Text color="green">✓ posted</Text>}
100          {text && (
101            <Button key="copy" hotkey="c" label="Copy" onPress={async press => {
102              await $.ui.copy({ text, surface: press.surface })
103              $.ui.toast('Copied')
104            }} />
105          )}
106          {now !== 'drafting' && now !== 'posting' && <Button key="redraft" hotkey="g" label="Redraft" onPress={() => redraft($, lastExtra)} />}
107          {now !== 'drafting' && <Text dimColor>{now === 'failed' ? status : status && `from ${status}`}</Text>}
108        </Box>
109      </Box>
110    )
111  })
112}
113
types/index.d.ts 8 lines
1export type SnippetPhase = 'drafting' | 'ready' | 'posting' | 'posted' | 'failed'
2
3declare module 'claude-code' {
4  interface PluginState {
5    snippet: { draft: string | null; phase: SnippetPhase; note: string }
6  }
7}
8