SLOPSHOPPER

ask-only

A prompt that starts with ? runs as a read-only turn: tools that change state are blocked

newguardstatusprompt
★ 1v0.1.0no licenseupdated 2026-10-04turanmahmudov/dotfiles/.claude/skills/ask-only
A shopper browsing a rack in a slop shop
README

Dotfiles

My personal dotfiles managed with YADM.

Tested on Ubuntu 25.10 and Ubuntu 25.04.

Installation

yadm clone <your-repo-url>
yadm config local.class work  # or personal
yadm config yadm.alt-copy true
yadm alt
yadm bootstrap

Important Keybindings

  • SUPER + Return - Terminal
  • SUPER + T - Terminal with tmux
  • SUPER + Space/D - Application launcher
Source 3 files
hooks/register.ts 37 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { isReadOnlyTool } from '../core/readonly'
5
6const ASK_PREFIX = /^\s*\?\s*(?=\S)/
7const isActive = atom({ plugin: 'ask-only', key: 'isActive' } as const, false)
8
9const CONTEXT = 'This is an ask-only turn. Answer the question only. Do not change files, run commands that change state, or call tools that write. The ask-only mod blocks such tool calls.'
10
11export const register: Register = on => {
12  on('prompt.submit', async ($, e, next) => {
13    if (!ASK_PREFIX.test(e.text)) return next(e)
14
15    await update($, isActive, () => true)
16    $.ui.status('ASK: read-only turn')
17
18    return next({ ...e, text: e.text.replace(ASK_PREFIX, ''), context: [...(e.context ?? []), CONTEXT] })
19  })
20
21  on('tool.call', async ($, e, next) => {
22    if (!(await read($, isActive))) return next(e)
23    if (isReadOnlyTool(e.tool, e as Record<string, unknown>)) return next(e)
24
25    return { deny: `ask-only: ${e.tool} is blocked in an ask-only turn. Answer without changing anything, or tell the user what you would run.` }
26  })
27
28  on('turn.complete', async ($, e, next) => {
29    if (e.agentId === undefined && (await read($, isActive))) {
30      await update($, isActive, () => false)
31      $.ui.status(undefined)
32    }
33
34    return next(e)
35  })
36}
37
core/readonly.ts 142 lines
1const READ_ONLY_TOOLS = new Set([
2  'Read', 'Glob', 'Grep', 'LSP', 'WebFetch', 'WebSearch', 'ToolSearch', 'TodoWrite', 'Skill', 'Agent',
3  'AskUserQuestion', 'ListMcpResourcesTool', 'ReadMcpResourceTool', 'ReadMcpResourceDirTool',
4  'ReadNotifications', 'TaskOutput', 'EnterPlanMode', 'ExitPlanMode',
5])
6
7const READ_ONLY_COMMANDS = new Set([
8  'ls', 'cat', 'head', 'tail', 'less', 'wc', 'grep', 'rg', 'egrep', 'fgrep', 'jq', 'yq', 'cut', 'sort', 'uniq',
9  'tr', 'column', 'diff', 'cmp', 'file', 'stat', 'du', 'df', 'pwd', 'echo', 'printf', 'which', 'whereis', 'type',
10  'date', 'env', 'printenv', 'id', 'whoami', 'uname', 'hostname', 'ps', 'pgrep', 'tree', 'basename', 'dirname',
11  'realpath', 'readlink', 'true', 'false', 'test', '[', 'cd', 'nl', 'fold', 'base64', 'sha256sum', 'md5sum', 'zcat',
12])
13
14const GIT_READ = new Set([
15  'status', 'log', 'diff', 'show', 'rev-parse', 'rev-list', 'ls-files', 'ls-remote', 'blame', 'fetch', 'describe',
16  'shortlog', 'grep', 'cat-file', 'merge-base', 'show-ref', 'for-each-ref', 'name-rev', 'whatchanged',
17])
18
19const GH_READ: Record<string, Set<string>> = {
20  pr: new Set(['view', 'list', 'checks', 'diff', 'status']),
21  issue: new Set(['view', 'list', 'status']),
22  run: new Set(['view', 'list', 'watch']),
23  repo: new Set(['view', 'list']),
24  release: new Set(['view', 'list']),
25  workflow: new Set(['view', 'list']),
26  search: new Set(['code', 'issues', 'prs', 'repos', 'commits']),
27  auth: new Set(['status']),
28}
29
30const MCP_READ_WORDS = new Set([
31  'get', 'list', 'search', 'read', 'query', 'describe', 'fetch', 'lookup', 'explain', 'validate', 'resolve', 'ask',
32  'info', 'view', 'show', 'find',
33])
34
35const MCP_WRITE_WORDS = new Set([
36  'create', 'update', 'delete', 'add', 'send', 'edit', 'transition', 'set', 'write', 'post', 'remove', 'schedule',
37  'plan', 'complete', 'authenticate', 'upload', 'move', 'merge', 'close', 'assign',
38])
39
40export function isReadOnlyTool(tool: string, input: Record<string, unknown>): boolean {
41  if (READ_ONLY_TOOLS.has(tool)) return true
42  if (tool === 'Bash') return typeof input.command === 'string' && isReadOnlyCommand(input.command)
43  if (tool.startsWith('mcp__')) return isReadOnlyMcpTool(tool)
44
45  return false
46}
47
48export function isReadOnlyMcpTool(tool: string): boolean {
49  const name = tool.split('__').pop() ?? ''
50  const words = name
51    .replace(/([a-z])([A-Z])/g, '$1_$2')
52    .toLowerCase()
53    .split(/[_-]+/)
54
55  return words.some(word => MCP_READ_WORDS.has(word)) && !words.some(word => MCP_WRITE_WORDS.has(word))
56}
57
58export function isReadOnlyCommand(command: string): boolean {
59  if (command.includes('`') || /<<|<\(|>\(/.test(command)) return false
60
61  const withoutSafeRedirects = command.replace(/\d?>&\d|\d?>\s*\/dev\/null/g, ' ')
62  if (withoutSafeRedirects.includes('>')) return false
63
64  const segments = withoutSafeRedirects
65    .replace(/\$\(/g, ';')
66    .replace(/\)/g, ';')
67    .split(/&&|\|\||[;|\n&]/)
68    .map(segment => segment.trim())
69    .filter(segment => segment !== '')
70
71  return segments.length > 0 && segments.every(isReadOnlySegment)
72}
73
74function isReadOnlySegment(segment: string): boolean {
75  const words = splitWords(segment)
76  while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] ?? '')) words.shift()
77
78  const [program = '', ...args] = words
79  const name = program.split('/').pop() ?? ''
80
81  if (READ_ONLY_COMMANDS.has(name)) return true
82  if (name === 'sed') return !args.some(arg => /^-[a-zA-Z]*i/.test(arg) || arg.startsWith('--in-place'))
83  if (name === 'awk') return !/system\s*\(|getline|print\s*>/.test(segment)
84  if (name === 'find') return !args.some(arg => /^-(delete|exec|execdir|ok|okdir|fprint|fprintf|fls)$/.test(arg))
85  if (name === 'xargs') return isReadOnlySegment(args.filter(arg => !arg.startsWith('-')).join(' '))
86  if (name === 'git') return isReadOnlyGit(args)
87  if (name === 'gh') return isReadOnlyGh(args)
88  if (name === 'docker') return isReadOnlyDocker(args)
89  if (name === 'kubectl') return ['get', 'describe', 'logs', 'top', 'explain', 'version', 'api-resources'].includes(args[0] ?? '')
90  if (name === 'aws') return args.some(arg => /^(describe|list|get)-/.test(arg) || arg === 'ls') && !args.includes('get-secret-value')
91  if (name === 'curl') return !args.some(arg => /^(-X|--request|-d|--data.*|-F|--form|-T|--upload-file|-o|--output|-O|--remote-name)$/.test(arg))
92  if (name === 'composer') return ['show', 'outdated', 'why', 'why-not', 'licenses', 'validate'].includes(args[0] ?? '')
93  if (name === 'npm') return ['ls', 'list', 'view', 'outdated', 'why'].includes(args[0] ?? '')
94  if (name === 'mise') return ['ls', 'list', 'current', 'which', 'where'].includes(args[0] ?? '')
95
96  return false
97}
98
99function isReadOnlyGit(args: string[]): boolean {
100  const rest = [...args]
101  while (rest[0] === '-C' || rest[0] === '-c') rest.splice(0, 2)
102  while ((rest[0] ?? '').startsWith('--')) rest.shift()
103  const [subcommand = '', ...options] = rest
104
105  if (GIT_READ.has(subcommand)) return true
106  if (subcommand === 'branch') return !options.some(option => /^(-[dDmMcC]|--delete|--move|--copy|--set-upstream-to|-u|--unset-upstream|--edit-description)/.test(option)) && options.every(option => option.startsWith('-'))
107  if (subcommand === 'remote') return options.length === 0 || options[0] === '-v' || options[0] === 'show' || options[0] === 'get-url'
108  if (subcommand === 'tag') return options.length === 0 || options.every(option => ['-l', '--list', '-n'].includes(option) || option.startsWith('--sort') || option.startsWith('--contains'))
109  if (subcommand === 'stash') return options[0] === 'list' || options[0] === 'show'
110  if (subcommand === 'config') return options.some(option => ['--get', '--get-all', '--list', '-l', '--get-regexp'].includes(option))
111  if (subcommand === 'worktree') return options[0] === 'list'
112  if (subcommand === 'reflog') return options.length === 0 || options[0] === 'show'
113
114  return false
115}
116
117function isReadOnlyGh(args: string[]): boolean {
118  const [group = '', action = ''] = args
119
120  if (group === 'api') {
121    const methodIndex = args.findIndex(arg => arg === '-X' || arg === '--method')
122    const method = methodIndex === -1 ? 'GET' : (args[methodIndex + 1] ?? '').toUpperCase()
123    const hasBody = args.some(arg => /^(-f|-F|--field|--raw-field|--input)$/.test(arg))
124
125    return method === 'GET' && !hasBody && !args.some(arg => arg.includes('graphql'))
126  }
127
128  return GH_READ[group]?.has(action) ?? false
129}
130
131function isReadOnlyDocker(args: string[]): boolean {
132  const [first = '', second = ''] = args
133  if (first === 'compose') return ['ps', 'logs', 'config', 'images', 'ls', 'top'].includes(second)
134
135  return ['ps', 'logs', 'inspect', 'images', 'version', 'info', 'top', 'port', 'history'].includes(first)
136    || (first === 'stats' && args.includes('--no-stream'))
137}
138
139function splitWords(segment: string): string[] {
140  return [...segment.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(match => match[1] ?? match[2] ?? match[3] ?? '')
141}
142
types/index.d.ts 8 lines
1export type AskOnlyMode = boolean
2
3declare module 'claude-code' {
4  interface PluginState {
5    'ask-only': { isActive: AskOnlyMode }
6  }
7}
8