
Brewtools:Think Short
- 7 installs
- 29 repo stars
- Updated August 2, 2026
- kochetkov-ma/claude-brewcode
Helps with ai & agent building tasks.
About
brewtools:think-short is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- brewtools:think-short
- AI & Agent Building
- AI-coding skill
Brewtools:Think Short by the numbers
- 7 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #12,545 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/kochetkov-ma/claude-brewcode --skill brewtoolsthink-shortAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 7 |
|---|---|
| repo stars | ★ 29 |
| Last updated | August 2, 2026 |
| Repository | kochetkov-ma/claude-brewcode ↗ |
What it does
Helps with ai & agent building tasks.
Files
Think-Short
Toggle terse-output mode. Writes state to$CLAUDE_PLUGIN_DATA/think-short.json(global) or.claude/brewtools/think-short.json(project). Hooks read state and inject profile-specific directives into SessionStart + PreToolUse:Task. This skill ONLY parses intent and mutates state.
<instructions>
Robustness Rules
| Rule | Applies |
|---|---|
| Every Bash call ends with `&& echo "OK ..." \ | \ |
Never use Write/Edit on ~/.claude/* or $CLAUDE_PLUGIN_DATA — use Bash + Node fs via helpers | ALL |
State writes go through writeState() in helpers/state.mjs (atomic, O_NOFOLLOW, 0600, merges defaults + timestamps) | P2 |
State reads go through resolveEffectiveState() in helpers/state.mjs (merges hardcoded → global → project → env) | P0, status |
NL-prompt resolution ALWAYS logged via log() from helpers/state.mjs at INFO level (auto-prefixed think-short), to .claude/logs/brewtools.log | P0 |
BT_ROOT Resolver
$CLAUDE_PLUGIN_ROOT is NOT inherited by the Bash tool in main-conversation slash invocations. Every Bash block MUST resolve BT_ROOT dynamically (no hardcoded version):
BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}"
test -d "$BT_ROOT/skills/think-short/helpers" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; }Paths (use $BT_ROOT literally in Bash):
- Global state:
$CLAUDE_PLUGIN_DATA/think-short.json(fallback:~/.claude/plugins/data/brewtools-claude-brewcode/think-short.json) — computed bygetPaths(cwd) - Project state:
$PWD/.claude/brewtools/think-short.json— computed bygetPaths(cwd) - State helper:
$BT_ROOT/skills/think-short/helpers/state.mjs— exportsgetPaths,readPluginDefaults,resolveEffectiveState,writeState,log - Safe-write helper:
$BT_ROOT/skills/think-short/helpers/safe-write.mjs— exportssafeReadJson,safeWriteJson - Log file:
$PWD/.claude/logs/brewtools.log(auto-created bylog())
State schema:
{"version":1, "enabled":false, "profile":"medium", "blacklist":["debate","docs-writer","architect"], "updated_at":"ISO"}---
P0: Parse Intent
Parse $ARGUMENTS into structured form:
{ op: on|off|profile|status|blacklist, profile?: light|medium|aggressive, blacklistOp?: add|remove, agent?: string, scope?: global|project }Structural match (exact)
| Input | Resolves to |
|---|---|
| `on [--scope global\ | project]` |
off | {op:off} |
| `profile <light\ | medium\ |
status | {op:status} |
blacklist add <agent> | {op:blacklist, blacklistOp:add, agent} |
blacklist remove <agent> | {op:blacklist, blacklistOp:remove, agent} |
NL-prompt fallback (MANDATORY)
If no structural match, treat argument as NL prompt:
1. Trim + lowercase. 2. Tokenize + apply synonym table:
| Regex / keyword | Resolves to |
|---|---|
| `включи\ | включись\ |
| `выключи\ | выключись\ |
| `light\ | лайт\ |
| `medium\ | мид\ |
| `aggressive\ | агрессив\ |
| `status\ | статус\ |
3. Combos allowed — e.g. включись максимально → on + profile aggressive. Execute BOTH ops in sequence. 4. Ambiguous (0 matches OR >1 mutually-exclusive match that is not a combo) → AskUserQuestion with candidate operations as options. 5. After resolution, INFO log:
think-short: NL-prompt "<input>" → resolved as <command>EXECUTE using Bash tool (resolve + log):
BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}"
test -d "$BT_ROOT/skills/think-short/helpers" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; }
node --input-type=module -e "
import {log} from '${BT_ROOT}/skills/think-short/helpers/state.mjs';
log('info', 'NL-prompt \"INPUT\" → resolved as RESOLVED', process.cwd(), process.env.CLAUDE_CODE_SESSION_ID || null);
" && echo "OK log" || echo "FAILED log"Replace INPUT and RESOLVED literally. The log() from state.mjs auto-prefixes with think-short — do NOT add it again.
---
P1: Scope Selection
Default = `project` scope. Silent — no AskUserQuestion unless the user explicitly asks for disambiguation.
| Signal | Scope |
|---|---|
--scope global or --scope=global present | global |
--scope project or --scope=project present | project |
User prompt contains explicit ambiguity ("для всех проектов или только здесь", "global or project?", --ask-scope) | Use AskUserQuestion — options: Project (default) / Global |
Otherwise (including --print / headless / no tty) | project (silent default) |
Always log chosen scope at INFO:
think-short: scope=<project|global> (<default|--scope|user-choice>, --scope <not specified|explicit>)For status — no scope question (reads merged state). For blacklist — defaults to project scope silently.
For combo ops — determine scope ONCE via rules above, apply to all ops.
---
P2: Mutate State
EXECUTE using Bash tool (substitute SCOPE, PATCH_JSON, OP):
BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}"
test -d "$BT_ROOT/skills/think-short/helpers" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; }
node --input-type=module -e "
import {writeState, log} from '${BT_ROOT}/skills/think-short/helpers/state.mjs';
const patch = PATCH_JSON;
const r = await writeState('SCOPE', patch, process.cwd());
log('info', 'toggle OP applied (scope=SCOPE) → ' + JSON.stringify(patch), process.cwd(), process.env.CLAUDE_CODE_SESSION_ID || null);
console.log(JSON.stringify({scope:'SCOPE', file:r.path, state:r.after}));
" && echo "OK mutate" || echo "FAILED mutate"| Op | PATCH_JSON | OP |
|---|---|---|
on | {enabled:true} | on |
off | {enabled:false} | off |
profile light | {profile:'light'} | profile-light |
profile medium | {profile:'medium'} | profile-medium |
profile aggressive | {profile:'aggressive'} | profile-aggressive |
blacklist add X | {blacklist:[...current,'X']} (read via resolveEffectiveState first, dedupe) | blacklist-add-X |
blacklist remove X | {blacklist:current.filter(a=>a!=='X')} | blacklist-remove-X |
writeState handles: reading existing scope file, merging defaults, atomic write via safeWriteJson, stamping updated_at, enforcing version:1. No manual fs.existsSync / safeWrite calls needed.
Combo ops (e.g. on + profile aggressive): pass a single merged patch {enabled:true, profile:'aggressive'} — one writeState call, atomic.
Blacklist mutation example (inline — single node invocation):
BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}"
node --input-type=module -e "
import {resolveEffectiveState, writeState, log} from '${BT_ROOT}/skills/think-short/helpers/state.mjs';
const s = await resolveEffectiveState(process.cwd());
const cur = Array.isArray(s.blacklist) ? s.blacklist : [];
const next = Array.from(new Set([...cur, 'AGENT'])); // or: cur.filter(a => a !== 'AGENT')
const r = await writeState('SCOPE', {blacklist: next}, process.cwd());
log('info', 'blacklist OP AGENT (scope=SCOPE)', process.cwd(), process.env.CLAUDE_CODE_SESSION_ID || null);
console.log(JSON.stringify({scope:'SCOPE', file:r.path, state:r.after}));
" && echo "OK mutate" || echo "FAILED mutate"---
P3: Status Output
For op=status — read merged state + metadata and print:
think-short: ENABLED (source: project-state)
profile: medium (source: project-state)
blacklist: [debate, docs-writer, architect]
state files:
project: .claude/brewtools/think-short.json (exists, updated 2026-04-20T12:34:56Z)
global: ~/.claude/plugins/data/brewtools-claude-brewcode/think-short.json (missing)
DEFAULT_THINK_SHORT: enabled=false, profile=medium
env override: THINK_SHORT_DEFAULT=(unset)
recent log:
<last 10 lines from .claude/logs/brewtools.log matching `think-short`>EXECUTE using Bash tool:
BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}"
test -d "$BT_ROOT/skills/think-short/helpers" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; }
node --input-type=module -e "
import {resolveEffectiveState, getPaths} from '${BT_ROOT}/skills/think-short/helpers/state.mjs';
import fs from 'node:fs';
const cwd = process.cwd();
const state = await resolveEffectiveState(cwd);
const {globalPath, projectPath, pluginJsonPath} = getPaths(cwd);
const gExists = fs.existsSync(globalPath), pExists = fs.existsSync(projectPath);
console.log(JSON.stringify({
enabled: state.enabled, profile: state.profile, blacklist: state.blacklist,
sources: state.sources,
files: {
global: {path: globalPath, exists: gExists, mtime: gExists ? fs.statSync(globalPath).mtime.toISOString() : null},
project: {path: projectPath, exists: pExists, mtime: pExists ? fs.statSync(projectPath).mtime.toISOString() : null}
},
pluginDefaults: state.raw.pluginDefaults,
envOverride: state.raw.env
}, null, 2));
" && echo "OK status" || echo "FAILED status"
# Append last 10 log lines matching think-short
grep 'think-short' .claude/logs/brewtools.log 2>/dev/null | tail -10 || echo "(no log entries)"Render the final output in the shape shown above. Omit sections that are N/A.
---
P4: Notify + Reload Reminder
After mutation (non-status ops), render:
# Think-Short — <op>
Scope: <project|global>
File: <absolute path>
State: enabled=<bool>, profile=<light|medium|aggressive>, blacklist=[...]
> Hooks pick up new state on next SessionStart / PreToolUse:Task — no reload needed.For combo ops, show the final merged state after all mutations.
---
Sub-operation: blacklist
blacklist add <agent>— append to state.blacklist if absentblacklist remove <agent>— remove from state.blacklist if present- Scope defaults to project (no AskUserQuestion). Override via
--scope=global. - Log every mutation at INFO level with prefix
think-short.
---
Guards
| Condition | Response |
|---|---|
BT_ROOT resolves but $BT_ROOT/skills/think-short/helpers missing | ERROR: think-short: helpers not found under $BT_ROOT — plugin cache incomplete. STOP. |
Neither $CLAUDE_PLUGIN_ROOT set nor any cached plugin dir found | ERROR: think-short: cannot locate plugin root — install/update brewtools first. STOP. |
| NL prompt matches nothing | AskUserQuestion: "Which action? [on / off / profile light / profile medium / profile aggressive / status / cancel]" |
| NL prompt matches >1 mutually-exclusive op (not a combo) | AskUserQuestion with matched candidates as options. |
User picks cancel in any AskUserQuestion | Abort. No state mutation. Log at INFO: think-short: user cancelled. |
---
Smoke Test
Verify wiring after install/update or when debugging:
BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}"
test -d "$BT_ROOT/skills/think-short/helpers" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; }
node --input-type=module -e "
import {resolveEffectiveState} from '${BT_ROOT}/skills/think-short/helpers/state.mjs';
const s = await resolveEffectiveState(process.cwd());
console.log('smoke OK:', JSON.stringify(s));
" && echo '✅ smoke' || echo '❌ smoke FAILED'Expected: one smoke OK: {...} line with enabled, profile, blacklist, sources, raw, then ✅ smoke.
</instructions>
import fs from 'node:fs/promises';
import fsConstants from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
/**
* Atomically write JSON to filePath with restrictive perms, symlink-safe.
* @returns {Promise<void>}
*/
export async function safeWriteJson(filePath, obj) {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
const tempPath = `${filePath}.tmp.${process.pid}.${crypto.randomBytes(6).toString('hex')}`;
// O_NOFOLLOW prevents symlink attacks on the temp path
const flags = fsConstants.constants.O_WRONLY | fsConstants.constants.O_CREAT | fsConstants.constants.O_TRUNC | fsConstants.constants.O_NOFOLLOW;
let handle;
try {
handle = await fs.open(tempPath, flags, 0o600);
await handle.writeFile(JSON.stringify(obj, null, 2) + '\n', 'utf8');
await handle.sync();
await handle.close();
handle = null;
await fs.rename(tempPath, filePath);
} catch (err) {
if (handle) {
try { await handle.close(); } catch {}
}
try { await fs.unlink(tempPath); } catch {}
throw err;
}
}
/**
* Read JSON from filePath. Returns null on ENOENT, throws otherwise.
* @returns {Promise<object|null>}
*/
export async function safeReadJson(filePath) {
const flags = fsConstants.constants.O_RDONLY | fsConstants.constants.O_NOFOLLOW;
let handle;
try {
handle = await fs.open(filePath, flags);
} catch (err) {
if (err.code === 'ENOENT') return null;
throw err;
}
try {
const content = await handle.readFile('utf8');
try {
return JSON.parse(content);
} catch (parseErr) {
throw new Error(`Failed to parse JSON at ${filePath}: ${parseErr.message}`);
}
} finally {
await handle.close();
}
}
import path from 'node:path';
import os from 'node:os';
import { safeReadJson, safeWriteJson } from './safe-write.mjs';
import { log as utilsLog } from '../../../hooks/lib/utils.mjs';
// Inline defaults (relocated from brewtools plugin.json `config` block,
// removed in CC 2.1.139 schema). Keep values in sync with prior plugin.json.
const DEFAULT_THINK_SHORT = {
default_enabled: false,
default_profile: 'medium',
};
const HARDCODED = {
enabled: DEFAULT_THINK_SHORT.default_enabled,
profile: DEFAULT_THINK_SHORT.default_profile,
blacklist: ['debate', 'docs-writer', 'architect'],
};
const VALID_PROFILES = ['light', 'medium', 'aggressive'];
const ENV_ON = ['on', 'enable'];
const ENV_OFF = ['off', 'disable'];
export function log(level, message, cwd, sessionId) {
utilsLog(level, 'think-short', message, cwd, sessionId);
}
export function getPaths(cwd) {
const dataRoot = process.env.CLAUDE_PLUGIN_DATA
|| path.join(os.homedir(), '.claude/plugins/data/brewtools-claude-brewcode');
const globalPath = path.join(dataRoot, 'think-short.json');
const projectPath = path.join(cwd, '.claude/brewtools/think-short.json');
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
const pluginJsonPath = pluginRoot ? path.join(pluginRoot, '.claude-plugin/plugin.json') : null;
return { globalPath, projectPath, pluginJsonPath };
}
/**
* Plugin defaults. Previously read from plugin.json `config.think_short`,
* which was removed in CC 2.1.139. Now sourced from inline DEFAULT_THINK_SHORT.
* Signature preserved for API compatibility; pluginJsonPath is unused.
* @returns {Promise<{enabled:boolean, profile:string}>}
*/
export async function readPluginDefaults(_pluginJsonPath) {
const ts = DEFAULT_THINK_SHORT;
return {
enabled: typeof ts.default_enabled === 'boolean' ? ts.default_enabled : false,
profile: VALID_PROFILES.includes(ts.default_profile) ? ts.default_profile : 'medium',
};
}
function applyEnvOverride(state, cwd) {
const raw = process.env.THINK_SHORT_DEFAULT;
if (!raw) return { applied: false, field: null };
const val = raw.trim().toLowerCase();
if (ENV_ON.includes(val)) {
state.enabled = true;
return { applied: true, field: 'enabled' };
}
if (ENV_OFF.includes(val)) {
state.enabled = false;
return { applied: true, field: 'enabled' };
}
if (VALID_PROFILES.includes(val)) {
state.profile = val;
return { applied: true, field: 'profile' };
}
log('warn', `Ignoring unknown THINK_SHORT_DEFAULT value: ${raw}`, cwd, null);
return { applied: false, field: null };
}
/**
* @returns {Promise<{enabled:boolean, profile:string, blacklist:string[], sources:object, raw:object}>}
*/
export async function resolveEffectiveState(cwd) {
const { globalPath, projectPath, pluginJsonPath } = getPaths(cwd);
const pluginDefaults = await readPluginDefaults(pluginJsonPath);
const global = await safeReadJson(globalPath);
const project = await safeReadJson(projectPath);
const sources = {
enabled: 'hardcoded',
profile: 'hardcoded',
blacklist: 'hardcoded',
};
const merged = {
enabled: HARDCODED.enabled,
profile: HARDCODED.profile,
blacklist: [...HARDCODED.blacklist],
};
if (pluginDefaults) {
merged.enabled = pluginDefaults.enabled;
merged.profile = pluginDefaults.profile;
sources.enabled = 'plugin.json';
sources.profile = 'plugin.json';
}
if (global && typeof global === 'object') {
if (typeof global.enabled === 'boolean') { merged.enabled = global.enabled; sources.enabled = 'global-state'; }
if (VALID_PROFILES.includes(global.profile)) { merged.profile = global.profile; sources.profile = 'global-state'; }
if (Array.isArray(global.blacklist)) { merged.blacklist = global.blacklist; sources.blacklist = 'global-state'; }
}
if (project && typeof project === 'object') {
if (typeof project.enabled === 'boolean') { merged.enabled = project.enabled; sources.enabled = 'project-state'; }
if (VALID_PROFILES.includes(project.profile)) { merged.profile = project.profile; sources.profile = 'project-state'; }
if (Array.isArray(project.blacklist)) { merged.blacklist = project.blacklist; sources.blacklist = 'project-state'; }
}
const envResult = applyEnvOverride(merged, cwd);
if (envResult.applied) {
sources[envResult.field] = 'env';
}
return {
enabled: merged.enabled,
profile: merged.profile,
blacklist: merged.blacklist,
sources,
raw: {
global,
project,
pluginDefaults,
env: process.env.THINK_SHORT_DEFAULT || null,
},
};
}
async function resolveNonEnvDefaults(cwd) {
const prevEnv = process.env.THINK_SHORT_DEFAULT;
delete process.env.THINK_SHORT_DEFAULT;
try {
return await resolveEffectiveState(cwd);
} finally {
if (prevEnv !== undefined) process.env.THINK_SHORT_DEFAULT = prevEnv;
}
}
/**
* @returns {Promise<{path:string, before:object|null, after:object}>}
*/
export async function writeState(scope, patch, cwd) {
if (scope !== 'global' && scope !== 'project') {
throw new Error(`Invalid scope: ${scope}`);
}
const { globalPath, projectPath } = getPaths(cwd);
const targetPath = scope === 'global' ? globalPath : projectPath;
const before = await safeReadJson(targetPath);
const defaults = await resolveNonEnvDefaults(cwd);
const base = {
version: 1,
enabled: defaults.enabled,
profile: defaults.profile,
blacklist: defaults.blacklist,
};
const after = {
...base,
...(before || {}),
...patch,
version: 1,
updated_at: new Date().toISOString(),
};
await safeWriteJson(targetPath, after);
return { path: targetPath, before, after };
}
Be terse. ASCII only - no em-dash, no smart quotes. No preamble ("Let me...", "Sure!"). No closing fluff, sycophancy, disclaimers, or "as an AI". No restatement of question. No unsolicited alternatives. Results first. Reasoning only if explicitly asked. Prefer Edit over Write. Diff over full file. Test before declaring done. User instructions always override these rules.
Grep/Glob before Read. Bundle edits per file. replace_all=true beats repeated Edits. Parallel calls (reads, greps, different files) in one message. Don't re-Read a file you just edited.
Plan the full edit set, then execute. If a task touches N call-sites, gather all N via Grep first, then issue parallel Edits.
Be terse. No preamble, no filler, no AI phrasings. Results first, reasoning only if asked. Plan the full edit set, then execute.
Be terse. No preamble, filler, sycophancy, or unsolicited alternatives. Results first, reasoning only if asked.
Grep/Glob before Read. Edit over Write. Parallel calls in one message. Don't re-Read a file you just edited. Plan the full edit set, then execute.
Think-Short
Toggle terse-output mode — cuts preamble and filler via SessionStart + PreToolUse:Task injection.
Commands
| Command | What it does |
|---|---|
| `/brewtools:think-short on [--scope global\ | project]` |
/brewtools:think-short off | Disable terse mode |
| `/brewtools:think-short profile <light\ | medium\ |
/brewtools:think-short status | Print effective state, source, state files, last 10 log lines |
| `/brewtools:think-short blacklist add\ | remove <agent>` |
NL prompts (RU+EN)
| Phrase | Resolves to |
|---|---|
включи терсный, be terse, think-short on | on |
выключи терсный, turn off, think-short off | off |
лёгкий режим, light, уровень 1, level 1 | profile light |
средний режим, medium, уровень 2, level 2 | profile medium |
агрессивный, макс, aggressive, уровень 3, level 3 | profile aggressive |
включись максимально, be terse max | on + profile aggressive (combo) |
что сейчас, think-short status | status |
Ambiguous input triggers AskUserQuestion with candidate operations.
State files
| Scope | Path |
|---|---|
| Global | ~/.claude/plugins/data/brewtools-claude-brewcode/think-short.json |
| Project | .claude/brewtools/think-short.json |
Project state wins over global (merge precedence). Default scope for writes is project (silent).
State schema: {"version":1, "enabled":false, "profile":"medium", "blacklist":["debate","docs-writer","architect"], "updated_at":"ISO"}
Profiles
| Profile | Directives | Approx tokens | Typical use |
|---|---|---|---|
light | Be terse. Results first. Think through edits before executing. | ~20 tokens | Light reduction, keeps reasoning visible |
medium | Light + no AI phrasings, no sycophancy. Tool discipline: Grep before Read, Edit over Write, parallel independent calls, no re-Read of just-edited files. | ~60 tokens | Balanced — recommended default |
aggressive | Medium + ASCII-only, no closing fluff, no disclaimers. Full tool discipline: bundle edits, replace_all for N-identical, gather call-sites via Grep before parallel Edits. | ~120 tokens | Maximum suppression — long automated runs |
Logs
File: .claude/logs/brewtools.log Prefix: think-short:
Every NL resolution and scope selection is logged at INFO level.
Docs
Full docs: https://doc-claude.brewcode.app/brewtools/skills/think-short/
01a — Toggle on (fresh state)
Given
- Clean test-project fixture (no pre-existing state files)
CLAUDE_PLUGIN_DATAset to a temp dir (no global state)- No
THINK_SHORT_DEFAULTenv var
When
user prompt: /brewtools:think-short on
(The skill defaults to project scope silently under --print — no AskUserQuestion. The runner pre-seeds no state files, so the skill writes a fresh project state.)
Then
- claude exits 0
- file
.claude/brewtools/think-short.jsonexists in the working dir - that file contains
"enabled": true - log
.claude/logs/brewtools.logcontains a line matchingthink-short - stdout JSON does not indicate an error
Assert
ASSERT_EXIT_CODE: 0 ASSERT_STATE_PROJECT_JSON_CONTAINS: "enabled": true ASSERT_LOG_CONTAINS: think-short
01b — Toggle off (pre-seeded enabled:true)
Given
- Project state file pre-seeded with
{enabled:true, profile:medium, blacklist:[...]} CLAUDE_PLUGIN_DATAset to a temp dir (no global state)- No
THINK_SHORT_DEFAULTenv var
When
user prompt: /brewtools:think-short off
(The skill defaults to project scope silently under --print. It flips enabled→false.)
Then
- claude exits 0
.claude/brewtools/think-short.jsonexists with"enabled": false- log contains
think-short
Assert
ASSERT_EXIT_CODE: 0 ASSERT_STATE_PROJECT_JSON_CONTAINS: "enabled": false ASSERT_LOG_CONTAINS: think-short
02 — Profile switch
Given
- Clean test-project fixture
- Project state pre-seeded:
{"version":1,"enabled":true,"profile":"medium","blacklist":["debate","docs-writer","architect"]} - No
THINK_SHORT_DEFAULTenv var
When
user prompt: /brewtools:think-short profile aggressive
Then
- exit 0
.claude/brewtools/think-short.jsoncontains"profile": "aggressive""enabled": trueis preserved (profile switch must not flip enabled)- log
.claude/logs/brewtools.logcontainsthink-short
Assert
ASSERT_EXIT_CODE: 0 ASSERT_STATE_PROJECT_JSON_CONTAINS: "profile": "aggressive" ASSERT_STATE_PROJECT_JSON_CONTAINS: "enabled": true ASSERT_LOG_CONTAINS: think-short
03 — NL prompt (Russian combo: включись агрессивно)
Given
- Clean test-project fixture (no state files)
- No
THINK_SHORT_DEFAULTenv var
When
user prompt: включись агрессивно
(NL parser should match включись → on, агрессивно → profile aggressive. Skill executes both mutations for project scope.)
Then
- exit 0
.claude/brewtools/think-short.jsoncontains"enabled": true.claude/brewtools/think-short.jsoncontains"profile": "aggressive"- log contains
think-short: NL-promptandresolved as
Assert
ASSERT_EXIT_CODE: 0 ASSERT_STATE_PROJECT_JSON_CONTAINS: "enabled": true ASSERT_STATE_PROJECT_JSON_CONTAINS: "profile": "aggressive" ASSERT_LOG_CONTAINS: think-short: NL-prompt ASSERT_LOG_CONTAINS: resolved as
04 — NL prompt level number (уровень 3)
Given
- Clean test-project fixture (no state files)
- No
THINK_SHORT_DEFAULTenv var
When
user prompt: уровень 3
(NL parser matches уровень 3 → profile aggressive per synonym table.)
Then
- exit 0
.claude/brewtools/think-short.jsoncontains"profile": "aggressive"- log contains
think-short: NL-promptandresolved as
Assert
ASSERT_EXIT_CODE: 0 ASSERT_STATE_PROJECT_JSON_CONTAINS: "profile": "aggressive" ASSERT_LOG_CONTAINS: think-short: NL-prompt ASSERT_LOG_CONTAINS: resolved as
05 — SessionStart injects profile into additionalContext
Given
- Clean test-project fixture
- Project state pre-seeded:
{"version":1,"enabled":true,"profile":"medium","blacklist":["debate","docs-writer","architect"]} - No
THINK_SHORT_DEFAULTenv var
When
user prompt: say hi (trivial prompt — just enough to trigger SessionStart hook)
Then
- exit 0
- log
.claude/logs/brewtools.logcontainsSessionStart — injecting profile=medium
Assert
ASSERT_EXIT_CODE: 0 ASSERT_LOG_CONTAINS: SessionStart — injecting profile=medium
Notes
ALLOW_SKIP_ON_NO_TRIGGER
The hook fires via SessionStart event when claude launches. If the log line is absent it means the hook did not fire (e.g. plugin not loaded, or session-start.mjs not executed). The runner marks this as SKIP rather than FAIL when ALLOW_SKIP_ON_NO_TRIGGER is set, since CI may not load the plugin.
06 — Pre-task injects profile-lite into sub-agent prompt
Given
- Clean test-project fixture
- Project state pre-seeded:
{"version":1,"enabled":true,"profile":"medium","blacklist":["debate","docs-writer","architect"]} - No
THINK_SHORT_DEFAULTenv var
When
user prompt: Use the Task tool with subagent_type=general-purpose to say the word "hello" (causes claude to call the Task tool, triggering PreToolUse:Task hook)
Then
- exit 0
- log
.claude/logs/brewtools.logcontainsinjecting profile-lite
Assert
ASSERT_EXIT_CODE: 0 ASSERT_LOG_CONTAINS: injecting profile-lite
Notes
ALLOW_SKIP_ON_NO_TRIGGER
Whether the Task invocation occurs depends on whether the model complies. If the log line is absent the runner marks as SKIP. Confirmed triggers: pre-task.mjs fires on PreToolUse for any tool_name Task or Agent. The log prefix is think-short and message contains injecting profile-lite.
07 — Blacklist: architect agent skipped
Given
- Clean test-project fixture
- Project state pre-seeded:
{"version":1,"enabled":true,"profile":"medium","blacklist":["debate","docs-writer","architect"]} - No
THINK_SHORT_DEFAULTenv var
When
user prompt: Use the Task tool with subagent_type=architect to briefly describe what a plugin is (Task tool called with subagent_type=architect, which is in the default blacklist)
Then
- exit 0
- log
.claude/logs/brewtools.logcontainsSKIP (agent in blacklist)witharchitectin the surrounding context - log does NOT contain
injecting profile-litefor the architect call
Assert
ASSERT_EXIT_CODE: 0 ASSERT_LOG_CONTAINS: SKIP (agent in blacklist) ASSERT_LOG_NOT_CONTAINS: architect) — injecting profile-lite
Notes
ALLOW_SKIP_ON_NO_TRIGGER
The assertion ASSERT_LOG_NOT_CONTAINS uses a substring that would only appear if the hook wrongly injected for the blacklisted agent. If the Task tool was never called, the log lines are absent entirely — the runner treats this as SKIP rather than FAIL.
08 — Project state overrides global state
Given
- Clean test-project fixture
- Global state pre-seeded in
$CLAUDE_PLUGIN_DATA/think-short.json:
{"version":1,"enabled":false,"profile":"light","blacklist":["debate","docs-writer","architect"]}
- Project state pre-seeded in
.claude/brewtools/think-short.json:
{"version":1,"enabled":true,"profile":"aggressive","blacklist":["debate","docs-writer","architect"]}
- No
THINK_SHORT_DEFAULTenv var
When
user prompt: /brewtools:think-short status (reads merged state — project must win over global)
Then
- exit 0
- stdout contains
enabledand something indicating true/ENABLED - stdout contains
aggressive - stdout contains
project(indicating project-state as source)
Assert
ASSERT_EXIT_CODE: 0 ASSERT_STDOUT_CONTAINS: aggressive ASSERT_STDOUT_CONTAINS: project
Notes
The status command reads the merged effective state. Because project-state has enabled:true and profile:aggressive, those values must appear in the output. The runner checks stdout from out.json (the result field of claude's JSON output) for the directive strings.
09 — Logging visibility: CLAUDE_DEBUG=1 exposes debug lines
Given
- Clean test-project fixture
- Project state pre-seeded:
{"version":1,"enabled":true,"profile":"medium","blacklist":["debate","docs-writer","architect"]} CLAUDE_DEBUG=1set in environment
When
user prompt: say hi (trivial prompt — triggers SessionStart hook)
Then
- exit 0
- log
.claude/logs/brewtools.logdoes NOT lack debug detail for the think-short session inject path
(When CLAUDE_DEBUG=1, session-start.mjs logs think-short: profile preview = line)
- stdout does not contain error
Assert
ASSERT_EXIT_CODE: 0 ASSERT_LOG_CONTAINS: think-short: profile preview = ASSERT_STDOUT_NOT_CONTAINS_REGEX: "error":\s*"
Notes
ALLOW_SKIP_ON_NO_TRIGGER
The profile preview line is only emitted when CLAUDE_DEBUG === '1' (see session-start.mjs line 47). If SessionStart hook does not fire (plugin not loaded), the runner marks as SKIP.
---
Without CLAUDE_DEBUG (second pass, same scenario dir, fresh log)
Given
- Same fixture, SAME state file
CLAUDE_DEBUGunset
When
user prompt: say hi
Then
- log does NOT contain
think-short: profile preview =
(debug line must be absent when CLAUDE_DEBUG is not set)
Assert
ASSERT_EXIT_CODE: 0 ASSERT_LOG_NOT_CONTAINS: think-short: profile preview =
think-short e2e test fixture
Minimal project root used by run-e2e.sh as the working directory for each scenario.
Each test copies this directory into a fresh temp location under tests/e2e/results/<ts>/<scenario>/ so that state files and log files are isolated between runs.
No source code is present — only the .claude/ directory skeleton required by the skill and hooks.
#!/usr/bin/env bash
# run-e2e.sh — E2E test runner for brewtools:think-short
# Usage:
# ./run-e2e.sh # run all 9 scenarios
# ./run-e2e.sh --dry-run # print plan without invoking claude
# ./run-e2e.sh 03 07 # run specific scenario numbers only
#
# Requirements: bash >=4, jq, node, claude CLI in PATH
# Compatible with zsh (shebang uses bash explicitly).
set -euo pipefail
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)"
BREWTOOLS_DIR="${REPO_ROOT}/brewtools"
FIXTURES_DIR="${SCRIPT_DIR}/fixtures/test-project"
SCENARIOS_DIR="${SCRIPT_DIR}/e2e"
RESULTS_BASE="${SCRIPT_DIR}/e2e/results"
TS="$(date +%Y%m%d-%H%M%S)"
RESULTS_DIR="${RESULTS_BASE}/${TS}"
# ---------------------------------------------------------------------------
# Options
# ---------------------------------------------------------------------------
DRY_RUN=0
FILTER_SCENARIOS=()
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
[0-9][0-9]|[0-9][0-9][a-z]) FILTER_SCENARIOS+=("$arg") ;;
*) echo "Unknown arg: $arg" >&2; exit 1 ;;
esac
done
# ---------------------------------------------------------------------------
# Colour helpers (no-op when not a tty)
# ---------------------------------------------------------------------------
if [ -t 1 ]; then
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; RESET='\033[0m'; BOLD='\033[1m'
else
RED=''; GREEN=''; YELLOW=''; CYAN=''; RESET=''; BOLD=''
fi
pass() { echo -e "${GREEN}PASS${RESET} $*"; }
fail() { echo -e "${RED}FAIL${RESET} $*"; }
skip() { echo -e "${YELLOW}SKIP${RESET} $*"; }
info() { echo -e "${CYAN}INFO${RESET} $*"; }
# ---------------------------------------------------------------------------
# Counters
# ---------------------------------------------------------------------------
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
FAILED_NAMES=()
# ---------------------------------------------------------------------------
# Trap for failures
# ---------------------------------------------------------------------------
CURRENT_SCENARIO="(none)"
trap 'echo -e "\n${RED}TRAP${RESET}: unexpected failure during scenario: ${CURRENT_SCENARIO}" >&2' ERR
# ---------------------------------------------------------------------------
# Parse a single scenario .md file.
# Extracts: WHEN prompt, ASSERT_* directives, ALLOW_SKIP_ON_NO_TRIGGER flag.
# Outputs variables: SCENARIO_PROMPT, ALLOW_SKIP, ASSERTS (array of "KEY:VALUE")
# ---------------------------------------------------------------------------
parse_scenario() {
local md_file="$1"
SCENARIO_PROMPT=""
ALLOW_SKIP=0
ASSERTS=()
local in_when=0
while IFS= read -r line; do
# Detect ## When section
if [[ "$line" =~ ^##[[:space:]]When ]]; then
in_when=1
continue
fi
# Leave When section on next ##
if [[ "$in_when" -eq 1 && "$line" =~ ^##[[:space:]] ]]; then
in_when=0
fi
# Capture prompt line inside When
if [[ "$in_when" -eq 1 && "$line" =~ ^user[[:space:]]prompt:[[:space:]] ]]; then
# Strip leading: user prompt: and surrounding backticks
SCENARIO_PROMPT="${line#*user prompt: }"
SCENARIO_PROMPT="${SCENARIO_PROMPT#\`}"
SCENARIO_PROMPT="${SCENARIO_PROMPT%\`}"
fi
# ASSERT directives
if [[ "$line" =~ ^ASSERT_([A-Z_]+):[[:space:]]*(.*) ]]; then
local key="${BASH_REMATCH[1]}"
local val="${BASH_REMATCH[2]}"
ASSERTS+=("${key}:${val}")
fi
# ALLOW_SKIP_ON_NO_TRIGGER
if [[ "$line" =~ ALLOW_SKIP_ON_NO_TRIGGER ]]; then
ALLOW_SKIP=1
fi
done < "$md_file"
}
# ---------------------------------------------------------------------------
# Evaluate a single ASSERT directive.
# Returns 0=pass, 1=fail, 2=skip (when ALLOW_SKIP and trigger absent)
# ---------------------------------------------------------------------------
evaluate_assert() {
local key="$1"
local val="$2"
local workdir="$3"
local out_json="$4"
local exit_code_actual="$5"
local allow_skip="$6"
local plugin_data_dir="$7"
local project_state="${workdir}/.claude/brewtools/think-short.json"
local global_state="${plugin_data_dir}/think-short.json"
local log_file="${workdir}/.claude/logs/brewtools.log"
case "$key" in
EXIT_CODE)
local expected_code="${val// /}"
if [[ "$exit_code_actual" -eq "$expected_code" ]]; then
return 0
else
echo " ASSERT_EXIT_CODE: expected=${expected_code} actual=${exit_code_actual}" >&2
return 1
fi
;;
STATE_PROJECT_JSON_CONTAINS)
if [[ ! -f "$project_state" ]]; then
if [[ "$allow_skip" -eq 1 ]]; then
echo " ALLOW_SKIP: project state file absent — hook may not have fired" >&2
return 2
fi
echo " ASSERT_STATE_PROJECT_JSON_CONTAINS: file absent: ${project_state}" >&2
return 1
fi
if grep -qF -- "$val" "$project_state"; then
return 0
else
echo " ASSERT_STATE_PROJECT_JSON_CONTAINS: '${val}' not found in ${project_state}" >&2
return 1
fi
;;
STATE_GLOBAL_JSON_CONTAINS)
if [[ ! -f "$global_state" ]]; then
echo " ASSERT_STATE_GLOBAL_JSON_CONTAINS: file absent: ${global_state}" >&2
return 1
fi
if grep -qF -- "$val" "$global_state"; then
return 0
else
echo " ASSERT_STATE_GLOBAL_JSON_CONTAINS: '${val}' not found in ${global_state}" >&2
return 1
fi
;;
LOG_CONTAINS)
if [[ ! -f "$log_file" ]]; then
if [[ "$allow_skip" -eq 1 ]]; then
echo " ALLOW_SKIP: log file absent — hook may not have fired" >&2
return 2
fi
echo " ASSERT_LOG_CONTAINS: log file absent: ${log_file}" >&2
return 1
fi
if grep -qF -- "$val" "$log_file"; then
return 0
else
if [[ "$allow_skip" -eq 1 ]]; then
echo " ALLOW_SKIP: '${val}' not in log — hook may not have fired" >&2
return 2
fi
echo " ASSERT_LOG_CONTAINS: '${val}' not found in ${log_file}" >&2
return 1
fi
;;
LOG_NOT_CONTAINS)
if [[ ! -f "$log_file" ]]; then
# absent log = line definitely not present = pass
return 0
fi
if grep -qF -- "$val" "$log_file"; then
echo " ASSERT_LOG_NOT_CONTAINS: '${val}' was found (unexpected) in ${log_file}" >&2
return 1
else
return 0
fi
;;
STDOUT_CONTAINS)
if [[ ! -f "$out_json" ]]; then
echo " ASSERT_STDOUT_CONTAINS: out.json absent" >&2
return 1
fi
# Extract result field from claude JSON output (may be array of messages)
local stdout_text
stdout_text="$(jq -r '
if type == "array" then
[.[] | if .type == "result" then .result // "" else "" end] | join("\n")
elif type == "object" then
.result // ""
else ""
end' "$out_json" 2>/dev/null || cat "$out_json")"
if echo "$stdout_text" | grep -qF -- "$val"; then
return 0
else
echo " ASSERT_STDOUT_CONTAINS: '${val}' not found in stdout" >&2
return 1
fi
;;
STDOUT_NOT_CONTAINS_REGEX)
if [[ ! -f "$out_json" ]]; then
# no output = not present = pass
return 0
fi
local stdout_text
stdout_text="$(jq -r '
if type == "array" then
[.[] | if .type == "result" then .result // "" else "" end] | join("\n")
elif type == "object" then
.result // ""
else ""
end' "$out_json" 2>/dev/null || cat "$out_json")"
if echo "$stdout_text" | grep -qE -- "$val"; then
echo " ASSERT_STDOUT_NOT_CONTAINS_REGEX: pattern '${val}' matched (unexpected)" >&2
return 1
else
return 0
fi
;;
*)
echo " Unknown assert directive: ASSERT_${key} — skipping" >&2
return 0
;;
esac
}
# ---------------------------------------------------------------------------
# Run one scenario
# ---------------------------------------------------------------------------
run_scenario() {
local md_file="$1"
local scenario_num
scenario_num="$(basename "$md_file" | cut -d'-' -f1)"
local scenario_name
scenario_name="$(basename "$md_file" .md)"
CURRENT_SCENARIO="$scenario_name"
TOTAL=$((TOTAL + 1))
local workdir="${RESULTS_DIR}/${scenario_name}"
local plugin_data_dir="${workdir}/_plugin_data"
local out_json="${workdir}/out.json"
# Parse scenario
parse_scenario "$md_file"
if [[ "$DRY_RUN" -eq 1 ]]; then
echo ""
echo -e "${BOLD}[DRY-RUN] ${scenario_name}${RESET}"
echo " prompt : ${SCENARIO_PROMPT}"
echo " allow_skip: ${ALLOW_SKIP}"
echo " asserts :"
for a in "${ASSERTS[@]}"; do
echo " ASSERT_${a%%:*}: ${a#*:}"
done
PASSED=$((PASSED + 1))
return 0
fi
info "Running ${scenario_name} ..."
# Create isolated workdir
mkdir -p "$workdir" "$plugin_data_dir"
cp -r "${FIXTURES_DIR}/." "${workdir}/"
# -------------------------------------------------------------------------
# Scenario-specific pre-seeding
# -------------------------------------------------------------------------
case "$scenario_num" in
01b)
mkdir -p "${workdir}/.claude/brewtools"
printf '{"version":1,"enabled":true,"profile":"medium","blacklist":["debate","docs-writer","architect"]}\n' \
> "${workdir}/.claude/brewtools/think-short.json"
;;
02)
mkdir -p "${workdir}/.claude/brewtools"
printf '{"version":1,"enabled":true,"profile":"medium","blacklist":["debate","docs-writer","architect"]}\n' \
> "${workdir}/.claude/brewtools/think-short.json"
;;
05|06|07)
mkdir -p "${workdir}/.claude/brewtools"
printf '{"version":1,"enabled":true,"profile":"medium","blacklist":["debate","docs-writer","architect"]}\n' \
> "${workdir}/.claude/brewtools/think-short.json"
;;
08)
# Global off, project on — project must win
mkdir -p "${workdir}/.claude/brewtools"
printf '{"version":1,"enabled":false,"profile":"light","blacklist":["debate","docs-writer","architect"]}\n' \
> "${plugin_data_dir}/think-short.json"
printf '{"version":1,"enabled":true,"profile":"aggressive","blacklist":["debate","docs-writer","architect"]}\n' \
> "${workdir}/.claude/brewtools/think-short.json"
;;
09)
mkdir -p "${workdir}/.claude/brewtools"
printf '{"version":1,"enabled":true,"profile":"medium","blacklist":["debate","docs-writer","architect"]}\n' \
> "${workdir}/.claude/brewtools/think-short.json"
;;
esac
# -------------------------------------------------------------------------
# Build env overrides per scenario
# -------------------------------------------------------------------------
local extra_env=()
extra_env+=("CLAUDE_PLUGIN_DATA=${plugin_data_dir}")
unset THINK_SHORT_DEFAULT 2>/dev/null || true
if [[ "$scenario_num" == "09" ]]; then
extra_env+=("CLAUDE_DEBUG=1")
fi
# -------------------------------------------------------------------------
# Invoke claude
# -------------------------------------------------------------------------
local exit_code=0
if [[ -z "$SCENARIO_PROMPT" ]]; then
echo " WARNING: no prompt found in ${md_file}" >&2
fi
set +e
(
cd "$workdir"
env "${extra_env[@]}" \
claude \
--plugin-dir "${BREWTOOLS_DIR}" \
--print "${SCENARIO_PROMPT}" \
--dangerously-skip-permissions \
--output-format json \
> "${out_json}" 2>"${workdir}/stderr.txt"
)
exit_code=$?
set -e
# -------------------------------------------------------------------------
# Scenario 09 second pass (without CLAUDE_DEBUG)
# -------------------------------------------------------------------------
if [[ "$scenario_num" == "09" ]]; then
local log_file_2="${workdir}/.claude/logs/brewtools.log"
# Remove log so second pass creates a fresh one
rm -f "$log_file_2"
local exit_code_2=0
set +e
(
cd "$workdir"
env "CLAUDE_PLUGIN_DATA=${plugin_data_dir}" \
claude \
--plugin-dir "${BREWTOOLS_DIR}" \
--print "say hi" \
--dangerously-skip-permissions \
--output-format json \
>> "${workdir}/out2.json" 2>>"${workdir}/stderr2.txt"
)
exit_code_2=$?
set -e
# The second-pass assert (LOG_NOT_CONTAINS) uses the fresh log
fi
# -------------------------------------------------------------------------
# Evaluate asserts
# -------------------------------------------------------------------------
local scenario_pass=1
local any_skip=0
for assert_entry in "${ASSERTS[@]}"; do
local ak="${assert_entry%%:*}"
local av="${assert_entry#*:}"
# Trim leading space
av="${av# }"
local result=0
evaluate_assert "$ak" "$av" "$workdir" "$out_json" "$exit_code" "$ALLOW_SKIP" "$plugin_data_dir" || result=$?
if [[ "$result" -eq 2 ]]; then
any_skip=1
elif [[ "$result" -ne 0 ]]; then
scenario_pass=0
fi
done
# -------------------------------------------------------------------------
# Scenario 09: also check second-pass asserts (LOG_NOT_CONTAINS lines)
# The second pass expects profile preview absent — evaluated separately
# -------------------------------------------------------------------------
if [[ "$scenario_num" == "09" ]]; then
local log_file_2="${workdir}/.claude/logs/brewtools.log"
local result_2=0
evaluate_assert "LOG_NOT_CONTAINS" "think-short: profile preview =" \
"$workdir" "${workdir}/out2.json" "$exit_code_2" "0" "$plugin_data_dir" || result_2=$?
if [[ "$result_2" -ne 0 ]]; then
scenario_pass=0
fi
fi
# -------------------------------------------------------------------------
# Report
# -------------------------------------------------------------------------
if [[ "$scenario_pass" -eq 0 ]]; then
fail "${scenario_name} (exit=${exit_code})"
FAILED=$((FAILED + 1))
FAILED_NAMES+=("$scenario_name")
elif [[ "$any_skip" -eq 1 && "$ALLOW_SKIP" -eq 1 ]]; then
skip "${scenario_name} (hook did not fire — marked ALLOW_SKIP_ON_NO_TRIGGER)"
SKIPPED=$((SKIPPED + 1))
else
pass "${scenario_name}"
PASSED=$((PASSED + 1))
fi
}
# ---------------------------------------------------------------------------
# Scenario list (ordered)
# ---------------------------------------------------------------------------
SCENARIO_FILES=()
while IFS= read -r -d '' f; do
SCENARIO_FILES+=("$f")
done < <(find "${SCENARIOS_DIR}" -maxdepth 1 \( -name '[0-9][0-9]-*.md' -o -name '[0-9][0-9][a-z]-*.md' \) -print0 | sort -z)
if [[ "${#SCENARIO_FILES[@]}" -eq 0 ]]; then
echo "No scenario .md files found in ${SCENARIOS_DIR}" >&2
exit 1
fi
# ---------------------------------------------------------------------------
# Pre-flight checks (skip in dry-run)
# ---------------------------------------------------------------------------
if [[ "$DRY_RUN" -eq 0 ]]; then
for cmd in claude jq node; do
if ! command -v "$cmd" &>/dev/null; then
echo "Required tool not found: $cmd" >&2
exit 1
fi
done
mkdir -p "${RESULTS_DIR}"
fi
# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
echo ""
echo -e "${BOLD}=== think-short E2E test runner ===${RESET}"
echo "Brewtools dir : ${BREWTOOLS_DIR}"
echo "Results dir : ${RESULTS_DIR}"
echo "Dry-run : ${DRY_RUN}"
echo ""
for md_file in "${SCENARIO_FILES[@]}"; do
num="$(basename "$md_file" | cut -d'-' -f1)"
# Apply filter if provided
if [[ "${#FILTER_SCENARIOS[@]}" -gt 0 ]]; then
local_match=0
for f in "${FILTER_SCENARIOS[@]}"; do
[[ "$f" == "$num" ]] && local_match=1 && break
done
if [[ "$local_match" -eq 0 ]]; then
continue
fi
fi
run_scenario "$md_file"
done
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
echo -e "${BOLD}=== Summary ===${RESET}"
printf "Total: %d | " "$TOTAL"
printf "${GREEN}Passed: %d${RESET} | " "$PASSED"
printf "${RED}Failed: %d${RESET} | " "$FAILED"
printf "${YELLOW}Skipped: %d${RESET}\n" "$SKIPPED"
if [[ "${#FAILED_NAMES[@]}" -gt 0 ]]; then
echo ""
echo -e "${RED}Failed scenarios:${RESET}"
for name in "${FAILED_NAMES[@]}"; do
echo " - ${name}"
done
fi
echo ""
if [[ "$DRY_RUN" -eq 1 ]]; then
echo -e "${CYAN}Dry-run complete — no claude invocations made.${RESET}"
exit 0
fi
if [[ "$FAILED" -gt 0 ]]; then
exit 1
fi
exit 0