SLOPSHOPPER

tool-group-hints

Shows a one-line hint per call under folded tool-group lines (Searched for 1 pattern, ran 2 shell commands), so you can see what ran without expanding it.

newrows
A shopper browsing a rack in a slop shop
README

AI Setup

Personal backup of global OpenCode setup.

Install

On a new machine (macOS or Linux) — no clone needed:

curl -fsSL https://raw.githubusercontent.com/josippapez/ai-setup/main/bootstrap.sh | bash

That clones this repo to ~/.ai-setup and runs the installer. Re-running it updates the clone and reinstalls; if that checkout has uncommitted changes it installs them as-is rather than overwriting your work.

From an existing clone, or to pick one adapter:

./install.sh              # both adapters
./install.sh --claude     # Claude Code config only
./install.sh --opencode   # OpenCode config only

curl -fsSL .../bootstrap.sh | bash -s -- --claude   # same flags through curl

Both are idempotent. Missing prerequisites are installed with the tool people normally use for them: Node via nvm, packages via Homebrew (which runs on Linux too, so one channel covers both platforms). Anything that can't be installed degrades to a warning instead of aborting — except Node, which everything needs.

VariableEffect
AI_SETUP_DIRwhere bootstrap.sh clones (default ~/.ai-setup)
AI_SETUP_REFbranch/tag/commit to install (default main)
AI_SETUP_SKIP_BREW=1never install Homebrew; use the release archive for rtk
AI_SETUP_SKIP_RTK=1skip rtk entirely (the PreToolUse hook then no-ops)
AI_SETUP_SKIP_NODE_INSTALL=1fail instead of installing Node via nvm
AI_SETUP_BIN_DIRwhere a downloaded rtk binary lands (default ~/.local/bin)
CLAUDE_CONFIG_DIRoverride the Claude config dir (default ~/.claude)

Notes:

  • The Claude plugins install only when the claude CLI is on PATH; without it the config files still land and the script says so. Install Claude Code, then re-run.
  • rtk prebuilds have a glibc floor (the arm64 Linux build needs 2.39+). The installer runs the downloaded binary once and removes it if it can't start, since a broken rtk on PATH would fail the PreToolUse hook on every Bash call. Use Homebrew or the .deb/.rpm on older distros.
  • Windows isn't supported natively (the installers are bash and assume POSIX paths). WSL2 works and behaves exactly like the Linux path; rtk itself does publish a Windows build if you want it outside this setup.

Contents

  • opencode/agents/ - global custom agents from ~/.config/opencode/agents.
  • opencode/skills/ - global OpenCode skills from ~/.config/opencode/skills.
  • opencode/rules/ - global OpenCode instruction/rule files from ~/.config/opencode/rules.
  • opencode/plugins/ - global OpenCode plugins from ~/.config/opencode/plugins.
  • install.sh / bootstrap.sh / scripts/install-common.sh - the universal (macOS + Linux) installer, its curl-able bootstrap, and their shared platform helpers.
  • claude/ - Claude Code global config mirroring ~/.claude/: CLAUDE.md, RTK.md, settings.json, hooks/, plus the bundled repo-docs, dev-core, concise-output, rules-index, orchestrate, better-design, and fundamentals plugins. claude/install.sh installs just this adapter; ./install.sh covers both.
  • opencode/opencode.json - global OpenCode config.
  • opencode/env.sh - OpenCode startup environment defaults, including background subagents.
  • opencode/package.json - global plugin dependency manifest.

This repository intentionally excludes dependency folders, environment files, and secret-like filenames. opencode/opencode.json mirrors the global config shape but replaces secret values with environment placeholders such as ${FIGMA_API_KEY}. opencode/plugins/dev-core/ is the source-owned copy of the custom OpenCode plugin; the global folder should be updated from this mirror. The free-tier OpenCode agents (free-tier-*, repo-scout, docs-maintainer, md-worker-free) pin an OpenCode Zen free model. That list rotates and models get retired, so when one stops resolving, pick a replacement from the current free list (curl -s https://opencode.ai/zen/v1/models, /models in the TUI, or the pricing table at <https://opencode.ai/docs/zen/>) and update the model: frontmatter in those files. Free Zen models allow the provider to use your prompts, so keep confidential repos off them. Semantic docs search uses a chunked/Orama vector engine (see opencode/plugins/dev-core/lib/semantic-index.cjs), indexing docs to .opencode/repo-docs/ on session start. Indexing is automatic and incremental (only re-indexes changed files). claude/ mirrors ~/.claude/ and includes a local Claude marketplace (repo-root .claude-plugin/marketplace.json) with repo-docs, dev-core, concise-output, rules-index, orchestrate, better-design (39 vendored web-design/motion/3D skills, tracked by claude/plugins/better-design/skills-lock.json), and fundamentals (the building-blocks plain-words handoff skill). orchestrate, orchestrate-nightly and better-design ship disabled to keep their skill and agent listings out of every session; turn them on per project in /plugin. Synced claude.ai skills are set to user-invocable-only in skillOverrides; orchestration auto-loads only its compact dispatcher, which explicitly reads bundled routing/store/phase/platform references and templates on demand by absolute skill-root path. repo-docs bundles the shared MCP server (find_docs/read_doc/find_libs for Markdown and installed packages, get_blast_radius/get_file_dependents for which JS/TS files a change affects) that dev-core and orchestrate both depend on rather than each shipping their own copy. claude/install.sh performs the Claude-side install, copying CLAUDE.md, RTK.md, settings.json, and hooks/scripts/ (test files excluded) into ~/.claude/, then registering/updating the marketplace and installing every plugin in it when the claude CLI is available. Always-on rules are no longer copied loose into ~/.claude/rules/: dev-core bundles the engineering rules under claude/plugins/dev-core/rules/ and its SessionStart hook (link-rules.cjs) publishes them as native user-scope rules, copying them into the plugin's data dir and pointing ~/.claude/rules/dev-core at that copy. install.sh prunes any previously-installed loose copies. concise-output instead ships its writing rules as a Claude Code output style (output-styles/concise-output.md, force-for-plugin: true), so they load into the system prompt rather than the conversation and survive compaction unchanged. Both plugins restate a short digest on every prompt via inject-rules-digest.cjs.

rules-index is one SessionStart hook that lists the repo's .claude/rules/ and the user's ~/.claude/rules/ files (path, name/description, paths globs) as a [rules-index] block, so path-scoped rules are known before a matching file is read.

Requirements

Both are installed for you by install.sh (see Install) — this section is what it sets up.

  • Node.js on PATH — the hooks in claude/settings.json are Node scripts (hooks/scripts/*.mjs) that parse the hook's stdin JSON themselves (no jq needed). Installed via nvm when missing.
  • rtk (Rust Token Killer) — the PreToolUse Bash hook runs rtk hook claude. Installed via brew install rtk-ai/tap/rtk (macOS and Linux), falling back to the upstream release archive into ~/.local/bin when Homebrew isn't usable.
  • The PostToolUse (Edit/Write/Bash) hook runs format-lint-edited-files.mjs: Prettier (--write) on supported files (.ts, .tsx, .js, .jsx, .json, .md, .css, .scss, .html, .yml, .yaml) and, because settings.json sets HOOK_RUN_ESLINT=1, ESLint (--fix --max-warnings=0) on JS/TS files. Both run from each project's local node_modules/.bin; the hook is best-effort and silently no-ops when the tool isn't installed for that project. It matches Bash as well as the edit tools because most file writing goes through the shell (sed -i, tee, cat > f <<EOF), which carries no file_path; those three shapes are parsed out of the command, and a path the command named but did not create is ignored. Tests: node --test claude/hooks/scripts/*.test.mjs.
Source 1 files
hooks/register.tsx 269 lines
1import type { Register, ToolGroupCall } from 'claude-code'
2
3const MAX_HINTS = 6
4// Lines of content shown under the hints, shared by the group's calls.
5const PREVIEW_LINES = 10
6
7export function labelFor(tool: string): string {
8  const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
9
10  if (!mcp) {
11    return tool
12  }
13
14  // Plugin servers are named plugin_<plugin>_<server>.
15  const server = /^plugin_[^_]+_(.+)$/.exec(mcp[1]!)?.[1] ?? mcp[1]
16
17  return `${server} › ${mcp[2]}`
18}
19
20// Arguments that are the same on nearly every call of a server (chrome-devtools'
21// pageId, ado's project) and would push the useful ones off the line.
22const NOISE = new Set(['pageId', 'project', 'repositoryId', 'device', 'format'])
23
24function argValue(v: unknown): string | undefined {
25  if (Array.isArray(v)) {
26    const first = typeof v[0] === 'string' ? v[0] : undefined
27    return v.length === 0 ? undefined : first !== undefined && v.length === 1 ? first : `[${v.length}]`
28  }
29
30  return ['string', 'number', 'boolean'].includes(typeof v) && String(v).length > 0 ? String(v).split('\n')[0] : undefined
31}
32
33// Any other tool, MCP ones included: an `action` bare, then its first few short arguments.
34function argsSummary(input: Record<string, unknown>): string {
35  const action = typeof input.action === 'string' ? [input.action] : []
36  const args = Object.entries(input)
37    .filter(([k]) => k !== 'action' && !NOISE.has(k))
38    .flatMap(([k, v]) => {
39      const value = argValue(v)
40
41      return value === undefined ? [] : [`${k}=${value}`]
42    })
43
44  return [...action, ...args].slice(0, 3).join(' ')
45}
46
47// A row is a run of styled parts: the verb, the thing acted on, and quieter connectors.
48export type Part = { text: string; kind: 'verb' | 'path' | 'range' | 'pattern' | 'plain' | 'dim' }
49
50// The verb's color follows prompt-timeline's tool cards: reads blue, searches cyan.
51const VERB_COLOR: Record<string, string> = { Read: 'blue', Search: 'cyan', List: 'cyan', Fetch: 'cyan', Ran: 'magenta', Running: 'magenta', Skill: 'magenta', LSP: 'yellow', Load: 'gray' }
52
53const verb = (text: string): Part => ({ text, kind: 'verb' })
54const dim = (text: string): Part => ({ text, kind: 'dim' })
55const plain = (text: string): Part => ({ text, kind: 'plain' })
56
57// Partial reads name their line range (`a.ts:120-180`), as Pi does.
58function readParts(call: ToolGroupCall): Part[] {
59  const path = String((call.input as { file_path?: unknown } | undefined)?.file_path ?? '')
60  const file = (call.output as { file?: { startLine?: unknown; numLines?: unknown; totalLines?: unknown } } | undefined)?.file
61  const start = Number(file?.startLine)
62  const shown = Number(file?.numLines)
63  const isPartial = file && shown !== Number(file.totalLines) && start > 0 && shown > 0
64
65  return [{ text: path, kind: 'path' }, ...(isPartial ? [{ text: `:${start}-${start + shown - 1}`, kind: 'range' } as Part] : [])]
66}
67
68// One row per call, led by a verb as in Codex's Explored block: `Read a.ts`, `Search "foo" in src`, `Ran git status`.
69export function hintParts(call: ToolGroupCall): Part[] {
70  const input = (call.input ?? {}) as Record<string, unknown>
71  const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
72  const where = (k: string): Part[] => (str(k) ? [dim(' in '), { text: str(k), kind: 'path' }] : [])
73
74  switch (call.tool) {
75    case 'Bash':
76      return [verb(call.isRunning ? 'Running' : 'Ran'), plain(` ${str('command').replace(/^\s*cd \S+\s*(&&|;)\s*/, '').split('\n')[0]}`)]
77    case 'Grep':
78      return [verb('Search'), plain(' '), { text: `"${str('pattern')}"`, kind: 'pattern' }, ...where('path'), ...(str('glob') ? [dim(` (${str('glob')})`)] : [])]
79    case 'Glob':
80      return [verb('List'), plain(' '), { text: str('pattern'), kind: 'pattern' }, ...where('path')]
81    case 'Read':
82      return [verb('Read'), plain(' '), ...readParts(call)]
83    case 'WebFetch':
84      return [verb('Fetch'), plain(` ${str('url')}`)]
85    case 'WebSearch':
86      return [verb('Search'), dim(' web '), { text: `"${str('query')}"`, kind: 'pattern' }]
87    case 'ToolSearch':
88      return [verb('Load'), dim(' tools '), plain(str('query'))]
89    case 'Skill':
90      return [verb('Skill'), plain(` ${str('skill')}`)]
91    case 'LSP':
92      return [verb('LSP'), plain(` ${str('operation')} `), { text: str('filePath'), kind: 'path' }, { text: `:${String(input.line ?? '')}`, kind: 'range' }]
93    default: {
94      const args = argsSummary(input)
95
96      return [verb(labelFor(call.tool)), ...(args ? [dim(` ${args}`)] : [])]
97    }
98  }
99}
100
101export function hintFor(call: ToolGroupCall): string {
102  return hintParts(call).map(part => part.text).join('')
103}
104
105export type Row = { calls: ToolGroupCall[]; parts: Part[]; text: string }
106
107// Back-to-back successful reads share one row (`Read a.ts, b.ts`), as Codex merges them.
108export function rowsFor(calls: readonly ToolGroupCall[]): Row[] {
109  const rows: Row[] = []
110
111  for (const call of calls) {
112    const last = rows.at(-1)
113    const isPlainRead = call.tool === 'Read' && !call.isErrored && !call.isRunning
114
115    if (isPlainRead && last && last.calls.every(c => c.tool === 'Read' && !c.isErrored && !c.isRunning)) {
116      last.calls.push(call)
117      last.parts.push(dim(', '), ...readParts(call))
118    } else {
119      rows.push({ calls: [call], parts: hintParts(call), text: '' })
120    }
121  }
122
123  return rows.map(row => ({ ...row, text: row.parts.map(part => part.text).join('') }))
124}
125
126// Long previews keep their head and tail around a `… +N lines` marker.
127export function previewLines(lines: string[], budget: number): string[] {
128  if (lines.length <= budget) {
129    return lines
130  }
131
132  const head = Math.ceil((budget - 1) / 2)
133  const tail = budget - 1 - head
134
135  return [...lines.slice(0, head), `… +${lines.length - head - tail} lines`, ...(tail > 0 ? lines.slice(-tail) : [])]
136}
137
138function statusColor(calls: ToolGroupCall[]): string {
139  if (calls.some(c => c.isErrored)) return 'red'
140  if (calls.some(c => c.isInterrupted)) return 'yellow'
141  if (calls.some(c => c.isRunning)) return 'gray'
142
143  return 'green'
144}
145
146// What a read-like call brought back: its size, and its lines for the preview.
147export function contentOf(call: ToolGroupCall): { size: string; lines: string[] } | undefined {
148  const output = call.output as Record<string, unknown> | undefined
149  const file = output?.file as { content?: unknown; numLines?: unknown; totalLines?: unknown } | undefined
150
151  if (call.tool === 'Read' && output?.type === 'text' && typeof file?.content === 'string') {
152    return { size: `${String(file.numLines)} lines`, lines: file.content.split('\n') }
153  }
154
155  if (call.tool === 'Bash' && typeof output?.stdout === 'string') {
156    if (output.stdout.trim() !== '') {
157      const lines = output.stdout.replace(/\n$/, '').split('\n')
158
159      return { size: `${lines.length} lines`, lines }
160    }
161
162    // `cat >> file <<'EOF'` prints nothing; what it wrote is the heredoc body.
163    const command = (call.input as { command?: unknown } | undefined)?.command
164    const heredoc = typeof command === 'string' ? /<<-?\s*['"]?(\w+)['"]?[^\n]*\n([\s\S]*?)\n\1\s*$/m.exec(command) : null
165
166    return heredoc ? { size: `no output, wrote ${heredoc[2]!.split('\n').length} lines`, lines: heredoc[2]!.replace(/^\n+/, '').split('\n') } : { size: 'no output', lines: [] }
167  }
168
169  return undefined
170}
171
172function clip(text: string, width: number): string {
173  return text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
174}
175
176export const register: Register = on => {
177  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
178    const own = await next(e)
179    const cwd = `${await $.session.cwd()}/`
180
181    if (e.props.isExpanded || e.props.calls.length === 0) {
182      return own
183    }
184
185    const { Box, Text } = $.ui.resolve(e)
186    const width = (e.viewport?.columns ?? 100) - 10
187    const rows = rowsFor(e.props.calls)
188    const shown = rows.slice(0, MAX_HINTS)
189    const rest = rows.length - shown.length
190    const perRow = Math.max(2, Math.floor(PREVIEW_LINES / shown.length))
191
192    // A path reads by its file name: the directory stays quiet, the name stands out.
193    const drawPart = (part: Part) => {
194      const text = part.text.replaceAll(cwd, '')
195
196      switch (part.kind) {
197        case 'verb':
198          return <Text bold color={VERB_COLOR[part.text] ?? 'blue'}>{text}</Text>
199        case 'path': {
200          const cut = text.lastIndexOf('/') + 1
201
202          return (
203            <Text>
204              <Text dimColor>{text.slice(0, cut)}</Text>
205              <Text bold>{text.slice(cut)}</Text>
206            </Text>
207          )
208        }
209        case 'range':
210          return <Text color="yellow">{text}</Text>
211        case 'pattern':
212          return <Text color="yellow">{text}</Text>
213        case 'dim':
214          return <Text dimColor>{text}</Text>
215        default:
216          return <Text>{text}</Text>
217      }
218    }
219
220    // Keep the row to one line: drop parts past the width, then cut the last one.
221    const fit = (parts: Part[], room: number): Part[] => {
222      const out: Part[] = []
223      let left = room
224
225      for (const part of parts) {
226        const text = part.text.replaceAll(cwd, '')
227        if (left <= 1) break
228        out.push({ ...part, text: text.length > left ? clip(text, left) : text })
229        left -= text.length
230      }
231
232      return out
233    }
234
235    return (
236      <Box flexDirection="column">
237        {own}
238        <Box flexDirection="column" paddingLeft={2}>
239          {shown.map(row => {
240            const content = row.calls.length === 1 ? contentOf(row.calls[0]!) : undefined
241            const size = content ? `  · ${content.size}` : ''
242
243            return (
244              <Box flexDirection="column">
245                <Text wrap="truncate-end">
246                  <Text color={statusColor(row.calls)}>{'• '}</Text>
247                  {fit(row.parts, width - size.length).map(drawPart)}
248                  <Text dimColor italic>{size}</Text>
249                </Text>
250                {previewLines(content?.lines ?? [], perRow).map(line =>
251                  /^… \+\d+ lines$/.test(line) ? (
252                    <Text dimColor italic>{`  ┆ ${line}`}</Text>
253                  ) : (
254                    <Text wrap="truncate-end">
255                      <Text color="gray">{'  │ '}</Text>
256                      <Text dimColor>{clip(line, width - 4)}</Text>
257                    </Text>
258                  ),
259                )}
260              </Box>
261            )
262          })}
263          {rest > 0 ? <Text dimColor italic>{`… ${rest} more`}</Text> : null}
264        </Box>
265      </Box>
266    )
267  })
268}
269