SLOPSHOPPER

scritture-esterne

A fine turno elenca tutto ciò che è stato scritto fuori dalla repo (Notion, Postpickr, Spreaker, mail, git push) con link e segnalazione degli errori…

newbandguardcommand
v1.0.1MITupdated 2026-10-07andreabrugnoli/mods/scritture-esterne
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · scritture-esterne
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /scritture ⎿ scritture-esterne: scritture-esterne · spento ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

mods

License: MIT Claude Code Mods

Un marketplace di mod per Claude Code, pensato per capire e controllare una sessione: barra della cache e dei consumi, registro di ciò che viene scritto fuori dalla repo, conferma delle proposte, raccolta degli inciampi, modalità rec per le registrazioni e inbox con bozze, etichette e task. Ogni mod è un plugin indipendente, installabile singolarmente con un comando.


Le sei mod

Ogni mod si accende e si spegne con un comando /..., che funziona in terminale, nell'app Desktop e da iPad. Lo stato si ricorda tra una sessione e l'altra.

⏳ barra-cache (/barra) — tre righe sopra il prompt. La prima è la memoria della chat: una barra con i token del contesto occupato e quanto è costata la sessione a listino (verde fino al 60%, gialla fino all'80%, rossa oltre, con il suggerimento Nuova chat). La seconda mostra l'*uso delle 5 ore* e l'*uso della settimana* dell'abbonamento, solo dove il motore riporta le cifre. La terza dice da quanto è aperta la sessione e quanto resta di cache calda: la prompt cache dura un'ora dall'ultima richiesta della conversazione principale (le richieste dei subagent non la rinnovano), poi la richiesta successiva riscrive tutto il contesto a prezzo pieno. Con la cache scaduta e almeno 20mila token di contesto compare un avviso rosso. Sotto, una riga con branch, file modificati e commit da pubblicare (letta da git, solo dove $.process esiste: nel cloud non compare) e i bottoni: Commit e push (tasto g), Push (tasto p, compare solo quando ci sono commit da pubblicare e nessuna modifica da committare, senza token) e Handoff (tasto n), che fa scrivere al modello un riassunto di ripartenza in ~/.claude/handoffs/, svuota la chat con /clear e riparte da quel file. Dove la banda non viene disegnata (ad esempio l'app su iPad) restano /cache, che mostra minuti rimasti, contesto, costo dell'ultimo turno e quota di cache letta, /push e /handoff (alias /nuova). I comandi sono anche file statici in barra-cache/commands/, perché l'elenco dello slash dell'app non mostra quelli registrati a runtime; l'hook risponde prima del modello, quindi non costano token.

📒 scritture-esterne (/scritture) — a fine turno elenca tutto ciò che il turno ha scritto fuori dalla repo: Notion, Postpickr, Spreaker, Gmail, Calendar, Drive, git push, gh, curl -X POST e gli script con --applica o --elimina. Ogni riga ha servizio, azione, bersaglio, un link apri se la risposta ne contiene uno, e una croce rossa se la chiamata è fallita o negata. Le letture non compaiono. Il registro sparisce all'inizio del turno successivo. Riconosce dal nome i connettori più comuni; per quelli con id opaco serve l'opzione servizi del plugin.

✅ conferma-proposte (/conferma) — quando l'ultima risposta di Claude chiude con una proposta o una domanda di conferma (una domanda, oppure formule come "procedo", "vuoi che", "confermi"), mostra sopra il prompt il passaggio che propone e due bottoni: Sì, procedi (tasto s) e No, fermati (tasto x). Il bottone invia la risposta come se l'avessi scritta tu. Non usa il modello, quindi non consuma token. Funziona anche nel cloud e da iPad.

🛠 correggi (/correggi) — durante la sessione annota gli inciampi: tool che falliscono, azioni negate dal sistema di permessi e le tue correzioni ("non vedo", "hai sbagliato", "riprova"). Con almeno due inciampi compare una riga con il conteggio, le skill usate e il bottone Proponi correzione (tasto l), che chiede a Claude di individuare la causa e proporre la modifica esatta alla skill o al file di istruzioni, senza applicarla. /correggi elenco mostra gli inciampi raccolti. Consuma token solo quando premi il bottone.

🔴 rec (/rec) — la modalità per registrare un video o lavorare in una sessione live con ospiti. Maschera a schermo chiavi e valori dei .env, email, nomi, telefoni, indirizzi, codice fiscale, partita IVA, IBAN, importi in euro e cifre vicino a parole come fatturato, margine, compenso, preventivo. I risultati di posta, chat, task, file, calendario, Notion e strumenti di pagamento si disegnano nascosti. Claude continua a lavorare sui dati reali: cambia solo ciò che si vede (se modifica un file da 29 a 39 euro, il file cambia davvero, lo schermo no). Tiene chiusi i file privati (.env, credenziali, fatture, contratti, preventivi, buste paga) e gli strumenti di pagamento, e ogni prompt porta una nota nascosta che chiede a Claude di usare segnaposto al posto di nomi e cifre. Un ● REC rosso sopra il prompt e nel piè di pagina ricorda che è acceso. /rec alterna, /rec rigoroso maschera anche ogni cifra grande, /rec off spegne, /rec config crea ~/.claude/mods-data/rec/config.json per il tuo nome (nomiVisibili), le persone da nascondere (nomiNascosti), le cartelle private (percorsiPrivati) e gli strumenti extra (strumentiAffari, strumentiChiusi). Limiti: cambia ciò che è disegnato, non ciò che è memorizzato; il riconoscimento per pattern non prende tutto quello che è scritto a parole, quindi riguarda il girato prima di pubblicarlo; i titoli delle chat nella barra laterale dell'app Desktop non si possono mascherare. Adattata da recording-mode di Nate Herk (MIT, vedi rec/NOTICE.md).

📬 posta (posta o /posta) — apre un pannello con la inbox Gmail: l'elenco delle ultime 12 mail per account (una finestra di 5 righe che scorre con j e k), i bottoni e, sotto, il testo intero della mail selezionata (tutti i messaggi del thread, dal più recente, con allegati), letto da Gmail senza modello. I bottoni sono Bozza (b), Label (l), Bozza+Label (m), Task (t), più Aggiorna (r) e Chiudi (x). Le quattro azioni girano dentro la mod, senza subagent: il modello riceve il thread senza alcun tool e sceglie solo i contenuti, mentre le scritture su Gmail e Notion le fa il codice dopo averle validate (la chat resta pulita: l'esito compare nel pannello e in un avviso). Bozza scrive con le regole di ~/.claude/mods-data/posta/sistematore.md e crea una bozza di risposta (la mail non viene mai inviata). Label fa scegliere al modello economico un'etichetta tra quelle esistenti e la applica solo se il nome coincide (non ne crea). Task fa proporre al modello economico titolo, scadenza, urgenza, importanza, impegno e contesto, li riporta ai valori ammessi (scadenza non valida o passata: domani alle 09:00) e crea la pagina nel database Tasks di Notion con il link alla mail. Solo Label e Bozza+Label tolgono la mail dall'inbox (finisce nella cartella dell'etichetta); Bozza e Task la lasciano in inbox. I lavori in corso mostrano uno spinner, quelli riusciti spariscono dopo 8 secondi e quelli falliti si rilanciano con Riprova (y). Nel dettaglio compare l'ultimo messaggio del thread; i precedenti sono righe ripiegate e si aprono con Mostra precedenti (p). Scrivendo posta senza barra il pannello si apre senza avviso dell'app e senza token; posta off lo chiude. Gli account sono in DEFAULT_ACCOUNTS e si sostituiscono con la chiave accounts dello store. In modalità Auto anche le chiamate della mod passano dal classificatore, che sulle scritture non dà verdetto: vanno autorizzate in permissions.allow le letture Gmail (search_threads, get_thread, list_labels) e le quattro scritture (create_draft, label_thread, unlabel_thread di Gmail, notion-create-pages di Notion), con il prefisso mcp__<id connettore>__. Un errore resta visibile solo sulla mail a cui appartiene e sparisce quando la stessa azione riesce.


Prerequisiti

  • Claude Code 2.1.287 o successivo: le mod sono attive di default da questa versione. Verifica con claude --version.
  • Terminale o tab Code dell'app Desktop: le sessioni WSL nell'app Desktop non eseguono le mod.

Una mod è codice eseguito con i tuoi permessi: può leggere e scrivere file, avviare processi e vedere prompt e tool call della sessione. Il sorgente di ogni mod è in hooks/: leggilo prima dell'installazione.


Installazione

  1. Aggiungi il marketplace a Claude Code:
   claude plugin marketplace add andreabrugnoli/mods
  1. Installa le mod che ti interessano, una per comando:
   claude plugin install barra-cache@andrea-mods
   claude plugin install scritture-esterne@andrea-mods
   claude plugin install conferma-proposte@andrea-mods
   claude plugin install correggi@andrea-mods
   claude plugin install rec@andrea-mods
   claude plugin install posta@andrea-mods
  1. Avvia una nuova sessione con claude.

Verifica: apri /plugin e controlla che le mod risultino attive. Per provare una mod in una sola sessione senza installarla, clona la repo e avvia claude --plugin-dir ./mods/barra-cache. Per disattivarla, usa il tab Installed di /plugin.


Uso in cloud e da iPad

Le sessioni cloud (claude.ai/code, app per iPad) non leggono le tue impostazioni locali: partono da un contenitore pulito e caricano i plugin dichiarati nella repo su cui lavori. Per attivare una mod in una repo, aggiungi a .claude/settings.json di quella repo:

{
  "extraKnownMarketplaces": {
    "andrea-mods": { "source": { "source": "github", "repo": "andreabrugnoli/mods" } }
  },
  "enabledPlugins": { "barra-cache@andrea-mods": true, "rec@andrea-mods": true }
}

Lo fa per te scripts/abilita-cloud.sh <percorso-repo> [mod ...], che unisce la voce alle impostazioni esistenti. Se il tuo gitignore (anche globale) esclude .claude/settings.json, aggiungilo con git add -f .claude/settings.json, poi commit e push. La sessione cloud legge il file dal branch.

Su iPad la banda sopra il prompt può non essere disegnata: restano /cache, /push e /nuova, e tutti i comandi on/off (/barra, /scritture, /conferma, /correggi, /rec) funzionano uguale. Lo stato ($.store) e i riassunti in ~/.claude/handoffs/ vivono nel contenitore della sessione e si perdono alla sua chiusura.


Comandi disponibili

Accendere e spegnere (senza argomento alternano; accettano anche on e off):

  • /barra: la barra sopra il prompt.
  • /scritture: il registro delle scritture esterne.
  • /conferma: i bottoni Sì e No sulle proposte.
  • /correggi: la raccolta degli inciampi (/correggi elenco li mostra).
  • /rec: la modalità registrazione (/rec rigoroso, /rec off, /rec config).
  • posta o /posta: la inbox con i bottoni Bozza, Label e Task (posta aggiorna, posta off).

Altri comandi di barra-cache:

  • /cache: minuti di cache rimasti, contesto occupato, costo dell'ultimo turno e quota di cache letta.
  • /push: pubblica i commit del branch corrente.
  • /nuova: riassume la sessione in un file e riparte da una chat pulita.

Personalizzazione

Durata della cache in barra-cache: parte da un'ora (TTL_MS) e si corregge da sola. Dopo una pausa di almeno 6 minuti guarda la quota di cache letta: se la cache è stata riscritta, passa a 5 minuti e lo ricorda tra le sessioni; se è stata letta, conferma l'ora.

Soglie dei colori in barra-cache: CONTEXT_WARN, CONTEXT_BAD, COST_WARN, CACHE_OK e CACHE_BAD, in cima allo stesso file.

Nomi dei connettori in scritture-esterne: opzione servizi del plugin.

Per modificare una mod, clona la repo e caricala con claude --plugin-dir: ogni salvataggio ricarica il modulo nella sessione aperta.


Struttura del progetto

mods/
├── .claude-plugin/marketplace.json   # elenco delle mod installabili
├── barra-cache/
├── scritture-esterne/
├── conferma-proposte/
├── correggi/
├── rec/
├── posta/
└── scripts/abilita-cloud.sh

Ogni cartella di mod è un plugin completo: .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.js e tests/ (eseguibili con claude plugin test ./<mod>). claude plugin validate ./<mod> elenca gli eventi intercettati e le chiamate al motore.


Licenza: MIT.

Source 1 files
hooks/register.js 191 lines
1// Quante righe mostrare al massimo nella banda
2const MAX_ROWS = 6
3
4// Nomi leggibili dei server MCP riconosciuti dal nome; i connettori con id opaco si nominano nelle opzioni
5const KNOWN = [
6  [/notion/i, 'Notion'],
7  [/spreaker/i, 'Spreaker'],
8  [/postpickr/i, 'Postpickr'],
9  [/gmail/i, 'Gmail'],
10  [/calendar/i, 'Calendar'],
11  [/drive/i, 'Drive'],
12  [/vercel/i, 'Vercel'],
13  [/stripe/i, 'Stripe'],
14  [/github/i, 'GitHub'],
15  [/slack/i, 'Slack'],
16  [/linear/i, 'Linear'],
17]
18const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-/i
19
20// Dall'opzione "servizi" ("id=Nome, id=Nome") alla mappa id -> nome
21export function parseServices(text) {
22  const map = {}
23  for (const part of String(text ?? '').split(',')) {
24    const [id, ...name] = part.split('=')
25    if (id && name.length) map[id.trim()] = name.join('=').trim()
26  }
27  return map
28}
29
30// Il nome da mostrare per un server: prima le opzioni, poi il nome noto, poi l'id accorciato
31export function serviceLabel(server, custom = {}) {
32  if (custom[server]) return custom[server]
33  const known = KNOWN.find(([re]) => re.test(server))
34  if (known) return known[1]
35  return UUID.test(server) ? server.slice(0, 8) : server
36}
37
38// Un tool MCP scrive se il nome contiene un verbo di scrittura e non uno di sola lettura
39const READ_VERB = /^(get|list|search|fetch|query|read|find|check|validate|resolve|download)/
40const WRITE_VERB = /(create|update|delete|trash|send|schedule|reschedule|publish|post|upload|move|duplicate|reply|forward|label|write|add|remove|set|save|stop|spawn)/
41
42// Le chiavi dell'input che meglio dicono "su cosa" è stata fatta la scrittura
43const TARGET_KEYS = ['title', 'name', 'subject', 'messaggio', 'page_id', 'id', 'project_id', 'url']
44
45// Le scritture del turno in corso, e quelle del turno concluso da mostrare
46let pending = []
47let shown = []
48
49// Accorcia un testo su una riga
50function clip(text, n) {
51  const one = String(text).replace(/\s+/g, ' ').trim()
52  return one.length > n ? one.slice(0, n - 1) + '…' : one
53}
54
55// Dice se una chiamata è una scrittura esterna e la descrive, altrimenti null
56export function describe(e, custom = {}) {
57  if (e.tool === 'Bash') {
58    const cmd = e.command ?? ''
59    if (/\bgit\s+push\b/.test(cmd)) return { service: 'git', action: 'push', target: clip(cmd, 60) }
60    if (/\bgh\s+(pr|issue|release|repo)\s+(create|merge|close|comment|edit|delete)/.test(cmd)) return { service: 'GitHub', action: 'modifica', target: clip(cmd, 60) }
61    if (/\bcurl\b.*-X\s*(POST|PUT|PATCH|DELETE)/.test(cmd)) return { service: 'HTTP', action: 'scrittura', target: clip(cmd, 60) }
62    if (/--(applica|elimina|apply|delete)\b/.test(cmd)) return { service: 'script', action: 'applica', target: clip(cmd, 60) }
63    return null
64  }
65  const m = /^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/.exec(e.tool)
66  if (!m) return null
67  const [, server, tool] = m
68  if (READ_VERB.test(tool) || !WRITE_VERB.test(tool)) return null
69  const key = TARGET_KEYS.find((k) => e[k] !== undefined && typeof e[k] !== 'object')
70  return {
71    service: serviceLabel(server, custom),
72    action: tool.replace(/^notion-/, '').replace(/_/g, ' '),
73    target: key ? clip(e[key], 50) : '',
74  }
75}
76
77// Il primo link https nel risultato del tool, se c'è
78export function findLink(text) {
79  const m = /https:\/\/[^\s"'<>)\]]+/.exec(text ?? '')
80  return m ? m[0].replace(/[.,;]+$/, '') : null
81}
82
83// Un esito è dubbio se il tool ha segnalato errore o il testo dice che è fallito
84export function isBad(result) {
85  if (result.isError === true) return true
86  return /"?(error|errore|failed|unauthorized|forbidden)"?\s*[:=]/i.test(result.text ?? '')
87}
88
89// Il nuovo stato dopo un comando: "on" accende, "off" spegne, senza argomento alterna
90export function parseToggle(args, current) {
91  const arg = String(args ?? '').trim().toLowerCase()
92  if (/^(on|acceso|attiva|si|sì)$/.test(arg)) return true
93  if (/^(off|spento|disattiva|no)$/.test(arg)) return false
94  return !current
95}
96
97// Acceso o spento: lo decide /scritture e resta nello store tra una sessione e l'altra
98let enabled = true
99
100async function runScritture($, e) {
101  enabled = parseToggle(e.args, enabled)
102  pending = []
103  shown = []
104  try {
105    await $.store.set('attiva', enabled)
106  } catch {}
107  $.ui.invalidate('ui.render')
108  return { text: 'scritture-esterne · ' + (enabled ? 'acceso' : 'spento') }
109}
110
111export function register(on, options) {
112  // I nomi dei connettori con id opaco, scritti dall'utente nelle opzioni
113  const custom = parseServices(options?.servizi)
114
115  on('session.start', async ($, e, next) => {
116    try {
117      if ((await $.store.get('attiva')) === false) enabled = false
118    } catch {}
119    await $.command.register({ name: 'scritture', description: 'Accende o spegne il registro delle scritture esterne', argumentHint: '[on|off]', immediate: true })
120    return next(e)
121  })
122
123  on('command.run', { command: 'scritture' }, runScritture)
124  // Il comando statico del plugin si chiama anche scritture-esterne:scritture
125  on('command.run', { command: 'scritture-esterne:scritture' }, runScritture)
126
127  // Ogni chiamata: la lasciamo passare, poi annotiamo che cosa è successo
128  on('tool.call', async ($, e, next) => {
129    if (!enabled) return next(e)
130    const what = describe(e, custom)
131    const ran = await next(e)
132    if (what && !e.agentId) {
133      const denied = ran.deny !== undefined
134      pending.push({
135        ...what,
136        state: denied ? 'negata' : isBad(ran) ? 'errore' : 'ok',
137        link: denied ? null : findLink(ran.text),
138      })
139    }
140    return ran
141  })
142
143  // Un nuovo turno cancella il registro del precedente
144  on('turn.start', ($, e, next) => {
145    if (!e.agentId) {
146      pending = []
147      if (shown.length) {
148        shown = []
149        $.ui.invalidate('ui.render')
150      }
151    }
152    return next(e)
153  })
154
155  // A fine turno il registro diventa visibile
156  on('turn.complete', ($, e, next) => {
157    if (enabled && !e.agentId) {
158      shown = pending
159      pending = []
160      $.ui.invalidate('ui.render')
161    }
162    return next(e)
163  })
164
165  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
166    const theirs = await next(e)
167    if (!enabled || !shown.length || e.props.isWorking) return theirs
168    const { Box, Text, Link } = $.ui.resolve(e)
169    const bad = shown.filter((r) => r.state !== 'ok').length
170    const head = Text({
171      color: bad ? 'red' : undefined,
172      dimColor: !bad,
173      children: ['scritto fuori dalla repo · ' + shown.length + (shown.length === 1 ? ' azione' : ' azioni') + (bad ? ' · ' + bad + ' da controllare' : '')],
174    })
175    const rows = shown.slice(0, MAX_ROWS).map((r) =>
176      Box({
177        flexDirection: 'row',
178        children: [
179          Text({ color: r.state === 'ok' ? 'green' : 'red', children: [r.state === 'ok' ? '✓ ' : '✗ '] }),
180          Text({ children: [r.service + ' · ' + r.action + (r.target ? ' · ' + r.target : '')] }),
181          r.state !== 'ok' && Text({ color: 'red', children: [' (' + r.state + ')'] }),
182          r.link && Text({ children: ['  '] }),
183          r.link && Link({ href: r.link, label: 'apri' }),
184        ].filter(Boolean),
185      }),
186    )
187    const more = shown.length > MAX_ROWS ? [Text({ dimColor: true, children: ['… e altre ' + (shown.length - MAX_ROWS)] })] : []
188    return Box({ flexDirection: 'column', children: [head, ...rows, ...more, theirs].filter(Boolean) })
189  })
190}
191