SLOPSHOPPER

answer

/answer: answer the questions in Claude's last reply in Claude Code's native question dialog

newguardcommandstatusmodeltimer
v0.3.2MITupdated 2026-10-03PeteChu/cc-answer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · answer
› fix the failing auth test and add an audit log call ● answer: extraction reply from haiku: OK ⏺ 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 › /answer ⎿ answer: haiku did not return the questions as JSON. It replied: "OK" (2 chars) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

CC-Answer

Claude forgot the question dialog? Open one yourself.

Sometimes Claude asks questions in plain prose instead of opening its built-in question dialog. Run /answer to pick options or write your own responses, review them, and send everything back in one organized reply.

A Claude Code mod to answer every question in Claude Code's native question dialog, all at once, instead of retyping them in the prompt.

Get started

Install from the marketplace, inside Claude Code:

/plugin marketplace add PeteChu/cc-answer
/plugin install answer@cc-answer
/reload-plugins

A mod runs inside Claude Code with the same access Claude Code has. Read the code before installing.

Or load a local clone directly:

git clone https://github.com/PeteChu/cc-answer
claude --plugin-dir ./cc-answer

For persistent loading, set CLAUDE_CODE_PLUGIN_DIRS=/path/to/cc-answer in your environment or in the env block of ~/.claude/settings.json.

How it works

  1. Extraction: /answer takes Claude's latest reply that has text. A small model (haiku by default) pulls out the questions, each with a short header, 2–4 options with one-line descriptions, and single or multi-select. Open-ended questions get a few suggested answers.
  2. Answering: the questions open in Claude Code's own AskUserQuestion dialog, the same one Claude uses to ask you something, right above the prompt. One tab per question; ↑/↓ and Enter to pick, Tab/←/→ to switch questions, checkboxes for multi-select, Type something to answer in your own words, a review screen before Submit answers, and Esc to cancel. The dialog holds up to 4 questions, so a fifth and later ones come in another round.
  3. Submit: the answers go to Claude as one prompt that starts with I answered your questions in the following way:, followed by Q:/A: pairs. Unanswered questions are left out.

Extraction uses haiku by default. Change the model option in /config, or set pluginConfigs.answer.options.model in settings to another model alias or ID.

Prompts sent by the mod

  • Extraction model call ($.model.complete): the prompt is the full text of the latest non-empty assistant reply, unchanged. The system prompt is SYSTEM_PROMPT in hooks/lib.mjs: it instructs the model to extract user-facing questions in order as JSON, include essential context, use headers of at most 12 characters, provide 2–4 concise options with descriptions (Yes/No for confirmations and suggestions for open-ended questions), omit an "Other" option, mark multi-select only for compatible choices, and return an empty questions array when none are found. No other conversation messages, files, or tool results are included by the mod.
  • Retry: if the first reply cannot be parsed, the mod sends the same assistant text and system prompt once more, appending: Your reply must be exactly one JSON object and nothing else: no prose, no code fences, no commentary. Escape quotes and newlines inside strings.
  • Conversation submission ($.prompt.submit): after all dialog rounds complete, the mod submits a new user message (asUser: true) beginning exactly with I answered your questions in the following way:, then a blank line and Q: <extracted question> / A: <your answer> blocks separated by blank lines. Questions are model-extracted and may be rephrased; answers are selected option labels or your custom text, with whitespace normalized. Multiple selections are joined with ,. Unanswered questions are omitted. Cancellation or no answers means no prompt is submitted. No additional instructions are appended.

With --debug, the command output also contains the extraction model name, raw model replies, and parsed questions/options; Claude can read that output. Extraction failures may include a snippet of the model reply even without --debug.

Intentional tool-input changes

The mod changes only AskUserQuestion, through its tool.call hook. /answer calls $.ui.ask with the placeholder Answer Claude's questions?, header Answer, and options Answer / Cancel. While a round is pending, the hook matches that placeholder as the first question and calls next({ ...e, questions }), replacing the entire questions array with up to four extracted questions. Each has question, header, options (labels and descriptions), and multiSelect; all other event fields are preserved. This lets the native dialog display the extracted questions instead of the placeholder. The hook reads the returned answers or cancellation and returns the tool result unchanged.

If no round is pending or the first question does not match the placeholder, the hook calls next(e) unchanged. Ordinary Claude-generated AskUserQuestion calls pass through under that rule; all other tools are outside the hook's filter and are not modified.

Custom text entry is available on terminal and desktop surfaces. On mobile, answers are limited to the extracted options.

Example reply

I answered your questions in the following way:

Q: Which database should we use?
A: PostgreSQL

Q: What should the service be called?
A: billing-api

Troubleshooting: /answer --debug

/answer --debug ends with an extraction report: the model used, its raw reply (and the retry's, when the first one did not parse), and the questions it parsed with their options. It shows straight away when extraction fails, or after the dialog closes. The report is part of the command's output, so Claude can read it too.

Extraction tolerates the usual model slips: prose or code fences around the JSON, trailing commas, raw newlines in strings, smart quotes, and a reply cut off partway (the questions that arrived whole are kept). If the reply still can't be read, /answer retries once with a stricter instruction and more room. If that fails too, it says what the model replied.

Development

claude plugin validate .   # Check the manifest and hooks module
claude plugin test .       # Run unit and end-to-end tests

Layout:

  • hooks/register.mjs: the /answer command (extraction, one retry, the --debug report) and the tool.call hook that fills the native dialog
  • hooks/lib.mjs: the extraction prompt, JSON repair and recovery from replies cut off partway, fitting questions to the dialog's limits (header ≤ 12 chars, 2–4 options, ≤ 4 questions per round), and compiling the answers
  • tests/answer.test.ts: tests, with the model and the dialog faked beneath the plugin: JSON repairs, rounds of four, the submitted prompt, Esc and "Chat about this", the retry, and the --debug report

License

MIT. See LICENSE.

Source 2 files
hooks/register.mjs 137 lines
1import { SUBMIT_PREFIX, SYSTEM_PROMPT, answerFor, batches, compileAnswers, parseExtraction } from './lib.mjs'
2
3/**
4 * `$.ui.ask` opens the engine's own AskUserQuestion dialog but takes a single
5 * question of bare labels. This plugin's `tool.call` hook sees that call and
6 * swaps in a whole round of extracted questions, so the native dialog draws
7 * them as tabs with descriptions, multi-select, "Type something" and its own
8 * review screen. The placeholder question is how the hook knows the call is ours.
9 */
10const PLACEHOLDER = "Answer Claude's questions?"
11
12const LIMITS = { maxTokens: 8192, effort: 'low', timeoutMs: 120_000 }
13const STRICT =
14  'Your reply must be exactly one JSON object and nothing else: no prose, no code fences, no commentary. Escape quotes and newlines inside strings.'
15
16/** The start of a reply, on one line, for an error message. */
17const snippet = text => {
18  const line = text.replace(/\s+/g, ' ').trim()
19  return line ? `"${line.length > 160 ? `${line.slice(0, 160)}…` : line}" (${text.length} chars)` : '(empty)'
20}
21
22/** What `/answer --debug` prints before the dialog: the model, its raw reply per attempt, and what was parsed. */
23function debugReport(model, input, first, retry, questions) {
24  const raw = r => (r.isAnswered ? `${r.text.length} chars\n${r.text}` : `no reply (${r.reason})`)
25  const lines = [
26    `answer --debug · model ${model} · last reply ${input.length} chars`,
27    '',
28    '── raw reply ──',
29    raw(first),
30  ]
31  if (retry !== first) lines.push('', '── retry (first reply did not parse) ──', raw(retry))
32  lines.push('', '── parsed ──')
33  if (questions === null) lines.push('nothing: no "questions" array could be read')
34  else if (questions.length === 0) lines.push('no questions')
35  for (const [i, q] of (questions ?? []).entries()) {
36    lines.push(`${i + 1}. [${q.header}]${q.multiSelect ? ' (multi-select)' : ''} ${q.question}`)
37    for (const o of q.options) lines.push(`     - ${o.label}${o.description ? ` — ${o.description}` : ''}`)
38  }
39  return lines.join('\n')
40}
41
42/** @type {import('./lib.mjs').AskQuestion[] | null} */
43let round = null
44/** @type {{ answers: unknown } | { cancel: 'dismissed' | 'clarify' } | null} */
45let outcome = null
46
47/** @type {import('claude-code').Register} */
48export const register = (on, options) => {
49  const model = typeof options.model === 'string' && options.model.trim() ? options.model.trim() : 'haiku'
50
51  on('session.start', async ($, e, next) => {
52    const r = await next(e)
53    await $.command.register({
54      name: 'answer',
55      description: "Answer the questions in Claude's last reply in the question dialog (--debug shows the extraction)",
56    })
57    return r
58  })
59
60  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
61    const questions = round
62    // Claude's own questions, and anything else not opened by /answer, pass untouched.
63    if (!questions || e.questions[0]?.question !== PLACEHOLDER) return next(e)
64    round = null
65    const ran = await next({ ...e, questions })
66    const result = ran.result
67    if (!ran.isError && result && typeof result === 'object' && 'answers' in result) {
68      outcome = { answers: result.answers }
69    } else {
70      // Esc rejects the call; "Chat about this" rejects it asking to clarify.
71      outcome = { cancel: /clarify/i.test(ran.text ?? '') ? 'clarify' : 'dismissed' }
72    }
73    return ran
74  })
75
76  on('command.run', { command: 'answer' }, async ($, e) => {
77    const isDebug = /(^|\s)--debug(\s|$)/.test(e.args)
78    const messages = await $.session.messages()
79    const last = [...messages].reverse().find(m => m.role === 'assistant' && m.text.trim().length > 0)
80    if (!last) return { text: 'no assistant reply with text to answer yet.' }
81
82    $.ui.status(`extracting questions with ${model}…`)
83    const first = await $.model.complete({ model, system: SYSTEM_PROMPT, prompt: last.text, ...LIMITS })
84    let questions = first.isAnswered ? parseExtraction(first.text) : null
85    let reply = first
86    if (first.isAnswered && questions === null) {
87      // One retry with a stricter instruction and more room: the usual failure is prose or a reply cut short.
88      reply = await $.model.complete({
89        model,
90        system: `${SYSTEM_PROMPT}\n\n${STRICT}`,
91        prompt: last.text,
92        ...LIMITS,
93        maxTokens: 16_000,
94      })
95      questions = reply.isAnswered ? parseExtraction(reply.text) : null
96    }
97    $.ui.status(undefined)
98    // With --debug, every way out of the command ends with the extraction report.
99    const report = isDebug ? debugReport(model, last.text, first, reply, questions) : ''
100    const done = text => {
101      const out = [text, report].filter(Boolean).join('\n\n')
102      return out ? { text: out } : {}
103    }
104    if (!reply.isAnswered) return done(`extraction with ${model} failed (${reply.reason}).`)
105    if (questions === null) {
106      $.ui.log(`extraction reply from ${model}:\n${reply.text}`, { to: 'debug' })
107      return done(`${model} did not return the questions as JSON. It replied: ${snippet(reply.text)}`)
108    }
109    if (questions.length === 0) return done('no questions found in the last reply.')
110
111    // The dialog holds up to four questions; more come as further rounds.
112    const answers = []
113    for (const batch of batches(questions)) {
114      round = batch
115      outcome = null
116      try {
117        await $.ui.ask(PLACEHOLDER, { options: ['Answer', 'Cancel'], header: 'Answer' })
118      } catch {
119        // The placeholder itself is never answered, so the ask rejects; the hook kept the outcome.
120      }
121      round = null
122      // Set by the tool.call hook while the ask ran.
123      const got = outcome
124      if (!got || 'cancel' in got) {
125        return done(got?.cancel === 'clarify' ? 'cancelled. Tell Claude what you would like to clarify.' : 'cancelled.')
126      }
127      for (const q of batch) answers.push(answerFor(got.answers, q.question))
128    }
129
130    const text = compileAnswers(questions, answers)
131    if (!text) return done('no answers provided.')
132    // Submitting waits for the session to be idle, which this command holds: submit once it returns.
133    $.clock.after(0, () => void $.prompt.submit({ text: `${SUBMIT_PREFIX}\n\n${text}`, asUser: true }))
134    return done()
135  })
136}
137
hooks/lib.mjs 232 lines
1/**
2 * One question in the shape the built-in AskUserQuestion dialog draws.
3 * @typedef {{ question: string, header: string, options: { label: string, description: string }[], multiSelect: boolean }} AskQuestion
4 */
5
6export const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract every question that needs an answer from the user, shaped for a multiple-choice dialog.
7
8Output ONLY a JSON object with this structure, no prose:
9{
10  "questions": [
11    {
12      "question": "Which database should the service use?",
13      "header": "Database",
14      "multiSelect": false,
15      "options": [
16        { "label": "PostgreSQL", "description": "Mature relational option with a strong ecosystem" },
17        { "label": "SQLite", "description": "Embedded, zero setup" }
18      ]
19    }
20  ]
21}
22
23Rules:
24- Extract all questions that require user input, in the order they appeared
25- "question" is clear, self-contained and ends with a question mark; fold in any context essential to answer it
26- "header" is a very short chip label, at most 12 characters (e.g. "Database", "Auth method")
27- Give 2 to 4 options per question; the dialog always adds its own free-text "Type something" row, so never add an "Other" option
28- Extract every concrete choice stated or clearly implied by the text; for yes/no, confirmation or permission questions use "Yes" and "No"
29- For open-ended questions (a name, a description), offer 2 to 4 sensible suggestions the text supports
30- Option labels are concise (1-5 words) and fully answer the question on their own; each description is one short sentence
31- Set "multiSelect": true only when the choices are not mutually exclusive
32- If no questions are found, return {"questions": []}`
33
34export const SUBMIT_PREFIX = 'I answered your questions in the following way:'
35
36/** The dialog's limits, which it enforces: questions per dialog, options per question, header length. */
37export const MAX_QUESTIONS = 4
38const MAX_OPTIONS = 4
39const MIN_OPTIONS = 2
40const MAX_HEADER = 12
41
42/** Pads a question that came back with fewer than two options; "Type something" still takes free text. */
43const PADDING = [
44  { label: 'No preference', description: 'Go with whatever you think is best' },
45  { label: 'Not sure yet', description: 'I need more information before deciding' },
46]
47
48/** @returns {string} */
49function str(value) {
50  return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''
51}
52
53/**
54 * Coerces the extractor's output into questions the dialog accepts, with unique texts.
55 * @returns {AskQuestion[]}
56 */
57export function normalizeQuestions(raw) {
58  if (!Array.isArray(raw)) return []
59  const out = []
60  for (const q of raw) {
61    if (!q || typeof q !== 'object') continue
62    let question = str(q.question)
63    if (!question) continue
64    // The dialog answers by question text, so texts must be unique.
65    while (out.some(one => one.question === question)) question = `${question} (${out.length + 1})`
66
67    const options = []
68    if (Array.isArray(q.options)) {
69      for (const opt of q.options) {
70        const record = opt && typeof opt === 'object' ? opt : {}
71        const label = typeof opt === 'string' ? str(opt) : str(record.label)
72        if (!label || /^(other|type something)\b/i.test(label) || options.some(o => o.label === label)) continue
73        options.push({ label, description: str(record.description) })
74        if (options.length === MAX_OPTIONS) break
75      }
76    }
77    for (const pad of PADDING) {
78      if (options.length >= MIN_OPTIONS) break
79      if (!options.some(o => o.label === pad.label)) options.push(pad)
80    }
81
82    const header = (str(q.header) || `Q${out.length + 1}`).slice(0, MAX_HEADER).trim()
83    out.push({ question, header, options, multiSelect: q.multiSelect === true })
84  }
85  return out
86}
87
88/**
89 * Repairs what models commonly get wrong in JSON: raw newlines and tabs inside
90 * strings, smart quotes used as delimiters, and trailing commas.
91 * @param {string} text
92 */
93export function repairJson(text) {
94  let out = ''
95  let inString = false
96  // A string a smart quote opened is closed by a smart quote too.
97  let isSmart = false
98  for (let i = 0; i < text.length; i++) {
99    const ch = text[i]
100    if (inString) {
101      if (ch === '\\') {
102        out += ch + (text[i + 1] ?? '')
103        i++
104      } else if (ch === '"' || (isSmart && (ch === '“' || ch === '”'))) {
105        inString = false
106        out += '"'
107      } else if (ch === '"' || ch === '“' || ch === '”') out += ch
108      else if (ch === '\n') out += '\\n'
109      else if (ch === '\r') out += '\\r'
110      else if (ch === '\t') out += '\\t'
111      else out += ch
112      continue
113    }
114    if (ch === '"' || ch === '“' || ch === '”') {
115      inString = true
116      isSmart = ch !== '"'
117      out += '"'
118    } else if (ch === ',') {
119      // Drop a comma that only precedes a closing bracket.
120      const rest = text.slice(i + 1).match(/^\s*([}\]])/)
121      if (!rest) out += ch
122    } else out += ch
123  }
124  return out
125}
126
127/** The end (exclusive) of the balanced {...} or [...] starting at `start`, or -1 when it never closes. */
128function balancedEnd(text, start) {
129  let depth = 0
130  let inString = false
131  for (let i = start; i < text.length; i++) {
132    const ch = text[i]
133    if (inString) {
134      if (ch === '\\') i++
135      else if (ch === '"') inString = false
136    } else if (ch === '"') inString = true
137    else if (ch === '{' || ch === '[') depth++
138    else if (ch === '}' || ch === ']') {
139      depth--
140      if (depth === 0) return i + 1
141    }
142  }
143  return -1
144}
145
146function parseJson(text) {
147  try {
148    return JSON.parse(text)
149  } catch {
150    return undefined
151  }
152}
153
154/**
155 * Reads the extractor's reply into raw question objects: the whole reply, the
156 * first balanced object holding "questions", and failing those, each complete
157 * element of the "questions" array, so a reply cut off mid-way keeps the
158 * questions that did arrive whole. Null when no "questions" array is found.
159 * @param {string} reply
160 * @returns {unknown[] | null}
161 */
162export function readQuestions(reply) {
163  const text = repairJson(reply.replace(/```(?:json)?/g, ''))
164  const whole = parseJson(text.trim())
165  if (whole && Array.isArray(whole.questions)) return whole.questions
166
167  for (let at = text.indexOf('{'); at >= 0; at = text.indexOf('{', at + 1)) {
168    const end = balancedEnd(text, at)
169    if (end < 0) break
170    const found = parseJson(text.slice(at, end))
171    if (found && Array.isArray(found.questions)) return found.questions
172  }
173
174  const key = text.search(/"questions"\s*:\s*\[/)
175  if (key < 0) return null
176  const items = []
177  let at = text.indexOf('[', key) + 1
178  for (;;) {
179    const open = text.indexOf('{', at)
180    if (open < 0) break
181    const end = balancedEnd(text, open)
182    if (end < 0) break
183    const item = parseJson(text.slice(open, end))
184    if (item !== undefined) items.push(item)
185    at = end
186  }
187  return items
188}
189
190/**
191 * The extractor's reply as dialog-ready questions, or null when it holds no "questions" array.
192 * @param {string} text
193 * @returns {AskQuestion[] | null}
194 */
195export function parseExtraction(text) {
196  const raw = readQuestions(text)
197  return raw === null ? null : normalizeQuestions(raw)
198}
199
200/**
201 * Splits questions into dialogs of at most four.
202 * @template T
203 * @param {readonly T[]} items
204 * @returns {T[][]}
205 */
206export function batches(items, size = MAX_QUESTIONS) {
207  const out = []
208  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
209  return out
210}
211
212/** Reads one answer from the dialog's `answers` map (question text -> answer). */
213export function answerFor(answers, question) {
214  if (!answers || typeof answers !== 'object') return ''
215  const value = answers[question]
216  if (Array.isArray(value)) return value.map(str).filter(Boolean).join(', ')
217  return str(value)
218}
219
220/**
221 * Q/A blocks for every answered question; unanswered ones are left out.
222 * @param {readonly AskQuestion[]} questions
223 * @param {readonly string[]} answers
224 */
225export function compileAnswers(questions, answers) {
226  return questions
227    .map((q, i) => ({ q, a: (answers[i] ?? '').trim() }))
228    .filter(({ a }) => a.length > 0)
229    .map(({ q, a }) => `Q: ${q.question}\nA: ${a}`)
230    .join('\n\n')
231}
232