Kacper Sieradziński

Materiały do odcinków

Mody w Claude Code: git-pasek i jak go zachować na stałe

Mod git-pasek z odcinka: gałąź Git i liczba zmienionych plików nad polem promptu, pytanie przed git push na main i powiadomienie po długiej turze. Do tego prompty, którymi zlecam moda Claude, i sposób, żeby mod ładował się w każdej sesji przez settings.json.

Miniatura filmu: Cudzy mod w Claude Code czyta Twój klucz API?Odcinek na YouTube · 16 minCudzy mod w Claude Code czyta Twój klucz API?

Pliki z odcinka

Komendy uruchamiasz w rozpakowanym folderze z ZIP-a. Ostatnie dwie same dopisują do ~/.claude/settings.json wpisy extraKnownMarketplaces i enabledPlugins; moda wyłączysz w /plugin. Testy: cd git-pasek && claude plugin test.

Instalacja w projekcie

claude --version                      # 2.1.287 albo nowszy (klik i powiadomienie: 2.1.295+)
claude plugin validate ./git-pasek
claude --plugin-dir ./git-pasek       # na jedną sesję

# Na stałe: ~/mods jako Twój marketplace, wpis trafi do ~/.claude/settings.json
mkdir -p ~/mods && cp -r git-pasek .claude-plugin ~/mods/
claude plugin marketplace add ~/mods
claude plugin install git-pasek@moje-mody

Zanim podepniesz. Mod działa z Twoimi uprawnieniami i nie ma piaskownicy, więc cudzego moda najpierw sprawdź: claude plugin validate pokaże jego hooki, wywołania i odczyty zmiennych środowiskowych. Czerwone flagi to prompt.submit, tool.check, dostęp do sieci i odczyt kluczy. Validate pokazuje, czego mod używa, ale nie ocenia, czy to bezpieczne. Wyłączanie wszystkich modów na sesję: claude --safe-mode. Dokumentacja modów.

prompty.md

Zdania, którymi zlecam moda Claude, oraz jak zachować moda na stałe i sprawdzić cudzego.

Pobierz
# Prompty z filmu „Claude Code Mods od podstaw”

Wymagany Claude Code 2.1.287 albo nowszy (`claude --version`). Sesję otwierasz w projekcie z repozytorium Git.

## 1. Własny mod

```
Zrób moda, który nad polem promptu pokazuje gałąź Git i liczbę zmienionych plików.
```

Claude pisze moda do `~/.claude/dev-mods/<id-sesji>/`. Pyta o każdy plik (chroniony katalog) i o hot reload: wybierz „Enable for this session”.

## 2. Zmiana w modzie

```
Dodaj do tego moda: zanim Claude zrobi git push na main, zapytaj mnie, a w razie błędu odmów.
```

Mod przeładowuje się na koniec tury. Sprawdzasz poleceniem: `zrób commit i wypchnij`.

## 2b. Klik w pasek i powiadomienie (Claude Code 2.1.295 albo nowszy)

```
Dodaj jeszcze: kliknięcie paska pokazuje zmienione pliki, a po turze dłuższej niż 30 sekund wyślij mi powiadomienie z własnym tytułem.
```

Kliknięcie paska pokazuje dymek z listą plików. Po długiej turze przychodzi powiadomienie systemowe; gdy powiadomienia są wyłączone, mod pokazuje dymek w terminalu.

## 3. Zachowaj moda na kolejne sesje

Mod napisany przez Claude działa tylko w sesji, w której powstał. Skopiuj go do własnego folderu:

```bash
mkdir -p ~/mods
cp -r ~/.claude/dev-mods/<id-sesji>/git-pasek ~/mods/
```

Potem wybierz, jak go ładować:

```bash
# Na jedną sesję
claude --plugin-dir ~/mods/git-pasek

# Zawsze, przez alias w ~/.zshrc albo ~/.bashrc
alias claude='claude --plugin-dir ~/mods/git-pasek'

# Zawsze, przez settings.json: ~/mods jako Twój marketplace
# (potrzebny plik ~/mods/.claude-plugin/marketplace.json z paczki)
claude plugin marketplace add ~/mods
claude plugin install git-pasek@moje-mody
```

Dwie ostatnie komendy same dopisują do `~/.claude/settings.json` wpisy `extraKnownMarketplaces` i `enabledPlugins`. Wyłączysz moda w `/plugin`.

## 4. Sprawdź cudzego moda, zanim go zainstalujesz

```bash
claude plugin validate ./mod
```

Przeczytaj linie `hooks:`, `calls:` i `env reads:`. Czerwone flagi: `prompt.submit` (widzi każdy prompt), `tool.check` (zatwierdza wywołania narzędzi), `$.http.fetch` (sieć) i `env reads` z kluczami. Validate pokazuje, czego mod używa, ale nie ocenia, czy to bezpieczne.

git-pasek/.claude-plugin/plugin.json

Opis moda: nazwa, wersja, autor.

Pobierz
{
  "name": "git-pasek",
  "version": "0.1.0",
  "description": "Gałąź Git i liczba zmienionych plików nad polem promptu, pytanie przed git push na main",
  "author": { "name": "Kacper Sieradziński" }
}

git-pasek/hooks/hooks.json

Wskazuje moduł z kodem moda.

Pobierz
{
  "description": "Moduł hooków git-pasek",
  "modules": ["./register.js"]
}

git-pasek/hooks/register.js

Kod moda: pasek nad promptem, kliknięcie z listą plików, pytanie przed git push na main, powiadomienie po turze dłuższej niż 30 s. Pytanie przed pushem to dopasowanie tekstu, nie pełna blokada: twardą daje ochrona gałęzi na GitHubie.

Pobierz
// git-pasek: nad polem promptu gałąź Git i liczba zmienionych plików,
// a przed `git push` na main pytanie „Wypchnąć?”.
// Od Claude Code 2.1.295: kliknięcie paska pokazuje zmienione pliki (Button z dziećmi),
// a po długiej turze przychodzi powiadomienie systemowe ($.ui.notify).
// Przykład z filmu YT-046 (Claude Code Mods od podstaw). W filmie ten kod pisze Claude.

// Stan paska, wspólny dla hooków niżej
let galaz = ''
let zmienione = 0
let pliki = []

// Od ilu sekund tury warto zawołać Cię powiadomieniem
const DLUGA_TURA_MS = 30_000

export function register(on) {
  // Start sesji i każde przeładowanie moda: pierwszy odczyt
  on('session.start', async ($, e, next) => {
    const wynik = await next(e)
    await odswiez($)
    return wynik
  })

  // Po każdej turze Claude pliki mogły się zmienić
  // a po długiej turze powiadomienie: możesz odejść od komputera
  on('turn.complete', async ($, e, next) => {
    const wynik = await next(e)
    if (e.agentId) return wynik
    await odswiez($)
    if (!e.isAborted && e.durationMs >= DLUGA_TURA_MS && galaz) {
      const tekst = `Gotowe po ${Math.round(e.durationMs / 1000)} s · ${galaz} · zmienione: ${zmienione}`
      try {
        const wyslane = await $.ui.notify(tekst, { title: 'git-pasek' })
        // Powiadomienia wyłączone albo terminal ich nie ma: zostaje dymek w terminalu
        if (!wyslane.isSent) $.ui.toast(tekst)
      } catch {
        $.ui.toast(tekst)
      }
    }
    return wynik
  })

  // Pasek nad polem promptu
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
    if (!galaz) return next(e)
    const { Box, Button, Text } = $.ui.resolve(e)
    return Box({
      flexDirection: 'row',
      paddingX: 1,
      children: [
        // Cały wiersz to jeden przycisk; key stały, bo tekst (liczba) się zmienia
        Button({
          key: 'git-pasek',
          plain: true,
          onPress: () => $.ui.toast(listaPlikow()),
          children: [
            Text({ color: 'cyan', bold: true, children: '⎇ ' + galaz }),
            Text({ dimColor: true, children: '  ·  zmienione pliki: ' }),
            Text({ color: zmienione > 0 ? 'yellow' : 'green', bold: true, children: String(zmienione) }),
          ],
        }),
      ],
    })
  })

  // git push na main: najpierw pytanie. Gałąź czytamy tuż przed pushem, bo Claude mógł
  // ją zmienić w tej samej turze, a push na main da się zrobić też z innej gałęzi.
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    if (!/\bgit\s+push\b/.test(e.command)) return next(e)
    await odswiez($)
    // To dopasowanie tekstu, nie pełna ochrona: alias gita albo push.default w konfiguracji je ominą.
    // Twardą blokadę daje reguła ochrony gałęzi po stronie serwera (GitHub, GitLab).
    const naMain = galaz === 'main' || /\bgit\s+push\b[^;&|]*(?:[\s:/]main\b|\s--(?:all|mirror)\b)/.test(e.command)
    if (!naMain) return next(e)
    let odp = 'Nie'
    try {
      odp = await $.ui.ask('Wypchnąć main? ' + e.command,
        ['Tak, wypchnij', 'Nie'])
    } catch {
      // Pytanie zamknięte albo nikt nie odpowie (claude -p)
    }
    if (odp !== 'Tak, wypchnij') {
      return { deny: 'Użytkownik odmówił: bez push na main.' }
    }
    return next(e)
  }).catch(async ($, e, next) => {
    // Hook padł przed decyzją: lepiej nie wypychać
    if (next.called) return next(e)
    return { deny: 'Strażnik git push nie zadziałał, push wstrzymany.' }
  })
}

async function odswiez($) {
  const b = await $.process.run(['git', 'branch', '--show-current'])
  if (b.exitCode !== 0) {
    galaz = ''
    $.ui.invalidate('ui.render')
    return
  }
  galaz = b.stdout.trim() || '(odłączony HEAD)'
  const s = await $.process.run(['git', 'status', '--porcelain'])
  pliki = s.stdout.split('\n').filter((l) => l.trim()).map((l) => l.slice(3))
  zmienione = pliki.length
  $.ui.invalidate('ui.render')
}

function listaPlikow() {
  if (pliki.length === 0) return 'Brak zmian'
  const reszta = pliki.length > 5 ? ` i ${pliki.length - 5} więcej` : ''
  return 'Zmienione: ' + pliki.slice(0, 5).join(', ') + reszta
}

git-pasek/tests/register.test.ts

10 testów moda, bez sieci i logowania: cd git-pasek && claude plugin test.

Pobierz
import { describe, expect, test } from 'claude-code/testing'

// Udawany git: gałąź main, dwa zmienione pliki
function udawanyGit(on: any) {
  on('process.run', ($: any, e: any) => {
    const polecenie = JSON.stringify(e)
    if (polecenie.includes('--show-current')) return { value: { stdout: 'main\n', stderr: '', exitCode: 0 } }
    return { value: { stdout: ' M app.py\n?? notatki.md\n', stderr: '', exitCode: 0 } }
  })
}

describe('register', () => {
  test('git push na main bez odpowiedzi nie idzie', async ($, on) => {
    udawanyGit(on)
    on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
    on('tool.call', () => ({ result: 'wypchnięte' }))
    await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
    const r: any = await $.tool.call({ tool: 'Bash', command: 'git push origin main' } as any)
    expect(r.deny).toContain('odmówił')
  })

  test('inne polecenia przechodzą bez pytania', async ($, on) => {
    udawanyGit(on)
    on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
    on('tool.call', () => ({ result: 'ok' }))
    await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
    const r: any = await $.tool.call({ tool: 'Bash', command: 'ls' } as any)
    expect(r.result).toBe('ok')
  })

  test('pasek pokazuje gałąź i liczbę zmienionych plików', async ($, on) => {
    udawanyGit(on)
    on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
    await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
    const ui = await $.ui.mount({
      plugin: 'git-pasek', surface: 'terminal', component: 'AbovePrompt',
      props: { hasSurvey: false, isWorking: false, maxRows: 10, bodyColumns: 120 },
    } as any)
    expect(await ui.find({ type: 'Text', text: /main/ })).toBeDefined()
    expect(await ui.find({ type: 'Text', text: /^2$/ })).toBeDefined()
    await ui.unmount()
  })
})

// Od Claude Code 2.1.295: przycisk z dziećmi i powiadomienie systemowe
describe('push na main: gałąź sprawdzana tuż przed pushem', () => {
  test('przełączenie na main w tej samej turze: i tak pyta', async ($, on) => {
    let galaz = 'feature'
    on('process.run', ($: any, e: any) => {
      const polecenie = JSON.stringify(e)
      if (polecenie.includes('--show-current')) return { value: { stdout: galaz + '\n', stderr: '', exitCode: 0 } }
      return { value: { stdout: '', stderr: '', exitCode: 0 } }
    })
    on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
    on('tool.call', () => ({ result: 'wypchnięte' }))
    await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
    galaz = 'main'
    const r: any = await $.tool.call({ tool: 'Bash', command: 'git push' } as any)
    expect(r.deny).toContain('odmówił')
  })

  test('push na main z innej gałęzi: też pyta', async ($, on) => {
    on('process.run', ($: any, e: any) => {
      const polecenie = JSON.stringify(e)
      if (polecenie.includes('--show-current')) return { value: { stdout: 'feature\n', stderr: '', exitCode: 0 } }
      return { value: { stdout: '', stderr: '', exitCode: 0 } }
    })
    on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
    on('tool.call', () => ({ result: 'wypchnięte' }))
    await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
    const r: any = await $.tool.call({ tool: 'Bash', command: 'git push origin HEAD:main' } as any)
    expect(r.deny).toContain('odmówił')
  })

  test('push --all z innej gałęzi: też pyta', async ($, on) => {
    on('process.run', ($: any, e: any) => {
      const polecenie = JSON.stringify(e)
      if (polecenie.includes('--show-current')) return { value: { stdout: 'feature\n', stderr: '', exitCode: 0 } }
      return { value: { stdout: '', stderr: '', exitCode: 0 } }
    })
    on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
    on('tool.call', () => ({ result: 'wypchnięte' }))
    await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
    const r: any = await $.tool.call({ tool: 'Bash', command: 'git push --all origin' } as any)
    expect(r.deny).toContain('odmówił')
  })

  test('pełny refspec refs/heads/main: też pyta', async ($, on) => {
    on('process.run', ($: any, e: any) => {
      const polecenie = JSON.stringify(e)
      if (polecenie.includes('--show-current')) return { value: { stdout: 'feature\n', stderr: '', exitCode: 0 } }
      return { value: { stdout: '', stderr: '', exitCode: 0 } }
    })
    on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
    on('tool.call', () => ({ result: 'wypchnięte' }))
    await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
    const r: any = await $.tool.call({ tool: 'Bash', command: 'git push origin HEAD:refs/heads/main' } as any)
    expect(r.deny).toContain('odmówił')
  })
})

describe('nowości 2.1.295', () => {
  test('kliknięcie paska pokazuje zmienione pliki', async ($, on) => {
    udawanyGit(on)
    const dymki: string[] = []
    on('ui.toast', ($: any, e: any) => { dymki.push(e.text); return { value: undefined } })
    on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
    await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
    const ui = await $.ui.mount({
      plugin: 'git-pasek', surface: 'terminal', component: 'AbovePrompt',
      props: { hasSurvey: false, isWorking: false, maxRows: 10, bodyColumns: 120 },
    } as any)
    await ui.press({ key: 'git-pasek' } as any)
    expect(dymki.join(' ')).toContain('app.py')
    expect(dymki.join(' ')).toContain('notatki.md')
    await ui.unmount()
  })

  test('długa tura: powiadomienie z gałęzią i liczbą zmian', async ($, on) => {
    udawanyGit(on)
    const powiadomienia: any[] = []
    on('ui.notify', ($: any, e: any) => { powiadomienia.push(e); return { value: { isSent: true, channel: 'iterm2' } } })
    on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
    on('turn.complete', () => ({ text: '' }))
    await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
    await $.turn.complete({ answer: 'ok', durationMs: 45_000, isAborted: false, turnId: 't1', reason: 'answer' } as any)
    expect(powiadomienia.length).toBe(1)
    expect(powiadomienia[0].text).toContain('main')
    expect(powiadomienia[0].text).toContain('zmienione: 2')
  })

  test('krótka tura: bez powiadomienia', async ($, on) => {
    udawanyGit(on)
    const powiadomienia: any[] = []
    on('ui.notify', ($: any, e: any) => { powiadomienia.push(e); return { value: { isSent: true, channel: 'iterm2' } } })
    on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
    on('turn.complete', () => ({ text: '' }))
    await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
    await $.turn.complete({ answer: 'ok', durationMs: 5_000, isAborted: false, turnId: 't2', reason: 'answer' } as any)
    expect(powiadomienia.length).toBe(0)
  })
})

.claude-plugin/marketplace.json

Zamienia folder ~/mods w Twój marketplace, żeby mod ładował się w każdej sesji przez settings.json.

Pobierz
{
  "name": "moje-mody",
  "description": "Moje mody do Claude Code trzymane w ~/mods",
  "owner": { "name": "Ja" },
  "plugins": [
    { "name": "git-pasek", "source": "./git-pasek" }
  ]
}

Chcesz więcej?

Pięć kolejnych skilli do Claude Code dostaniesz w bezpłatnym pakiecie po zapisie do newslettera Dokodu.

Odbierz 5 skilli