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.

Personal backup of global OpenCode setup.
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.
| Variable | Effect |
|---|---|
AI_SETUP_DIR | where bootstrap.sh clones (default ~/.ai-setup) |
AI_SETUP_REF | branch/tag/commit to install (default main) |
AI_SETUP_SKIP_BREW=1 | never install Homebrew; use the release archive for rtk |
AI_SETUP_SKIP_RTK=1 | skip rtk entirely (the PreToolUse hook then no-ops) |
AI_SETUP_SKIP_NODE_INSTALL=1 | fail instead of installing Node via nvm |
AI_SETUP_BIN_DIR | where a downloaded rtk binary lands (default ~/.local/bin) |
CLAUDE_CONFIG_DIR | override the Claude config dir (default ~/.claude) |
Notes:
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.rtk itself does publish a Windows build if you want it outside this setup.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.
Both are installed for you by install.sh (see Install) — this section is what it sets up.
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.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.hooks/register.tsx 269 lines1import 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