SLOPSHOPPER

frugal

Cost-free mode for Claude Code. Helper subagents (Explore and friends) run on Haiku, and a summarize_free tool lets Claude hand big files and logs to a free…

newbandguardcommandtoolnetwork
v0.1.0MITupdated 2026-10-04saksham10arora-dotcom/claude-frugal
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · frugal
› 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 › /frugal ⎿ frugal: frugal is on: helper agents run on haiku. ⎿ frugal: This session: 0 helper agents on haiku, 0 big reads on free Gemini (0 characters summarised for free). ⎿ frugal: All time: 0 helper agents, 0 big reads, 0 characters. ⎿ frugal: No Gemini key yet: set GEMINI_API_KEY, or `claude plugin configure frugal`. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

frugal

Spend Claude on thinking, not chores. A Claude Code mod.

frugal summarising a 6,000 line log with free Gemini

A real, unedited session: Claude hands a 6,000 line log to free Gemini and gets back a timeline.

Two ways to spend Claude on thinking instead of chores:

  1. Helper subagents (Explore and friends) that don't ask for a model run on Haiku.
  2. summarize_free, a tool Claude can call to hand a big file or log to a free Gemini model and get back only what it needs. Gemini models are tried in order (Lite first), so one being busy doesn't stop it.

A line above the prompt counts what went where; /frugal status adds all-time totals.

An honest note from making the demo: for "find the error" questions Claude reaches for Grep, which is cheaper still. summarize_free earns its place on summaries, timelines and questions grep can't answer, or whenever you ask for it.

Setup

Get a free key at aistudio.google.com, then either set GEMINI_API_KEY or run /plugin configure frugal@nerfsaksham-frugal (the key goes to secure storage).

Commands and settings

  • /frugal on, /frugal off, /frugal status
  • Settings: Gemini models in order, helper model (haiku or sonnet), which subagent types count as helpers

What it touches

The file you ask it to summarise, one call to Gemini per summary, and GEMINI_API_KEY.

Install

Claude Code 2.1.287 or newer runs mods out of the box. On 2.1.273 to 2.1.286, start it with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 first.

claude plugin marketplace add saksham10arora-dotcom/claude-frugal
claude plugin install frugal@nerfsaksham-frugal

Every option has a default, so the installer's "userConfig options not yet set" note is safe to ignore.

Or try it without installing:

git clone https://github.com/saksham10arora-dotcom/claude-frugal && claude --plugin-dir claude-frugal

Develop

claude plugin validate .
claude plugin test .

More mods

Part of a set: teach-me · vhs · frugal · lofi. More community mods: awesome-claude-mods.

MIT. Made by Saksham Arora (@nerfsaksham).

Source 1 files
hooks/register.ts 178 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// frugal: spend Claude on thinking, not on chores.
4//  1. Helper subagents (Explore and the like) that don't ask for a model run on
5//     the helper model, Haiku by default.
6//  2. A summarize_free tool: Claude hands a big file or log to a free Gemini
7//     model and gets back only what it needs, instead of reading it whole.
8// A one-line band above the prompt counts what was offloaded this session.
9
10const TOOL = 'summarize_free'
11const MAX_CHARS = 600_000 // what one Gemini call is sent at most
12const MUTED = '#8a8f98'
13const GREEN = '#3ecf8e'
14
15type Counts = { helpers: number; summaries: number; chars: number }
16const ZERO: Counts = { helpers: 0, summaries: 0, chars: 0 }
17
18const list = (v: unknown, fallback: string) =>
19  String(typeof v === 'string' && v.trim() ? v : fallback)
20    .split(',')
21    .map(s => s.trim())
22    .filter(Boolean)
23
24const asCounts = (v: unknown): Counts => {
25  const o = (v && typeof v === 'object' ? v : {}) as Partial<Counts>
26  return { helpers: Number(o.helpers) || 0, summaries: Number(o.summaries) || 0, chars: Number(o.chars) || 0 }
27}
28
29// This session's counts. Module state, like the pet's animation loop: a hot
30// reload starts it over.
31let session: Counts = { ...ZERO }
32
33async function bump($: EngineInterface, add: Partial<Counts>) {
34  session = {
35    helpers: session.helpers + (add.helpers ?? 0),
36    summaries: session.summaries + (add.summaries ?? 0),
37    chars: session.chars + (add.chars ?? 0),
38  }
39  const life = asCounts(await $.store.get('lifetime'))
40  await $.store.set('lifetime', {
41    helpers: life.helpers + (add.helpers ?? 0),
42    summaries: life.summaries + (add.summaries ?? 0),
43    chars: life.chars + (add.chars ?? 0),
44  })
45  $.ui.invalidate('ui.render')
46}
47
48const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
49const kchars = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
50
51export const register: Register = (on, options) => {
52  const helperModel = String(options.helperModel || 'haiku')
53  const helperTypes = new Set(list(options.helperTypes, 'Explore,claude-code-guide,statusline-setup'))
54  const geminiModels = list(options.geminiModels, 'gemini-flash-lite-latest,gemini-3.5-flash-lite,gemini-flash-latest')
55  const configuredKey = typeof options.geminiKey === 'string' ? options.geminiKey.trim() : ''
56
57  let enabled = true
58
59  on('session.start', async ($, e, next) => {
60    const r = await next(e)
61    enabled = (await $.store.get('enabled')) !== false
62    await $.command.register({
63      name: 'frugal',
64      description: 'Cost-free mode: helper agents on Haiku, big reads on free Gemini (on, off, status)',
65      argumentHint: '[on | off | status]',
66      immediate: true,
67    })
68    await $.tool.register({
69      name: TOOL,
70      description:
71        'Summarise, or answer a question about, a LARGE file or text (logs, build or test output, long docs, ' +
72        'generated files, anything over about 500 lines) using a free model, so the main context and the ' +
73        "user's plan limits are spared. Pass `path` (a file) or `text`, and `question`: what you need from it. " +
74        'Returns only that. Prefer it to reading a huge file whole when you need the gist or one fact; ' +
75        'read the exact lines yourself when you need precise code to edit.',
76      inputSchema: {
77        type: 'object',
78        properties: {
79          path: { type: 'string', description: 'A file to read, relative to the working directory or absolute' },
80          text: { type: 'string', description: 'The text itself, when there is no file' },
81          question: { type: 'string', description: 'What you need from it; empty means a short summary' },
82        },
83      },
84    })
85    return r
86  })
87
88  on('command.run', { command: 'frugal' }, async ($, e) => {
89    const arg = String(e.args ?? '').trim().toLowerCase()
90    if (arg === 'on' || arg === 'off') {
91      enabled = arg === 'on'
92      await $.store.set('enabled', enabled)
93      $.ui.invalidate('ui.render')
94    } else if (arg && arg !== 'status') {
95      return { text: 'Try /frugal on, /frugal off or /frugal status.' }
96    }
97    const life = asCounts(await $.store.get('lifetime'))
98    const key = configuredKey || (await $.env.get('GEMINI_API_KEY'))
99    const state = enabled ? `on: helper agents run on ${helperModel}` : 'off: every agent keeps the model it asks for'
100    return {
101      text:
102        `frugal is ${state}.\n` +
103        `This session: ${plural(session.helpers, 'helper agent')} on ${helperModel}, ` +
104        `${plural(session.summaries, 'big read')} on free Gemini (${kchars(session.chars)} characters summarised for free).\n` +
105        `All time: ${plural(life.helpers, 'helper agent')}, ${plural(life.summaries, 'big read')}, ${kchars(life.chars)} characters.\n` +
106        (key ? '' : 'No Gemini key yet: set GEMINI_API_KEY, or `claude plugin configure frugal`.'),
107    }
108  })
109
110
111  // A helper that names no model gets the cheap one. One that asks for a model
112  // keeps it, and forks always inherit their parent's.
113  on('agent.spawn', async ($, e, next) => {
114    if (!enabled || e.fork || e.model || !helperTypes.has(e.subagentType)) return next(e)
115    const r = await next({ ...e, model: helperModel })
116    await bump($, { helpers: 1 })
117    return r
118  })
119
120  on('tool.call', { tool: 'mcp__frugal__summarize_free' }, async ($, e) => {
121    const input = e as unknown as { path?: unknown; text?: unknown; question?: unknown }
122    const key = configuredKey || (await $.env.get('GEMINI_API_KEY'))
123    if (!key) return { result: 'summarize_free has no Gemini key. Read the file directly instead.', isError: true }
124
125    let body = typeof input.text === 'string' ? input.text : ''
126    const path = typeof input.path === 'string' ? input.path : ''
127    if (!body && path) {
128      try {
129        body = String(await $.fs.read(path))
130      } catch (err) {
131        return { result: `Could not read ${path}: ${String(err)}`, isError: true }
132      }
133    }
134    if (!body) return { result: 'Pass `path` or `text`.', isError: true }
135
136    const question = typeof input.question === 'string' && input.question.trim() ? input.question.trim() : 'Summarise it in under 200 words: what it is, and anything that looks wrong.'
137    const clipped = body.length > MAX_CHARS
138    const prompt =
139      'You are helping a coding agent that cannot afford to read this whole thing. ' +
140      'Answer only from the content below. Be concise and concrete: quote exact names, ' +
141      'numbers, error messages and line fragments when they matter. If the answer is not in it, say so.\n\n' +
142      `Question: ${question}\n\n----- ${path || 'text'}${clipped ? ` (first ${MAX_CHARS} characters of ${body.length})` : ''} -----\n` +
143      body.slice(0, MAX_CHARS)
144
145    for (const model of geminiModels) {
146      const res = await $.http.fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
147        method: 'POST',
148        headers: { 'content-type': 'application/json', 'x-goog-api-key': key },
149        body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }),
150      })
151      if (!res.ok) continue // busy or out of quota: try the next model
152      let answer = ''
153      try {
154        const j = JSON.parse(res.text) as { candidates?: { content?: { parts?: { text?: string }[] } }[] }
155        answer = (j.candidates?.[0]?.content?.parts ?? []).map(p => p.text ?? '').join('').trim()
156      } catch {
157        continue
158      }
159      if (!answer) continue
160      await bump($, { summaries: 1, chars: Math.min(body.length, MAX_CHARS) })
161      return { result: `${answer}\n\n(summarised by ${model} from ${body.length} characters${clipped ? ', clipped' : ''})` }
162    }
163    return { result: 'Every Gemini model was busy or out of quota. Read the file directly instead.', isError: true }
164  })
165
166  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
167    const original = await next(e)
168    if (!enabled || session.helpers + session.summaries === 0) return original
169    const { Box, Text } = $.ui.resolve(e)
170    const parts = [
171      session.helpers ? `${plural(session.helpers, 'helper')} on ${helperModel}` : '',
172      session.summaries ? `${plural(session.summaries, 'big read')} on free Gemini, ${kchars(session.chars)} chars summarised free` : '',
173    ].filter(Boolean)
174    const line = Text({ wrap: 'truncate', children: [Text({ color: GREEN, children: '● frugal ' }), Text({ color: MUTED, children: parts.join(' · ') })] })
175    return original ? Box({ flexDirection: 'column', children: [original, line] }) : line
176  })
177}
178