Commits changes to the claude-mods repository it lives in and offers a one-click push (opt-in per clone)

My mods for Claude Code and my status line.
Shows the GitHub issues and pull requests to check for the repository in the current working directory, using the gh CLI.
↗ web) to open it in the browser.✦ Claude drafts a prompt in the input box to handle that PR or issue inside Claude Code. Nothing is sent until you press Enter, and the prompt asks Claude not to comment, approve or merge without asking./gh-review toggles the pane, /gh-review refresh refreshes. Data refreshes every 5 minutes and when the session changes directory.Requires gh installed and authenticated (gh auth login).
Keeps a clone of this repository committed, and leaves the push to you. After each Claude Code turn, and every two minutes, it validates the plugins that changed and commits everything with a message naming what changed (Update gh-review, statusline). When local commits are waiting, a row above the prompt shows ⇡ claude-mods: N to push with a Push button. /repo-sync commits now, /repo-sync push commits and pushes.
It does nothing unless the clone opts in:
git config claude-mods.autosync true
The flag lives in the clone's local git config, so forks and installed copies never commit on their own.
Requires jq, and gh authenticated (gh auth login) for gh-review and the PR number in the status line.
curl -fsSL https://raw.githubusercontent.com/debba/claude-mods/main/install.sh | bash
Or from a clone:
git clone https://github.com/debba/claude-mods && cd claude-mods
./install.sh # gh-review from the marketplace + the status line
./install.sh --no-statusline # plugins only
./install.sh --no-plugins # status line only
The script backs up ~/.claude/settings.json and any existing ~/.claude/statusline.sh before changing them. Restart Claude Code afterwards.
To install only the mod from inside Claude Code instead:
/plugin install gh-review --marketplace debba/claude-mods
Answer y to add the marketplace, then pick a scope.
statusline/statusline.sh shows the model, git branch and PR, context usage, 5-hour and weekly limits, time to the 5-hour reset and a link to the usage dashboard. It needs jq, and gh for the PR number.
cp statusline/statusline.sh ~/.claude/statusline.sh
# or, to edit it in the clone:
ln -s "$PWD/statusline/statusline.sh" ~/.claude/statusline.sh
Then in ~/.claude/settings.json:
"statusLine": { "type": "command", "command": "~/.claude/statusline.sh", "padding": 0 }
Work from a clone so edits hot-reload in every session and repo-sync commits them:
./install.sh --dev
This points CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json at the clone's plugins, symlinks ~/.claude/statusline.sh to statusline/statusline.sh, and sets git config claude-mods.autosync true in the clone.
Check a mod with claude plugin validate plugins/<name> and claude plugin test plugins/<name>.
MIT
hooks/register.tsx 169 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Pending } from '../types'
5
6// Opt-in flag, kept in the clone's local git config so it never travels with the repo:
7// git config claude-mods.autosync true
8const FLAG = 'claude-mods.autosync'
9const INTERVAL_MS = 2 * 60 * 1000
10
11const pending = atom({ plugin: 'repo-sync', key: 'pending' } as const, {
12 ahead: 0,
13 lastSubject: '',
14 isPushing: false,
15} as Pending)
16
17/** Parses `git status --porcelain` into changed paths (renames keep the new path). */
18export const changedPaths = (porcelain: string) =>
19 porcelain
20 .split('\n')
21 .filter(line => line.trim() !== '')
22 .map(line => line.slice(3).split(' -> ').at(-1)!.replace(/^"|"$/g, ''))
23
24/** Groups paths into the areas a commit message names: a plugin, the statusline, or the repo. */
25export const areasOf = (paths: string[]) => {
26 const areas = new Set<string>()
27 for (const path of paths) {
28 const [top, name] = path.split('/')
29 if (top === 'plugins' && name) areas.add(name)
30 else if (top === 'statusline') areas.add('statusline')
31 else areas.add('repo')
32 }
33
34 return [...areas].sort()
35}
36
37export const pluginsOf = (paths: string[]) =>
38 [...new Set(paths.filter(path => path.startsWith('plugins/')).map(path => path.split('/')[1]!))].sort()
39
40export const commitMessage = (paths: string[]) => {
41 const subject = `Update ${areasOf(paths).join(', ')}`
42 const body = paths.map(path => `- ${path}`).join('\n')
43
44 return [subject, body] as const
45}
46
47const firstLine = (text: string) => text.trim().split('\n')[0] ?? ''
48
49const git = ($: Engine, cwd: string, args: string[]) => $.process.run(['git', ...args], { cwd, timeoutMs: 60_000 })
50
51/** The clone's root when it opted in, otherwise undefined. */
52const optedInRoot = async ($: Engine) => {
53 const top = await git($, $.plugin.root, ['rev-parse', '--show-toplevel'])
54 if (top.exitCode !== 0) return undefined
55 const root = top.stdout.trim()
56 const flag = await git($, root, ['config', '--get', FLAG])
57
58 return flag.stdout.trim() === 'true' ? root : undefined
59}
60
61const countAhead = async ($: Engine, root: string) => {
62 const ahead = await git($, root, ['rev-list', '--count', '@{upstream}..HEAD'])
63
64 return ahead.exitCode === 0 ? Number(ahead.stdout.trim()) || 0 : 0
65}
66
67// One commit pass at a time; a reload starts over, which is harmless.
68let isCommitting = false
69
70/** Validates the plugins that changed and commits everything. Never pushes. */
71const commitChanges = async ($: Engine) => {
72 if (isCommitting) return
73 isCommitting = true
74 try {
75 const root = await optedInRoot($)
76 if (root === undefined) return
77
78 const status = await git($, root, ['status', '--porcelain', '--untracked-files=all'])
79 const paths = changedPaths(status.stdout)
80 if (paths.length > 0) {
81 for (const plugin of pluginsOf(paths)) {
82 const dir = `${root}/plugins/${plugin}`
83 if ((await $.fs.stat(dir).catch(() => null)) === null) continue
84 const checked = await $.process.run(['claude', 'plugin', 'validate', dir], { timeoutMs: 120_000 })
85 if (checked.exitCode !== 0) {
86 $.ui.toast(`repo-sync: ${plugin} does not validate, not committing`)
87 return
88 }
89 }
90
91 const [subject, body] = commitMessage(paths)
92 await git($, root, ['add', '--all'])
93 const commit = await git($, root, ['commit', '--quiet', '-m', subject, '-m', body])
94 if (commit.exitCode !== 0) {
95 $.ui.toast(`repo-sync: commit failed: ${firstLine(commit.stderr)}`)
96 return
97 }
98 await update($, pending, value => ({ ...value, lastSubject: subject }))
99 }
100
101 const ahead = await countAhead($, root)
102 await update($, pending, value => ({ ...value, ahead }))
103 } finally {
104 isCommitting = false
105 }
106}
107
108const push = async ($: Engine) => {
109 const root = await optedInRoot($)
110 if (root === undefined) return
111 await update($, pending, value => ({ ...value, isPushing: true }))
112 try {
113 const pull = await git($, root, ['pull', '--rebase', '--autostash', '--quiet'])
114 const pushed = pull.exitCode === 0 ? await git($, root, ['push', '--quiet']) : pull
115 $.ui.toast(pushed.exitCode === 0 ? 'repo-sync: pushed to GitHub' : `repo-sync: push failed: ${firstLine(pushed.stderr)}`)
116 } finally {
117 const ahead = await countAhead($, root)
118 await update($, pending, value => ({ ...value, ahead, isPushing: false }))
119 }
120}
121
122export const register: Register = on => {
123 on('session.start', async ($, e, next) => {
124 await $.command.register({ name: 'repo-sync', description: 'Commit pending changes to claude-mods (arg: push)' })
125 // The timer also catches edits made outside Claude Code (an editor, the statusline script).
126 $.clock.every(INTERVAL_MS, () => void commitChanges($))
127 void commitChanges($)
128
129 return next(e)
130 })
131
132 on('command.run', { command: 'repo-sync' }, async ($, e) => {
133 await commitChanges($)
134 if (e.args.trim() === 'push') await push($)
135 const { ahead } = await read($, pending)
136
137 return { text: ahead > 0 ? `${ahead} commit(s) waiting to be pushed.` : 'claude-mods is up to date.' }
138 })
139
140 on('turn.complete', async ($, e, next) => {
141 const result = await next(e)
142 void commitChanges($)
143
144 return result
145 })
146
147 // Adds a push row under whatever else draws above the prompt (gh-review's bar, for one).
148 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
149 const state = await read($, pending)
150 if (e.props.hasSurvey || state.ahead === 0) return next(e)
151
152 const { Box, Text, Button } = $.ui.resolve(e)
153 const below = await next(e)
154
155 return (
156 <Box flexDirection="column">
157 {below}
158 <Box key="repo-sync-band" flexDirection="row" gap={2} paddingX={1}>
159 <Text color="permission" bold>{`⇡ claude-mods: ${state.ahead} to push`}</Text>
160 {state.lastSubject !== '' && <Text dimColor wrap="truncate-end">{state.lastSubject}</Text>}
161 <Button key="repo-sync-push" variant="primary" onPress={() => void push($)}>
162 {state.isPushing ? 'Pushing…' : 'Push'}
163 </Button>
164 </Box>
165 </Box>
166 )
167 })
168}
169types/index.d.ts 14 lines1export type Pending = {
2 /** Local commits not yet on the upstream branch. */
3 ahead: number
4 /** Subject of the last commit repo-sync made, if any. */
5 lastSubject: string
6 isPushing: boolean
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'repo-sync': { pending: Pending }
12 }
13}
14