/readonly on turns the session into look-but-don't-touch: file edits are refused and only read-only shell commands (ls, cat, git status/log/diff, grep...) run…

Look but don't touch: explore a codebase with edits disabled.
/readonly on refuses every file edit and every shell command that is not clearly read-only (ls, cat, grep, git status/log/diff, find without -delete...). Redirects, command substitution and unknown commands are refused. Great for onboarding to unfamiliar code or inspecting a production checkout.
/plugin marketplace add cloudcruncher/claude-mod-garage
/plugin install read-only-mode@mod-garage
Run /reload-plugins (or restart Claude Code) afterwards. To try it from a local clone without installing: claude --plugin-dir ./dev-mods/read-only-mode
/readonly on to enable, /readonly off to disable, /readonly status to check.Illustrative output.
Enable
> /readonly on
Read-only mode ON: edits and mutating commands are refused.
Model tries to edit
Read-only mode is on (the user ran /readonly on). Do not edit files; describe the change instead.
Status line
🔒 read-only
hooks/register.ts 32 lines1import type { Register } from 'claude-code'
2import { EDIT_TOOLS, isReadOnlyCommand } from './lib'
3
4export const register: Register = on => {
5 let isOn = false
6
7 on('session.start', async ($, e, next) => {
8 await $.command.register({ name: 'readonly', description: 'Look-but-don\'t-touch mode: /readonly on | off | status', argumentHint: 'on|off|status' })
9 return next(e)
10 })
11
12 on('command.run', { command: 'readonly' }, ($, e) => {
13 const arg = (e.args ?? '').trim().toLowerCase()
14 if (arg === 'on') isOn = true
15 else if (arg === 'off') isOn = false
16 else if (arg !== 'status' && arg !== '') return { text: 'Usage: /readonly on | off | status' }
17 $.ui.status(isOn ? '🔒 read-only' : undefined)
18 return { text: isOn ? 'Read-only mode ON: edits and mutating commands are refused.' : 'Read-only mode OFF.' }
19 })
20
21 on('tool.call', ($, e, next) => {
22 if (!isOn) return next(e)
23 if (EDIT_TOOLS.includes(e.tool)) {
24 return { deny: 'Read-only mode is on (the user ran /readonly on). Do not edit files; describe the change instead.' }
25 }
26 if (e.tool === 'Bash' && !isReadOnlyCommand(String(e.command ?? ''))) {
27 return { deny: 'Read-only mode is on. Only read-only commands (ls, cat, grep, git status/log/diff...) may run. Ask the user to run /readonly off if this command is needed.' }
28 }
29 return next(e)
30 }).catch(($, e, next) => ({ deny: `read-only-mode failed (${next.error.kind}); call not run.` }))
31}
32hooks/lib.ts 33 lines1const READ_CMDS = new Set(['ls', 'cat', 'head', 'tail', 'wc', 'pwd', 'echo', 'grep', 'rg', 'which', 'file', 'stat', 'du', 'df', 'tree', 'diff', 'sort', 'uniq', 'cut', 'date', 'whoami', 'env', 'printenv', 'basename', 'dirname', 'realpath'])
2const GIT_READ = new Set(['status', 'log', 'diff', 'show', 'branch', 'remote', 'blame', 'ls-files', 'rev-parse', 'describe', 'shortlog', 'tag', 'config', 'stash'])
3const GIT_MUTATING_FLAGS = /(^|\s)(-d|-D|-m|-M|--delete|--set|--unset|--add|drop|pop|apply|clear)(\s|$)/
4
5/** True only when every segment of the command is a known read-only command with no redirect. */
6export function isReadOnlyCommand(command: string): boolean {
7 if (/[>`]|\$\(|<\(/.test(command)) return false
8 const segments = command.split(/&&|\|\||;|\|/).map(s => s.trim()).filter(Boolean)
9 if (segments.length === 0) return false
10 return segments.every(seg => {
11 const words = seg.split(/\s+/)
12 const cmd = words[0]
13 if (cmd === 'git') {
14 const sub = words.slice(1).find(w => !w.startsWith('-'))
15 if (!sub || !GIT_READ.has(sub)) return false
16 if (['branch', 'tag', 'config', 'stash', 'remote'].includes(sub)) {
17 const rest = words.slice(words.indexOf(sub) + 1).join(' ')
18 // listing is fine; creating, deleting or setting is not
19 if (sub === 'stash') return /^(list|show)\b/.test(rest)
20 if (sub === 'config') return /^(--get|--list|-l)\b/.test(rest)
21 if (sub === 'remote') return rest === '' || /^(-v|show)\b/.test(rest)
22 return rest === '' || !(GIT_MUTATING_FLAGS.test(rest) || /^[^-]/.test(rest)) || /^(-a|-r|-v|--list|--show-current)\b/.test(rest)
23 }
24 return true
25 }
26 if (cmd === 'find') return !/(^|\s)(-delete|-exec|-execdir|-ok)\b/.test(seg)
27 if (cmd === 'sed') return !/\s-i/.test(seg)
28 return READ_CMDS.has(cmd)
29 })
30}
31
32export const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
33