SLOPSHOPPER

link-prefetch

Paste a Slack thread, Linear issue or GitHub PR link and Claude gets its contents as context before it answers

newtoaststatuspromptprocess
A shopper browsing a rack in a slop shop
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 1 files
hooks/register.tsx 105 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3type $ = EngineInterface
4type Json = any // composio responses are per-tool; each caller picks the fields it needs
5
6const CONTEXT_CAP = 12_000
7
8async function composio($: $, slug: string, data: unknown): Promise<Json | null> {
9  const bin = `${await $.env.get('HOME')}/.composio/composio`
10  const run = await $.process
11    .run([bin, 'execute', slug, '-d', JSON.stringify(data)], { timeoutMs: 20_000 })
12    .catch(() => null)
13  if (!run || run.exitCode !== 0) return null
14  const out = await Promise.resolve()
15    .then(() => JSON.parse(run.stdout))
16    .catch(() => null)
17  return out?.successful ? out.data : null
18}
19
20const linear = async ($: $, query: string, variables: object): Promise<Json | null> =>
21  (await composio($, 'LINEAR_RUN_QUERY_OR_MUTATION', { query_or_mutation: query, variables }))?.data ?? null
22
23type Link =
24  | { kind: 'slack'; url: string; channel: string; ts: string }
25  | { kind: 'linear'; url: string; id: string }
26  | { kind: 'github'; url: string; owner: string; repo: string; number: number }
27
28function findLinks(text: string): Link[] {
29  const links: Link[] = []
30  for (const m of text.matchAll(/https:\/\/[\w-]+\.slack\.com\/archives\/(\w+)\/p(\d{10})(\d{6})\S*/g)) {
31    const root = m[0].match(/thread_ts=(\d+\.\d+)/)?.[1]
32    links.push({ kind: 'slack', url: m[0], channel: m[1]!, ts: root ?? `${m[2]}.${m[3]}` })
33  }
34  for (const m of text.matchAll(/https:\/\/linear\.app\/[\w-]+\/issue\/([A-Z]+-\d+)\S*/g)) {
35    links.push({ kind: 'linear', url: m[0], id: m[1]! })
36  }
37  for (const m of text.matchAll(/https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)\S*/g)) {
38    links.push({ kind: 'github', url: m[0], owner: m[1]!, repo: m[2]!, number: Number(m[3]) })
39  }
40  // ponytail: first 5 links only, so a pasted wall of URLs can't stall the prompt
41  return links.filter((l, i) => links.findIndex(o => o.url === l.url) === i).slice(0, 5)
42}
43
44async function fetchLink($: $, link: Link): Promise<unknown> {
45  switch (link.kind) {
46    case 'slack': {
47      const data = await composio($, 'SLACK_FETCH_MESSAGE_THREAD_FROM_A_CONVERSATION', {
48        channel: link.channel,
49        ts: link.ts,
50        limit: 100,
51      })
52      return data?.messages?.map((m: Json) => ({ user: m.user ?? m.username, ts: m.ts, text: m.text }))
53    }
54    case 'linear':
55      return (
56        await linear(
57          $,
58          'query($id:String!){ issue(id:$id){ identifier title url state{name} assignee{name} priorityLabel description comments(first:30){nodes{user{name} body createdAt}} } }',
59          { id: link.id },
60        )
61      )?.issue
62    case 'github': {
63      const pr = await composio($, 'GITHUB_GET_A_PULL_REQUEST', {
64        owner: link.owner,
65        repo: link.repo,
66        pull_number: link.number,
67      })
68      return (
69        pr && {
70          title: pr.title,
71          state: pr.merged ? 'merged' : pr.state,
72          draft: pr.draft,
73          author: pr.user?.login,
74          base: pr.base?.ref,
75          head: pr.head?.ref,
76          size: `+${pr.additions} -${pr.deletions} in ${pr.changed_files} files`,
77          reviewers: pr.requested_reviewers?.map((r: Json) => r.login),
78          body: pr.body,
79        }
80      )
81    }
82  }
83}
84
85export const register: Register = on => {
86  on('prompt.submit', async ($, e, next) => {
87    const links = findLinks(e.text)
88    if (links.length === 0) return next(e)
89
90    $.ui.status(`prefetching ${links.length} link${links.length > 1 ? 's' : ''}…`)
91    const fetched = await Promise.all(links.map(l => fetchLink($, l).catch(() => null)))
92    $.ui.status(undefined)
93
94    const blocks = links.flatMap((l, i) =>
95      fetched[i] == null
96        ? []
97        : [`Prefetched via Composio (${l.kind}) ${l.url}:\n${JSON.stringify(fetched[i], null, 1).slice(0, CONTEXT_CAP)}`],
98    )
99    const missed = links.length - blocks.length
100    $.ui.toast(`Prefetched ${blocks.length}/${links.length} link${links.length > 1 ? 's' : ''}${missed ? `, ${missed} failed` : ''}`)
101
102    return next({ ...e, context: [...(e.context ?? []), ...blocks] })
103  })
104}
105