SLOPSHOPPER

permissions

Shows the permission mode and picks the one new sessions start in (permissions.defaultMode). Does nothing until you pick something.

newpanecommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · permissions
│ ┃ Permissions ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Permissions │ ┃ The permission mode new sessions start in. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Running in ⏺ Update(src/auth.ts) │ ┃ unknown · shift+tab switches it ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ New sessions start in ⎿ 3 pass, 1 fail │ ┃ ● ask │ ┃ ○ plan ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ○ accept edits │ ┃ ○ auto ✻ Worked for 42s · done 4:20 PM │ ┃ ○ don't ask │ ┃ ○ bypass permissions › /permmode │ ┃ Project or local settings can override ⎿ permissions: Permission settings are open. │ ┃ this for a given folder. │ ┃ │ ┃ ↑↓ move · enter select · esc close │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Permissions
◆ Permissions The permission mode new sessions start in. Running in unknown · shift+tab switches it New sessions start in ● ask ○ plan ○ accept edits ○ auto ○ don't ask ○ bypass permissions Project or local settings can override this for a given folder. ↑↓ move · enter select · esc close
README

permissions

Pick the permission mode that new sessions start in (permissions.defaultMode in your user settings.json) from one small pane, or from /permmode. It does nothing until you pick something: installing it writes no file.

  • /permmode opens the pane: the six modes (ask, plan, accept edits, auto, don't ask, bypass permissions) with the current default marked.
  • /permmode <mode> sets the default directly (default, plan, acceptEdits, auto, dontAsk, bypassPermissions); any other argument prints the usage. bypassPermissions is the exception: it can only be picked in the pane, so a model calling the command cannot turn every check off.
  • The pane also has a "Running in" line. A mod cannot read the running session's mode, so it shows unknown; switch the running mode with shift+tab. The mod never changes the running session's mode.
  • bypassPermissions skips every permission check. While it is the default, the pane shows the warning "Bypass skips every permission check in every new session."

What it writes: only permissions.defaultMode; every other key of settings.json is kept. The first change saves the original file as settings.json.permissions.bak, and every default it replaces is appended to settings.json.permissions.prev.bak. An invalid settings.json is never overwritten (a toast says so). The file is $CLAUDE_CONFIG_DIR/settings.json, else ~/.claude/settings.json.

/permissions is a Claude Code built-in, hence the name /permmode.

Works on its own, or together with the optional menu mod: then the menu lists "Permissions" and shows this page in its pane.

Install

/plugin marketplace add danielmalka/malka-claude-mods
/plugin install permissions@malka-claude-mods

Uninstall

/plugin uninstall permissions@malka-claude-mods

The mode you picked stays in settings.json; to undo it, pick another with /permmode default (or edit permissions.defaultMode by hand). settings.json.permissions.bak is a last-resort copy of the file as it was before the first pick; restoring it drops every later change.

Development

Type declarations in .claude-plugin/types/ are generated by Claude Code when the mod loads (not committed); the mod's own state contract is types/index.d.ts, and types/menu.d.ts vendors the menu mod's contract (apiVersion 1). Then claude plugin test plugins/permissions.


Português

Escolha o modo de permissão em que novas sessões começam (permissions.defaultMode no settings.json do usuário) num painel pequeno ou com /permmode. Não faz nada até você escolher algo: instalar não grava arquivo nenhum.

  • /permmode abre o painel: os seis modos (ask, plan, accept edits, auto, don't ask, bypass permissions), com o padrão atual marcado.
  • /permmode <modo> define o padrão direto (default, plan, acceptEdits, auto, dontAsk, bypassPermissions); qualquer outro argumento mostra o uso. bypassPermissions é a exceção: só pode ser escolhido no painel, para que um modelo chamando o comando não desligue todas as verificações.
  • O painel tem também a linha "Running in". Um mod não consegue ler o modo da sessão em andamento, então mostra unknown; troque o modo atual com shift+tab. O mod nunca muda o modo da sessão em andamento.
  • bypassPermissions pula toda verificação de permissão. Enquanto ele for o padrão, o painel mostra o aviso "Bypass skips every permission check in every new session." (o texto é exibido em inglês, como os demais rótulos do painel).

O que grava: só permissions.defaultMode; as demais chaves do settings.json ficam intactas. A primeira alteração guarda o arquivo original em settings.json.permissions.bak, e cada padrão substituído é acrescentado a settings.json.permissions.prev.bak. Um settings.json inválido nunca é sobrescrito (um aviso informa). O arquivo é $CLAUDE_CONFIG_DIR/settings.json, senão ~/.claude/settings.json.

/permissions é um comando nativo do Claude Code, daí o nome /permmode.

Funciona sozinho ou com o mod opcional menu (que lista "Permissions" e mostra a página no seu painel).

Instalar: /plugin marketplace add danielmalka/malka-claude-mods e depois /plugin install permissions@malka-claude-mods. Desinstalar: /plugin uninstall permissions@malka-claude-mods. O modo escolhido continua no settings.json; para desfazer, escolha outro com /permmode default (ou edite permissions.defaultMode à mão). settings.json.permissions.bak é uma cópia de último recurso do arquivo como estava antes da primeira escolha; restaurá-la descarta todas as mudanças posteriores.

Source 5 files
hooks/register.tsx 129 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {} from '../types/menu'
5import type { MenuEntry } from '../types'
6import { isMode, MODES, modeLabel, type Mode } from './modes'
7import { BYPASS_WARNING, permissionsPage } from './permissions-page'
8import { configDir, currentDefault, parseSettings, why, withDefaultMode, type Settings } from './settings'
9
10const defaultModeAtom = atom({ plugin: 'permissions', key: 'defaultMode' } as const, 'default')
11// Foreign ref: the optional `menu` mod owns it (apiVersion 1); undefined = no menu.
12const menuPanel = { plugin: 'menu', key: 'panel' } as const
13const menuEntryRef = { plugin: 'permissions', key: 'menuEntry' } as const
14
15const PANE = 'permissions-settings'
16const MENU_PANE = 'mods-menu'
17
18// ~/.claude/settings.json ----------------------------------------------------------------
19// Parsed first, so an invalid file is never overwritten. The first change keeps the original text in
20// settings.json.permissions.bak; a defaultMode a pick supersedes is appended to settings.json.permissions.prev.bak.
21
22async function settingsPath($: EngineInterface) {
23  const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
24  return `${configDir(await $.env.get('CLAUDE_CONFIG_DIR'), home)}/settings.json`
25}
26
27// Two quick presses cannot interleave their file writes.
28let queue: Promise<unknown> = Promise.resolve()
29function serial<T>(job: () => Promise<T>): Promise<T> {
30  const result = queue.then(job)
31  queue = result.catch(() => undefined)
32  return result
33}
34
35async function writeDefaultMode($: EngineInterface, mode: Mode) {
36  const path = await settingsPath($)
37  const existed = await $.fs.exists(path)
38  const text = existed ? ((await $.fs.read(path)) as string) : '{}'
39  const settings: Settings = parseSettings(text)
40  const next = withDefaultMode(settings, mode)
41  const prev = currentDefault(settings)
42  if ((prev ?? 'default') === mode) return // already the default (an absent key means default): nothing to write, no backup
43  // the safe copies first, the settings.json write last: a failure never loses the user's value
44  if (prev !== undefined && prev !== mode) {
45    const prevPath = `${path}.permissions.prev.bak`
46    const old = (await $.fs.exists(prevPath)) ? parseSettings((await $.fs.read(prevPath)) as string, 'the permissions history file') : {}
47    const history: { defaultMode: string; at: string }[] = Array.isArray(old.history) ? old.history : []
48    history.push({ defaultMode: prev, at: new Date(await $.clock.now()).toISOString() })
49    await $.fs.write(prevPath, `${JSON.stringify({ history }, null, 2)}\n`)
50  }
51  const backup = `${path}.permissions.bak`
52  if (existed && !(await $.fs.exists(backup))) await $.fs.write(backup, text)
53  await $.fs.write(path, `${JSON.stringify(next, null, 2)}\n`)
54}
55
56// permissions.defaultMode: the mode new sessions start in. A running session's mode is shift+tab's; a mod cannot set it.
57async function setDefaultMode($: EngineInterface, mode: Mode) {
58  await serial(() => writeDefaultMode($, mode))
59  await update($, defaultModeAtom, () => mode)
60  await writeEntry($)
61  $.ui.toast(`New sessions start in ${modeLabel(mode)} mode${mode === 'bypassPermissions' ? `. ${BYPASS_WARNING}` : ''}`)
62}
63
64// Published for the optional menu; a failure here never blocks the pick itself.
65async function writeEntry($: EngineInterface) {
66  const entry: MenuEntry = { apiVersion: 1, title: 'Permissions', summary: `new sessions: ${modeLabel(await read($, defaultModeAtom))}`, order: 4 }
67  await $.state.set(menuEntryRef, entry).catch(() => undefined)
68}
69
70// The user's file value, the one the writes go to: always current. Pure (a render hook may call it).
71async function fileDefault($: EngineInterface): Promise<string> {
72  const path = await settingsPath($)
73  const settings = (await $.fs.exists(path)) ? parseSettings((await $.fs.read(path)) as string) : {}
74  return currentDefault(settings) ?? 'default'
75}
76
77// Into the atom (the menu entry reads it); when the file cannot be read the atom keeps its value.
78const loadDefault = ($: EngineInterface) => fileDefault($).then(value => update($, defaultModeAtom, () => value)).catch(() => undefined)
79
80async function page($: EngineInterface, e: any, hasBack: boolean) {
81  // the file may have changed since session.start (an edit, another session): re-read on every draw
82  const defaultMode = await fileDefault($).catch(() => read($, defaultModeAtom))
83  return permissionsPage($.ui.resolve(e), {
84    hasBack,
85    defaultMode,
86    onMode: mode => setDefaultMode($, mode).catch(error => $.ui.toast(`Could not set the default mode: ${why(error)}`)),
87    // the menu mod handles this press itself (ui.press on this plugin's key); the closure keeps the Button pressable
88    onBack: () => undefined,
89  })
90}
91
92export const register: Register = on => {
93  on('session.start', async ($, e, next) => {
94    // Nothing is written until the user picks: only the current value is read.
95    await loadDefault($)
96    await writeEntry($)
97    await $.command
98      .register({ name: 'permmode', description: 'Show the permission mode and pick the one new sessions start in', argumentHint: `[${MODES.join('|')}]` })
99      .catch(() => undefined)
100    return next(e)
101  })
102
103  on('command.run', { command: 'permmode' }, async ($, e) => {
104    const arg = e.args.trim()
105    if (arg !== '') {
106      if (!isMode(arg)) return { text: `Usage: /permmode [${MODES.join('|')}]` }
107      // The command may be invoked by the model: the mode that skips every check is a human pick, in the pane.
108      if (arg === 'bypassPermissions') return { text: 'bypassPermissions can only be picked in the pane; open it with /permmode.' }
109      try {
110        await setDefaultMode($, arg)
111        return { text: `New sessions start in ${modeLabel(arg)} mode.` }
112      } catch (error) {
113        return { text: `The default mode could not be changed: ${why(error)}` }
114      }
115    }
116    await $.ui.open({ id: PANE, title: 'Permissions', focus: true, closeOnEscape: true, rows: 17, columns: 56 })
117    return { text: 'Permission settings are open.' }
118  })
119
120  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => page($, e, false))
121
122  // Inside the menu's pane (apiVersion 1): drawn only when the menu is on this panel.
123  on('ui.render', { component: 'Pane', requestId: MENU_PANE }, async ($, e, next) => {
124    const { value } = await $.state.get(menuPanel)
125    if (value !== 'permissions') return next(e)
126    return page($, e, true)
127  })
128}
129
hooks/modes.ts 18 lines
1export const MODES = ['default', 'plan', 'acceptEdits', 'auto', 'dontAsk', 'bypassPermissions'] as const
2
3export type Mode = (typeof MODES)[number]
4
5export const isMode = (v: unknown): v is Mode => MODES.includes(v as Mode)
6
7export const MODE_LABELS: Record<Mode, string> = {
8  default: 'ask',
9  plan: 'plan',
10  acceptEdits: 'accept edits',
11  auto: 'auto',
12  dontAsk: "don't ask",
13  bypassPermissions: 'bypass permissions',
14}
15
16// A value in settings.json that is not one of the modes stays as written.
17export const modeLabel = (m: string): string => (isMode(m) ? MODE_LABELS[m] : m)
18
hooks/permissions-page.tsx 55 lines
1import { MODES, modeLabel, type Mode } from './modes'
2
3export const BYPASS_WARNING = 'Bypass skips every permission check in every new session.'
4
5type Ui = { Box: any; Text: any; Button: any }
6
7// The Permissions page: the running mode (shown only) and the mode new sessions start in, one item per row.
8export function permissionsPage(
9  { Box, Text, Button }: Ui,
10  p: {
11    hasBack: boolean
12    defaultMode: string
13    onMode: (mode: Mode) => unknown
14    onBack: () => unknown
15  },
16) {
17  const heading = (text: string) => (
18    <Box key={`h-${text}`} marginTop={1}>
19      <Text bold>{text}</Text>
20    </Box>
21  )
22  return (
23    <Box flexDirection="column" paddingX={1}>
24      <Box justifyContent="space-between">
25        <Text bold color="claude">
26          ◆ Permissions
27        </Text>
28        {p.hasBack && <Button key="mods-back" plain dimColor hotkey="b" label="← back" onPress={p.onBack} />}
29      </Box>
30      <Text dimColor>The permission mode new sessions start in.</Text>
31      {heading('Running in')}
32      {/* The engine gives mods no permission-mode event or getter, so the running mode is unknown. */}
33      <Text key="mode-running">
34        unknown
35        <Text dimColor> · shift+tab switches it</Text>
36      </Text>
37      {heading('New sessions start in')}
38      {MODES.map(m => (
39        <Box key={`row-default-mode-${m}`} gap={1}>
40          <Button key={`default-mode-${m}`} plain label={`${m === p.defaultMode ? '●' : '○'} ${modeLabel(m)}`} dimColor={m !== p.defaultMode} onPress={() => p.onMode(m)} />
41        </Box>
42      ))}
43      {p.defaultMode === 'bypassPermissions' && (
44        <Text key="mode-warning" color="warning">
45          {BYPASS_WARNING}
46        </Text>
47      )}
48      <Text dimColor>Project or local settings can override this for a given folder.</Text>
49      <Box marginTop={1}>
50        <Text dimColor>↑↓ move · enter select · esc close</Text>
51      </Box>
52    </Box>
53  )
54}
55
hooks/settings.ts 39 lines
1// ~/.claude/settings.json edits on a parsed object: other keys are kept; an invalid file throws before anything is written.
2export type Settings = Record<string, any>
3
4// An error whose message is fixed text of this mod: safe to show. Anything else is shown as a generic line (see `why`).
5export class SettingsError extends Error {}
6
7export const why = (error: unknown): string =>
8  error instanceof SettingsError ? error.message : 'the settings file could not be read or written; nothing was changed.'
9
10export function parseSettings(text: string, name = 'settings.json'): Settings {
11  let s: unknown
12  try {
13    s = JSON.parse(text)
14  } catch {
15    throw new SettingsError(`${name} is not valid JSON; nothing was written.`)
16  }
17  if (!s || typeof s !== 'object' || Array.isArray(s)) throw new SettingsError(`${name} is not a JSON object; nothing was written.`)
18  return s as Settings
19}
20
21const ABSOLUTE = /^(\/|[A-Za-z]:[\\/]|\\\\)/
22
23// CLAUDE_CONFIG_DIR when it is an absolute path, else <home>/.claude; never a relative path.
24export function configDir(configDirEnv: string | undefined, home: string | undefined): string {
25  const dir = configDirEnv && ABSOLUTE.test(configDirEnv) ? configDirEnv : home ? `${home}/.claude` : ''
26  if (!ABSOLUTE.test(dir)) throw new SettingsError('no absolute CLAUDE_CONFIG_DIR, HOME or USERPROFILE: settings.json not touched')
27  return dir.replaceAll('\\', '/').replace(/\/+$/, '')
28}
29
30export const currentDefault = (s: Settings): string | undefined =>
31  typeof s.permissions?.defaultMode === 'string' ? s.permissions.defaultMode : undefined
32
33// Only permissions.defaultMode changes; a non-object `permissions` is refused rather than overwritten.
34export function withDefaultMode(s: Settings, mode: string): Settings {
35  const p = s.permissions
36  if (p !== undefined && (p === null || typeof p !== 'object' || Array.isArray(p))) throw new SettingsError('settings.json "permissions" is not an object; nothing was written.')
37  return { ...s, permissions: { ...p, defaultMode: mode } }
38}
39
types/index.d.ts 13 lines
1// Menu contract, apiVersion 1: an optional `menu` mod lists this entry.
2export type MenuEntry = { apiVersion: 1; title: string; summary: string; order: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    permissions: {
7      menuEntry: MenuEntry
8      // permissions.defaultMode of the user's settings.json: the mode new sessions start in
9      defaultMode: string
10    }
11  }
12}
13