SLOPSHOPPER

ask-mode

A read-only mode for questions: Claude answers and investigates but cannot change files, run state-changing commands or send anything; quick, ask and deep…

newbandguardcommandtoastprompt
v1.0.0no licenseupdated 2026-10-23Tvrtk0/claude-code-mods/ask-mode
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ask-mode
› 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 › /ask ⎿ ask-mode: Ask mode: ask (read-only). Nothing gets changed until /ask off. ⟨Claude Code's own drawing⟩ ╭───────────────────────────────────────────────────────────────────────────────────── 🔒 │ level-quick │ level-ask │ level-deep ╰───────────────────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ╭───────────────────────────────────────────────────────────────────── 🔒 │ level-quick │ level-ask │ level-deep ╰─────────────────────────────────────────────────────────────────────
README

local-mods

Personal Claude Code mods for a calmer, more readable terminal.

ModWhat it does
readable-widthDraws each piece of a reply in a grey rounded card, as wide as its longest line and wrapped at 80 columns; tables and code blocks that name a language keep the full width; an unlabelled or text block wraps like prose. Your prompts are bold and accent-colored, in a rounded accent-colored bubble as wide as their longest line, with a blank line above each message.
usage-metersThe bottom of the band above the prompt, under the other mods' buttons: ctx, 5h and 7d meters in one rounded frame. Each bar runs teal to green to yellow to red along its length, so a fuller bar reaches warmer colors; 5h and 7d also show the time left until they reset (2h 16m, 3d 4h). The pointer on a meter shows a card over the buttons: the context's tokens and the session's cost, or when a limit resets (today 18:00, Fri 16:00).
tool-logFolds tool calls and their results in the chat into a ◆ N tools line, one per run of calls with no text between them, and keeps them in a side log grouped by prompt. Toggle it with the framed ☰ panel button, /panel or a key bound to app:toggleDiffPreSession (ctrl+t in ~/.claude/keybindings.json, see below; this replaces the built-in todo list toggle); a click on a chat line opens it on that prompt's steps, until the next prompt or ✕ returns it to live. The chat line also sums file changes and failures (◆ 6 tools · 2 files changed +14 −3 · 1 failed). A turn longer than 30 s ends with a desktop notification. Successful Bash rows expand to show their command, and Edit/Write rows to show their diff. Inside a git repo a framed ± diff button opens the built-in /diff panel. The panel has three tabs: Log, Files (every file changed this session with its diff, new files included) and Agents (subagents with status, time, tool count and their latest step); ☰ panel reopens it on the tab last in view. The active tab is orange, a tab with something new since you last looked is bright, the rest are dim. Replaces the spinner with a minimal one that names the current step, e.g. ⠋ 12s · Reading register.tsx or ⠙ 3s · Thinking.
turn-detailsReplaces the Brewed for 34s · done 1:09 PM line with ◆ details ▸, which folds the turn's duration, finish time, tool calls and model open in place.
ask-modeA read-only mode for when you want an answer, not changes. Beside the panel button, a framed quick │ ask │ deep switch: a click picks a level, a click on the active one turns it off; /quick, /ask and /deep do the same, /ask off leaves any. While on, the frame and the level are yellow beside a 🔒, every prompt carries a brief to answer and report, and every call that may change something is refused, in subagents too: Edit, Write, Bash commands off a read-only list (ls, cat, rg, git log/diff/status/show, gh pr view…; redirects and command substitution included), MCP tools whose name does not read (send, create, update…). quick answers briefly on Haiku 5.5 at low effort, ask investigates on your model, deep analyzes at max effort on Opus. It stays on until you turn it off; a new session starts with it off.

Install

From GitHub:

claude plugin marketplace add Tvrtk0/claude-code-mods
claude plugin install readable-width@local-mods
claude plugin install usage-meters@local-mods
claude plugin install tool-log@local-mods
claude plugin install turn-details@local-mods
claude plugin install ask-mode@local-mods

From a local clone (edits apply after /reload-plugins, no reinstall):

claude plugin marketplace add ~/.claude/mods
claude plugin install readable-width@local-mods
claude plugin install usage-meters@local-mods
claude plugin install tool-log@local-mods
claude plugin install turn-details@local-mods
claude plugin install ask-mode@local-mods

Installed from a local clone, plugins are read straight from the folder.

Develop

claude plugin validate ~/.claude/mods/<mod>
claude plugin test ~/.claude/mods/<mod>

Keyboard shortcut for the panel

A mod cannot add a key of its own, so the ⌃T hint beside ☰ panel borrows an engine action that only the /diff panel handles. Bind any key to it:

{
  "$schema": "https://www.schemastore.org/claude-code-keybindings.json",
  "bindings": [
    { "context": "Global", "bindings": { "ctrl+t": "app:toggleDiffPreSession" } }
  ]
}

ctrl+t hands the panel the keyboard (opening it if needed) and closes it when it already has it. Inside: h/l or 1–3 switch tabs, j/k move between rows (starting from the newest), enter opens one, d/u scroll half a window, g/b jump to the first or last row, esc returns to the prompt and leaves the panel open. The panel scrolls itself, so its tabs and keys stay at the top; Bottom is b, not G: a button hotkey cannot tell shift+g from g.

Source 3 files
hooks/register.tsx 178 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { verdict } from './policy'
5import type { Level } from '../types'
6
7// Stays on until it is turned off; a new session starts with it off.
8const level = atom({ plugin: 'ask-mode', key: 'level' } as const, 'off')
9
10// The switch: the band's grey rounded frame, yellow while on.
11const FRAME = '#5a5a5a'
12const FRAME_HOVER = '#9a9a9a'
13const IDLE = '#9a9a9a'
14
15const QUICK_MODEL = 'claude-haiku-5-5'
16const DEEP_MODEL = 'claude-opus-5-5'
17
18const LABELS: Record<Level, string> = {
19  off: 'off',
20  quick: 'quick',
21  ask: 'ask',
22  deep: 'deep',
23}
24
25// What the model reads beside each prompt while the mode is on.
26const BRIEFS: Record<Exclude<Level, 'off'>, string> = {
27  quick:
28    'Ask mode, quick (read-only). The user wants a short, direct answer, not changes. Answer from what you know or a minimal look (a few reads at most). Do not edit files, run commands that change anything, commit, push, install or send anything. If the question needs a real investigation, say so in one line instead of doing it.',
29  ask:
30    'Ask mode (read-only). The user wants information, not changes. Investigate as much as the question needs with read-only tools, then report what you found: the answer first, then the evidence (files, lines, commands). Do not edit files, run commands that change anything, commit, push, install or send anything; where a change would help, describe it and stop.',
31  deep:
32    'Ask mode, deep (read-only). The user wants a thorough analysis, not changes. Read the relevant code widely, trace the flow end to end, check each assumption against the code, and use read-only subagents in parallel where they help. Report the answer, the evidence, the options with their trade-offs and risks, and a recommendation. Do not edit files, run commands that change anything, commit, push, install or send anything.',
33}
34
35const deny = (why: string) =>
36  `Ask mode is on (read-only): ${why}. Do not work around it; answer from what you can read, and say what you would change. The user turns it off with /ask off.`
37
38// The model and effort a step runs on at each level; ask keeps the session's.
39// A model's context suffix (`[1m]`) carries over.
40function stepModel(at: Level, model: string): { model: string; effort: 'low' | 'max' } | undefined {
41  const suffix = /\[[^\]]+\]$/.exec(model)?.[0] ?? ''
42
43  if (at === 'quick') return { model: QUICK_MODEL + suffix, effort: 'low' }
44  if (at === 'deep') return { model: model.includes('opus') ? model : DEEP_MODEL + suffix, effort: 'max' }
45
46  return undefined
47}
48
49async function setLevel($: EngineInterface, to: Level) {
50  await update($, level, () => to)
51}
52
53const confirmation = (to: Level) =>
54  to === 'off'
55    ? 'Ask mode off: Claude can change things again.'
56    : `Ask mode: ${LABELS[to]} (read-only). Nothing gets changed until /ask off.`
57
58export const register: Register = on => {
59  on('session.start', async ($, e, next) => {
60    await $.command.register({
61      name: 'ask',
62      description: 'Read-only mode: Claude only answers and investigates (/ask off to leave)',
63      argumentHint: '[off]',
64    })
65    await $.command.register({ name: 'quick', description: 'Read-only mode with a short answer from a faster model' })
66    await $.command.register({ name: 'deep', description: 'Read-only mode with a thorough analysis at max effort' })
67
68    return next(e)
69  })
70
71  // /ask, /quick and /deep turn their level on, and off when it is on already;
72  // `/ask off` turns any level off.
73  for (const name of ['ask', 'quick', 'deep'] as const) {
74    on('command.run', { command: name }, async ($, e) => {
75      const now = await read($, level)
76      const to: Level = e.args.trim() === 'off' || now === name ? 'off' : name
77      await setLevel($, to)
78
79      return { text: confirmation(to) }
80    })
81  }
82
83  on('prompt.submit', async ($, e, next) => {
84    const at = await read($, level)
85    if (at === 'off') return next(e)
86
87    return next({ ...e, context: [...(e.context ?? []), BRIEFS[at]] })
88  })
89
90  // The guard: a call that may change something is refused, in subagents too.
91  on('tool.call', async ($, e, next) => {
92    const at = await read($, level)
93    if (at === 'off') return next(e)
94
95    const why = verdict(String(e.tool), e as unknown as Record<string, unknown>)
96
97    return why === undefined ? next(e) : { deny: deny(why) }
98  }).catch(($, e, next) => (next.called ? next(e) : { deny: deny('its guard failed') }))
99
100  // Quick and deep run the main loop's steps on their own model and effort; a
101  // request the other model refuses before answering runs on the session's.
102  on('turn.step', async function* ($, e, next) {
103    const at = await read($, level)
104    const want = e.agentId === undefined ? stepModel(at, e.model) : undefined
105    if (want === undefined) return yield* next(e)
106
107    let hasStreamed = false
108    try {
109      const stream = next({ ...e, ...want })
110      while (true) {
111        const step = await stream.next()
112        if (step.done === true) return step.value
113        hasStreamed = true
114        yield step.value
115      }
116    } catch (error) {
117      if (hasStreamed) throw error
118      $.ui.toast(`Ask mode: ${want.model} failed, answering on ${e.model}`)
119      return yield* next(e)
120    }
121  })
122
123  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
124    const below = await next(e)
125
126    if (e.surface !== 'terminal' || e.props.hasSurvey) {
127      return below
128    }
129
130    const { Box, Button, Text } = $.ui.resolve(e)
131    const at = await read($, level)
132    const isOn = at !== 'off'
133
134    // A segmented switch beside the panel's button, in the band's rounded
135    // frame: a click picks a level, a click on the active one turns it off.
136    // While on, the frame and the level are yellow beside a lock, whose cell
137    // is kept while off, so the switch never moves.
138    return (
139      <Box>
140        {below}
141        <Box flexShrink={0}>
142          <Box flexShrink={0} paddingTop={1}>
143            <Text color="warning">{isOn ? '🔒 ' : '   '}</Text>
144          </Box>
145          <Box
146            key="levels"
147            flexShrink={0}
148            borderStyle="round"
149            borderColor={isOn ? 'warning' : FRAME}
150            paddingX={1}
151            hover={isOn ? undefined : { borderColor: FRAME_HOVER }}
152          >
153            {(['quick', 'ask', 'deep'] as const).map((to, i) => {
154              const isActive = at === to
155
156              return (
157                <Box key={`levelbox-${to}`}>
158                  {i > 0 && <Text color={FRAME}>{'  │  '}</Text>}
159                  <Button
160                    key={`level-${to}`}
161                    plain
162                    onPress={() => setLevel($, isActive ? 'off' : to)}
163                    hover={isActive ? undefined : { color: 'white' }}
164                  >
165                    <Text color={isActive ? 'warning' : IDLE} bold={isActive}>
166                      {LABELS[to]}
167                    </Text>
168                  </Button>
169                </Box>
170              )
171            })}
172          </Box>
173        </Box>
174      </Box>
175    )
176  })
177}
178
hooks/policy.ts 299 lines
1// What ask mode lets through: tools that only read, Bash commands on a
2// read-only list, and MCP tools whose name reads. Everything else is refused,
3// an unknown tool or command included.
4
5import type { Level } from '../types'
6
7
8const READ_TOOLS = new Set([
9  'Read',
10  'Grep',
11  'Glob',
12  'LS',
13  'WebFetch',
14  'WebSearch',
15  'ToolSearch',
16  'Agent',
17  'Task',
18  'TaskOutput',
19  'TaskStop',
20  'AskUserQuestion',
21  'Skill',
22  'SendMessage',
23  'ListAgents',
24  'ListMcpResourcesTool',
25  'ReadMcpResourceTool',
26  'ReadMcpResourceDirTool',
27  'CronList',
28  'EnterPlanMode',
29  'ExitPlanMode',
30  'ReportFindings',
31])
32
33// Tools that read with some actions and write with others.
34const READ_ACTIONS: Record<string, Set<string>> = {
35  Artifact: new Set(['read', 'list', 'quickstart']),
36  ArtifactData: new Set(['get', 'list', 'query']),
37  ArtifactComments: new Set(['list', 'read', 'get']),
38}
39
40const MCP_READ = new Set([
41  'get', 'list', 'search', 'read', 'query', 'fetch', 'find', 'view', 'describe',
42  'whoami', 'guide', 'lookup', 'check', 'show', 'inspect', 'count', 'preview',
43])
44
45const MCP_WRITE = new Set([
46  'send', 'create', 'update', 'delete', 'remove', 'post', 'reply', 'forward',
47  'trash', 'untrash', 'label', 'unlabel', 'mark', 'unmark', 'apply', 'upload',
48  'share', 'add', 'set', 'schedule', 'respond', 'use', 'generate', 'export',
49  'copy', 'move', 'rename', 'edit', 'write', 'run', 'cancel', 'complete',
50  'authenticate', 'download', 'batch', 'submit', 'publish', 'merge', 'close',
51])
52
53// The first verb in an MCP tool's own name decides: `slack_read_channel`
54// reads, `slack_send_message` writes, a name with no known verb is refused.
55function mcpVerdict(tool: string): string | undefined {
56  const name = tool.split('__').pop() ?? tool
57  const words = name.split(/[_\-]|(?<=[a-z])(?=[A-Z])/).map(w => w.toLowerCase())
58  const verb = words.find(w => MCP_READ.has(w) || MCP_WRITE.has(w))
59
60  return verb !== undefined && MCP_READ.has(verb) ? undefined : `${name} may change something`
61}
62
63// Commands that only read, whatever their arguments.
64const READ_COMMANDS = new Set([
65  'ls', 'cat', 'head', 'tail', 'wc', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'fd',
66  'tree', 'pwd', 'echo', 'printf', 'which', 'type', 'whereis', 'file', 'stat',
67  'du', 'df', 'date', 'whoami', 'id', 'uname', 'hostname', 'printenv', 'diff',
68  'cmp', 'comm', 'uniq', 'cut', 'tr', 'column', 'nl', 'basename', 'dirname',
69  'realpath', 'readlink', 'jq', 'yq', 'cd', 'true', 'false', 'test', '[', 'bat',
70  'strings', 'hexdump', 'xxd', 'od', 'md5', 'shasum', 'sha256sum', 'lsof', 'ps',
71  'sleep', 'tldr', 'cal',
72])
73
74const GIT_READ = new Set([
75  'status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'ls-files', 'ls-tree',
76  'ls-remote', 'grep', 'shortlog', 'describe', 'cat-file', 'merge-base',
77  'for-each-ref', 'name-rev', 'rev-list', 'count-objects', 'whatchanged',
78  'show-ref', 'show-branch', 'cherry', 'range-diff', 'check-ignore', 'var',
79  'help', 'version',
80])
81
82const GH_READ: Record<string, Set<string>> = {
83  pr: new Set(['view', 'list', 'diff', 'checks', 'status']),
84  issue: new Set(['view', 'list', 'status']),
85  repo: new Set(['view', 'list']),
86  run: new Set(['view', 'list']),
87  release: new Set(['view', 'list']),
88  workflow: new Set(['view', 'list']),
89  label: new Set(['list']),
90  auth: new Set(['status']),
91  search: new Set(['code', 'commits', 'issues', 'prs', 'repos']),
92}
93
94const PACKAGE_READ: Record<string, Set<string>> = {
95  npm: new Set(['ls', 'list', 'view', 'info', 'outdated', 'why', 'explain', 'search', 'help']),
96  pnpm: new Set(['ls', 'list', 'view', 'info', 'outdated', 'why']),
97  yarn: new Set(['list', 'info', 'why', 'outdated']),
98}
99
100// Splits a command line into its simple commands, quotes respected: on
101// `&&`, `||`, `;`, `|`, `&` and newlines. Each comes back as its words, the
102// quotes taken off.
103function commandsOf(line: string): string[][] {
104  const commands: string[][] = []
105  let words: string[] = []
106  let word = ''
107  let hasWord = false
108  let quote: '"' | "'" | null = null
109
110  const endWord = () => {
111    if (hasWord) words.push(word)
112    word = ''
113    hasWord = false
114  }
115  const endCommand = () => {
116    endWord()
117    if (words.length > 0) commands.push(words)
118    words = []
119  }
120
121  for (let i = 0; i < line.length; i++) {
122    const c = line[i] as string
123    if (quote !== null) {
124      if (c === quote) quote = null
125      else if (c === '\\' && quote === '"' && i + 1 < line.length) word += line[++i]
126      else word += c
127      continue
128    }
129    if (c === '"' || c === "'") {
130      quote = c
131      hasWord = true
132    } else if (c === '\\' && i + 1 < line.length) {
133      word += line[++i]
134      hasWord = true
135    } else if (c === ' ' || c === '\t') {
136      endWord()
137    } else if (c === '\n' || c === ';' || c === '|' || c === '&') {
138      endCommand()
139    } else {
140      word += c
141      hasWord = true
142    }
143  }
144  endCommand()
145
146  return commands
147}
148
149const flags = (args: string[]) => args.filter(a => a.startsWith('-'))
150
151function gitVerdict(args: string[]): string | undefined {
152  let i = 0
153  // Global options before the subcommand.
154  while (i < args.length && (args[i] as string).startsWith('-')) {
155    const a = args[i] as string
156    i += a === '-C' || a === '-c' ? 2 : 1
157  }
158  const sub = args[i]
159  const rest = args.slice(i + 1)
160  const no = `git ${sub ?? ''} may change the repository`
161
162  if (sub === undefined) return undefined
163  if (GIT_READ.has(sub)) return sub === 'diff' && rest.includes('--output') ? no : undefined
164
165  switch (sub) {
166    case 'branch': {
167      const isList = rest.some(a => ['-l', '--list', '-a', '-r', '-v', '-vv', '--all', '--remotes', '--contains', '--merged', '--no-merged', '--show-current'].includes(a))
168      const isChange = rest.some(a => /^-(d|D|m|M|c|C|f|u)$|^--(delete|move|copy|force|set-upstream-to|unset-upstream|edit-description)/.test(a))
169      const names = rest.filter(a => !a.startsWith('-'))
170
171      return !isChange && (names.length === 0 || isList) ? undefined : no
172    }
173    case 'tag': {
174      const isChange = rest.some(a => /^-(d|a|s|f|m|u)$|^--(delete|annotate|sign|force)/.test(a))
175      const names = rest.filter(a => !a.startsWith('-'))
176      const isList = rest.some(a => a === '-l' || a === '--list')
177
178      return !isChange && (names.length === 0 || isList) ? undefined : no
179    }
180    case 'remote':
181      return rest.length === 0 || rest[0] === '-v' || rest[0] === 'show' || rest[0] === 'get-url' ? undefined : no
182    case 'stash':
183      return rest[0] === 'list' || rest[0] === 'show' ? undefined : no
184    case 'worktree':
185      return rest[0] === 'list' ? undefined : no
186    case 'reflog':
187      return rest.length === 0 || rest[0] === 'show' || flags(rest).length === rest.length ? undefined : no
188    case 'config':
189      return rest.some(a => ['--get', '--get-all', '--get-regexp', '--list', '-l'].includes(a)) ? undefined : no
190    default:
191      return no
192  }
193}
194
195function ghVerdict(args: string[]): string | undefined {
196  const [group, action] = args
197  const no = `gh ${group ?? ''} ${action ?? ''} may change something`.replace(/\s+/g, ' ').trim()
198
199  if (group === 'api') {
200    const rest = args.slice(1)
201    const method = rest.findIndex(a => a === '-X' || a === '--method')
202    const isGet = method === -1 || (rest[method + 1] ?? '').toUpperCase() === 'GET'
203    const hasBody = rest.some(a => /^(-f|-F|--field|--raw-field|--input)$/.test(a) || /^--(field|raw-field|input)=/.test(a))
204
205    return isGet && !hasBody ? undefined : no
206  }
207
208  return group !== undefined && action !== undefined && GH_READ[group]?.has(action) ? undefined : no
209}
210
211function commandVerdict(words: string[]): string | undefined {
212  let i = 0
213  // Environment assignments and wrappers that only run what follows.
214  while (i < words.length) {
215    const w = words[i] as string
216    if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) i += 1
217    else if (w === 'rtk' && words[i + 1] === 'proxy') i += 2
218    else if (w === 'rtk' || w === 'time' || w === 'command') i += 1
219    else break
220  }
221  const rest = words.slice(i)
222  const name = (rest[0] ?? '').split('/').pop() ?? ''
223  const args = rest.slice(1)
224
225  if (name === '') return undefined
226  if (args.length === 1 && args[0] === '--version') return undefined
227  if (READ_COMMANDS.has(name)) return undefined
228
229  switch (name) {
230    case 'find':
231      return args.some(a => /^-(delete|exec|execdir|ok|okdir|fprint|fprint0|fprintf|fls)$/.test(a))
232        ? 'find with -delete or -exec may change files'
233        : undefined
234    case 'sort':
235      return args.some(a => a === '-o' || a.startsWith('--output')) ? 'sort -o writes a file' : undefined
236    case 'sed':
237      return args.some(a => /^-[a-zA-Z]*i/.test(a) || a.startsWith('--in-place')) ? 'sed -i edits files in place' : undefined
238    case 'awk':
239      return args.some(a => /system\s*\(|print\s*>|\|\s*"/.test(a)) ? 'awk may run commands or write files' : undefined
240    case 'git':
241      return gitVerdict(args)
242    case 'gh':
243      return ghVerdict(args)
244    case 'npm':
245    case 'pnpm':
246    case 'yarn':
247      return args[0] !== undefined && PACKAGE_READ[name]?.has(args[0]) ? undefined : `${name} ${args[0] ?? ''} may change the project`.trim()
248    case 'defaults':
249      return args[0] === 'read' ? undefined : 'defaults may change settings'
250    case 'env': {
251      let j = 0
252      while (j < args.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(args[j] as string) || (args[j] as string).startsWith('-'))) j += 1
253      return commandVerdict(args.slice(j))
254    }
255    case 'xargs': {
256      let j = 0
257      while (j < args.length && (args[j] as string).startsWith('-')) {
258        j += /^-[nILPdEs]$/.test(args[j] as string) ? 2 : 1
259      }
260      return commandVerdict(args.slice(j))
261    }
262    default:
263      return `${name} is not on the read-only list`
264  }
265}
266
267// Undefined when the command line only reads; else why it may not run.
268export function bashVerdict(command: string): string | undefined {
269  if (/\$\(|`|<\(|>\(/.test(command)) return 'a command substitution may run anything'
270
271  // Redirects that write no file go first, so their `&` splits nothing.
272  const cleaned = command
273    .replace(/\d?>&\d/g, '')
274    .replace(/(&|\d)?>>?\s*\/dev\/null/g, '')
275  if (cleaned.includes('>')) return 'a redirect writes a file'
276
277  for (const words of commandsOf(cleaned)) {
278    const why = commandVerdict(words)
279    if (why !== undefined) return why
280  }
281
282  return undefined
283}
284
285// Undefined when the call only reads; else why ask mode refuses it.
286export function verdict(tool: string, input: Record<string, unknown>): string | undefined {
287  if (READ_TOOLS.has(tool)) return undefined
288  if (tool === 'Bash') return bashVerdict(typeof input.command === 'string' ? input.command : '')
289  if (tool.startsWith('mcp__')) return mcpVerdict(tool)
290
291  const actions = READ_ACTIONS[tool]
292  if (actions !== undefined) {
293    const action = typeof input.action === 'string' ? input.action : tool === 'Artifact' ? 'publish' : ''
294    return actions.has(action) ? undefined : `${tool} ${action} may change something`.trim()
295  }
296
297  return `${tool} may change something`
298}
299
types/index.d.ts 10 lines
1export type Level = 'off' | 'quick' | 'ask' | 'deep'
2
3declare module 'claude-code' {
4  interface PluginState {
5    'ask-mode': {
6      level: Level
7    }
8  }
9}
10