SLOPSHOPPER

stay-put

Bounces `cd dir && …` before it runs and gives Claude the one-command form.

newpanebandguardcommandprompt
v0.1.0MITupdated 2026-10-04ivanvyd/ground-rules/plugins/stay-put
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stay-put
│ ┃ stay-put ✕ › fix the failing auth test and add an audit log call │ ┃ This session: caught 0, fixed 0 │ ┃ All sessions: caught 0, fixed 0 ⏺ Read(src/auth.ts) │ ┃ Mode: teach ⎿ Read 6 lines │ ┃ [ teach ][ watch ][ off ] ⏺ Update(src/auth.ts) │ ┃ teach bounces a chain, watch only counts it, ⎿ Added 2 lines, removed 1 line │ ┃ off does nothing. ⏺ 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 │ │ › /stay-put │ ⎿ stay-put: stay-put pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · stay-put
This session: caught 0, fixed 0 All sessions: caught 0, fixed 0 Mode: teach [ teach ][ watch ][ off ] teach bounces a chain, watch only counts it, off does nothing.
README

stay-put

Bounces cd dir && … before it runs and gives Claude the one-command form.

cd api && git log --oneline -3
  refused: stay-put: run it without the directory change: git -C api log --oneline -3
git -C api log --oneline -3
  runs, no permission dialog

Agents change directory out of habit. In one month of real use, 51.8% of one person's shell commands began with cd: 19,691 of 38,035 (Bash 36,219 and PowerShell 1,816). A chained cd also takes a command out from under the rules that match plain commands, and Claude Code asks about it more often.

Install

claude plugin install stay-put@ground-rules

Needs Claude Code 2.1.287 or later. See the repository README for updating and uninstalling.

What it does

  • Describes. tool.describe adds one fixed sentence to the Bash and PowerShell tool descriptions: don't chain a directory change, use absolute paths or the tool's own directory flag. The text never varies, so it stays cache-stable. In one run each with Claude Haiku, a task that produced 8 of 8 cd-first calls without the mod produced 0 of 12 with it. That is one sample, not a rate.
  • Bounces. tool.check denies a call that starts with a static directory change followed by a command and tells Claude the one-command form. It returns the decision it was given or deny, never allow.
  • Counts. classic.PostToolUse counts the next shell call that ran without the directory change as fixed. A band row shows stay-put · 1 caught · 1 fixed until your next prompt.

What counts as a chain

A leading cd, pushd or chdir in Bash, or cd, sl, Set-Location [-Path|-LiteralPath], Push-Location or chdir in PowerShell, with a target the shell does not expand, followed by &&, ; or a newline and another command. Also ( cd x && … ) and … && cd x && ….

Left alone: a bare cd x, cd, cd -, targets with $, a backtick or $(, anything inside quotes, heredocs, here-strings or comments, and cd x || ….

The suggestion

Command after the cdSuggested form
pnpm, npm, yarn, makepnpm --dir <dir>, npm --prefix <dir>, yarn --cwd <dir>, make -C <dir>
`dotnet build\test\restore\clean\publish\pack`dotnet <verb> <dir>
dotnet rundotnet run --project <dir>
python, python3, py, node with a relative scriptthe script path joined to the directory
git <subcommand>git -C <dir> <subcommand>
anything else"use paths relative to the project directory, or absolute paths with forward slashes"

A suggested form can be gated differently from the plain command: Bash(git push *) does not match git -C . push. So before suggesting anything except a read-only git command (status, log, diff, show, rev-parse, ls-files, grep, blame, branch --show-current), stay-put asks Claude Code for the permission decision on both forms. It suggests the form only when that decision is at least as strict as the plain command's. Otherwise it gives the generic advice.

Your spelling of the path is kept: D:\w, D:/w, /d/w, "D:\a b" and \\srv\share all pass through unchanged. In auto mode it denies and never asks, because an ask would go to the auto-mode classifier rather than to you.

Modes

/stay-put teach|watch|off, or the same buttons in /stay-put.

ModeBehaviour
teach (default)Describe and bounce.
watchDescribe and count. Nothing is denied.
offNothing.

The mode setting in the plugin's options sets the default. A mode chosen with the command wins over it.

What it reads, runs and stores

Hookssession.start, command.run, tool.describe, tool.check, classic.PostToolUse, prompt.submit, and ui.render for the band and the pane.
ReadsThe command text of Bash and PowerShell calls.
RunsNothing. It asks Claude Code for permission decisions with $.tool.check.
Storesmode, and one n:<session> key per session with two counters.
Nevertool.call, $.process, $.http, $.model, the session directory.

reach.json pins the exact hooks and calls.

Limits

  • It judges the command text alone. tool.check carries no directory, so a worktree subagent gets the same verdict as the main thread.
  • A command assembled by the shell (cd "$REPO" && make) is not recognised.
  • On a bounce, Claude spends one extra request. The description note is there to prevent most bounces.
  • It is a convenience, not a guard. It fails open.
Source 7 files
hooks/register.tsx 169 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Mode } from '../types'
5import { findCdChain } from './chain'
6import { addCount, sumCounts } from './counts'
7import { suggest } from './suggest'
8import {
9  DESCRIPTION_NOTE,
10  bandText,
11  bounceReason,
12  commandOf,
13  isAsGated,
14  parseMode,
15  shellOf,
16} from './verdict'
17
18const PANE = 'stay-put'
19const mode = atom({ plugin: 'stay-put', key: 'mode' } as const, 'teach')
20const caught = atom({ plugin: 'stay-put', key: 'caught' } as const, 0)
21const fixed = atom({ plugin: 'stay-put', key: 'fixed' } as const, 0)
22const isAwaitingFix = atom({ plugin: 'stay-put', key: 'isAwaitingFix' } as const, false)
23const isRecent = atom({ plugin: 'stay-put', key: 'isRecent' } as const, false)
24
25export const register: Register = (on, options) => {
26  const configured = parseMode(String(options.mode ?? '')) ?? 'teach'
27
28  on('session.start', async ($, e, next) => {
29    const stored = parseMode(String(await $.store.get('mode')))
30    await update($, mode, () => stored ?? configured)
31    await $.command.register({
32      name: 'stay-put',
33      description: 'Show the stay-put pane, or set its mode',
34      argumentHint: 'teach|watch|off',
35      immediate: true,
36    })
37
38    return next(e)
39  })
40
41  on('command.run', { command: 'stay-put' }, async ($, e) => {
42    const argument = e.args.trim()
43    if (argument === '') {
44      await $.ui.open({ id: PANE, title: 'stay-put' })
45      return { text: 'stay-put pane opened.' }
46    }
47
48    const chosen = parseMode(argument)
49    if (!chosen) return { text: `stay-put: "${argument}" is not a mode. Use teach, watch or off.` }
50
51    await $.store.set('mode', chosen)
52    await update($, mode, () => chosen)
53    $.ui.invalidate('tool.describe')
54    return { text: `stay-put: mode is now ${chosen}.` }
55  })
56
57  on('tool.describe', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
58    const described = await next(e)
59
60    return (await read($, mode)) === 'off'
61      ? described
62      : { ...described, description: `${described.description} ${DESCRIPTION_NOTE}` }
63  })
64
65  on('tool.check', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
66    const decision = await next(e)
67    const current = await read($, mode)
68    const command = commandOf(e.input)
69    const shell = shellOf(e.tool)
70
71    if (current === 'off' || decision.decision === 'deny' || command === undefined || !shell) {
72      return decision
73    }
74
75    const chain = findCdChain(command, shell)
76    // Only the engine's own call is a bounce; another mod may merely be asking.
77    if (!chain || next.origin.plugin !== 'engine') return decision
78
79    const key = `n:${await $.session.id()}`
80    await $.store.set(key, addCount(await $.store.get(key), { caught: 1 }))
81    await update($, caught, n => n + 1)
82    if (current === 'watch') return decision
83
84    let suggestion = suggest(chain, shell)
85    if (suggestion.command !== undefined && suggestion.needsCheck) {
86      // The suggested form must never be gated more loosely than the plain command.
87      const [suggested, plain] = await Promise.all([
88        $.tool.check({ tool: e.tool, input: { command: suggestion.command } }),
89        $.tool.check({ tool: e.tool, input: { command: chain.rest } }),
90      ])
91      if (!isAsGated(suggested.decision, plain.decision)) {
92        suggestion = { command: undefined, needsCheck: false }
93      }
94    }
95
96    await update($, isAwaitingFix, () => true)
97    await update($, isRecent, () => true)
98    return { decision: 'deny', reason: bounceReason(suggestion, chain) }
99  })
100
101  on('classic.PostToolUse', async ($, e, next) => {
102    const command = commandOf(e.tool_input)
103    const shell = shellOf(e.tool_name)
104    const isFix =
105      command !== undefined && shell !== undefined && !findCdChain(command, shell) && (await read($, isAwaitingFix))
106
107    if (isFix) {
108      const key = `n:${await $.session.id()}`
109      await $.store.set(key, addCount(await $.store.get(key), { fixed: 1 }))
110      await update($, fixed, n => n + 1)
111      await update($, isAwaitingFix, () => false)
112    }
113
114    return next(e)
115  })
116
117  on('prompt.submit', async ($, e, next) => {
118    await update($, isRecent, () => false)
119    return next(e)
120  })
121
122  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
123    if (e.props.hasSurvey || !(await read($, isRecent))) return next(e)
124
125    const { Box, Text } = $.ui.resolve(e)
126    return (
127      <Box>
128        <Text dimColor>{bandText(await read($, caught), await read($, fixed))}</Text>
129      </Box>
130    )
131  })
132
133  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
134    const { Box, Button, Text } = $.ui.resolve(e)
135    const current = await read($, mode)
136    const keys = (await $.store.keys()).filter(key => key.startsWith('n:'))
137    const total = sumCounts(await Promise.all(keys.map(key => $.store.get(key))))
138
139    const choose = (chosen: Mode) => async (): Promise<void> => {
140      await $.store.set('mode', chosen)
141      await update($, mode, () => chosen)
142      $.ui.invalidate('tool.describe')
143    }
144
145    return (
146      <Box flexDirection="column">
147        <Text>
148          This session: caught {await read($, caught)}, fixed {await read($, fixed)}
149        </Text>
150        <Text dimColor>
151          All sessions: caught {total.caught}, fixed {total.fixed}
152        </Text>
153        <Text>Mode: {current}</Text>
154        <Box>
155          {(['teach', 'watch', 'off'] as const).map(choice => (
156            <Button
157              key={`mode-${choice}`}
158              label={choice}
159              variant={choice === current ? 'primary' : undefined}
160              onPress={choose(choice)}
161            />
162          ))}
163        </Box>
164        <Text dimColor>teach bounces a chain, watch only counts it, off does nothing.</Text>
165      </Box>
166    )
167  })
168}
169
hooks/chain.ts 98 lines
1import { splitSegments, splitWords, type Segment, type Shell, type Word } from './scan'
2
3/** A directory change followed by another command, as the model typed it. */
4export type CdChain = {
5  /** The target as written, quotes included, so a suggestion keeps the spelling. */
6  dir: string
7  /** The first command after the directory change. */
8  rest: string
9  /** True when more commands follow `rest`. */
10  hasMore: boolean
11}
12
13const BASH_CD = new Set(['cd', 'pushd', 'chdir'])
14const POWERSHELL_CD = new Set(['cd', 'sl', 'chdir', 'pushd', 'set-location', 'push-location'])
15const POWERSHELL_PATH_PARAMS = new Set(['-path', '-literalpath'])
16const DRIVE_ONLY = /^[A-Za-z]:$/
17
18function isCdCommand(word: Word, shell: Shell): boolean {
19  return shell === 'bash'
20    ? BASH_CD.has(word.value)
21    : POWERSHELL_CD.has(word.value.toLowerCase())
22}
23
24/** The words that are not options, with `-Path` and `-LiteralPath` keeping their value. */
25function positionalWords(args: Word[], shell: Shell): Word[] {
26  const positional: Word[] = []
27  for (let i = 0; i < args.length; i += 1) {
28    const word = args[i] as Word
29    const lower = word.value.toLowerCase()
30    if (shell === 'powershell' && POWERSHELL_PATH_PARAMS.has(lower)) {
31      const target = args[i + 1]
32      if (target) positional.push(target)
33      i += 1
34    } else if (word.value === '--' || (word.value.startsWith('-') && word.value.length > 1)) {
35      continue
36    } else if (lower === '/d' && i < args.length - 1) {
37      // `cd /d D:\x` is cmd.exe syntax that people type into Git Bash.
38      continue
39    } else {
40      positional.push(word)
41    }
42  }
43  return positional
44}
45
46/** The target of a directory change, or undefined when it can't be judged statically. */
47function staticTarget(segment: Segment, shell: Shell): Word | undefined {
48  const [command, ...args] = splitWords(segment.text, shell)
49  if (!command || !isCdCommand(command, shell)) return undefined
50
51  const positional = positionalWords(args, shell)
52  const [target] = positional
53  if (positional.length !== 1 || !target) return undefined
54  if (!target.isStatic || target.value === '-' || DRIVE_ONLY.test(target.value)) return undefined
55  return target
56}
57
58const isChainSeparator = (segment: Segment): boolean =>
59  segment.separator === '&&' || segment.separator === ';'
60
61/**
62 * Finds a static directory change that is followed by another command with
63 * `&&`, `;` or a newline: `cd x && …`, `( cd x && … )`, `… && cd x && …`.
64 * A bare `cd x`, `cd -`, `cd "$dir"` and `cd x || exit` are left alone.
65 */
66export function findCdChain(command: string, shell: Shell): CdChain | undefined {
67  const segments = splitSegments(command, shell)
68
69  for (let i = 0; i < segments.length - 1; i += 1) {
70    const segment = segments[i] as Segment
71    const previous = segments[i - 1]
72    const isLeading = previous === undefined || isChainSeparator(previous)
73    const following = segments[i + 1] as Segment
74
75    if (!isLeading || !isChainSeparator(segment) || following.text === '') continue
76
77    const target = staticTarget(segment, shell)
78    if (!target) continue
79
80    const [rest, hasMore] = takePipeline(segments, i + 1)
81    return { dir: target.raw, rest, hasMore }
82  }
83
84  return undefined
85}
86
87/** The pipeline that starts at `from`, as one string, and whether commands follow it. */
88function takePipeline(segments: Segment[], from: number): [pipeline: string, hasMore: boolean] {
89  let end = from
90  while (segments[end]?.separator === '|' && end + 1 < segments.length) end += 1
91
92  const pipeline = segments
93    .slice(from, end + 1)
94    .map(segment => segment.text)
95    .join(' | ')
96  return [pipeline, segments.slice(end + 1).some(later => later.text !== '')]
97}
98
hooks/counts.ts 21 lines
1export type Count = { caught: number; fixed: number }
2
3const isCount = (value: unknown): value is Count =>
4  typeof value === 'object' &&
5  value !== null &&
6  'caught' in value &&
7  'fixed' in value &&
8  typeof value.caught === 'number' &&
9  typeof value.fixed === 'number'
10
11/** A stored value as a count; anything else reads as zero. */
12export const toCount = (stored: unknown): Count => (isCount(stored) ? stored : { caught: 0, fixed: 0 })
13
14export const addCount = (stored: unknown, delta: Partial<Count>): Count => {
15  const count = toCount(stored)
16  return { caught: count.caught + (delta.caught ?? 0), fixed: count.fixed + (delta.fixed ?? 0) }
17}
18
19export const sumCounts = (stored: readonly unknown[]): Count =>
20  stored.reduce<Count>((total, value) => addCount(total, toCount(value)), { caught: 0, fixed: 0 })
21
hooks/suggest.ts 101 lines
1import type { CdChain } from './chain'
2import { splitWords, type Shell, type Word } from './scan'
3
4/** The one-command form of a cd-chain. */
5export type Suggestion = {
6  /** The command to run instead, or undefined when no safe rewording exists. */
7  command: string | undefined
8  /**
9   * True when a permission rule could gate the suggested form differently
10   * from the plain command, so the caller compares the two decisions.
11   */
12  needsCheck: boolean
13}
14
15const READ_ONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'rev-parse', 'ls-files', 'grep', 'blame'])
16/** Flags after which the next word is code or a module, not a script path. */
17const INLINE_CODE_FLAGS = new Set(['-m', '-c', '-e', '-p', '--eval', '--print'])
18const DOTNET_DIR_VERBS = new Set(['build', 'test', 'restore', 'clean', 'publish', 'pack'])
19
20const NONE: Suggestion = { command: undefined, needsCheck: false }
21const checked = (command: string): Suggestion => ({ command, needsCheck: true })
22const unchecked = (command: string): Suggestion => ({ command, needsCheck: false })
23
24/** The command name without its folder or Windows extension, lower-cased. */
25function commandName(word: Word): string {
26  const name = word.value.split(/[\\/]/).at(-1) ?? ''
27  return name.toLowerCase().replace(/\.(exe|cmd|bat)$/, '')
28}
29
30/** `prefix` followed by the words, which keep their original spelling. */
31const extend = (prefix: string, words: Word[]): string =>
32  words.length === 0 ? prefix : `${prefix} ${words.map(word => word.raw).join(' ')}`
33
34/** `dir` and `file` joined with the separator `dir` already uses, quoted when needed. */
35function joinPath(dir: Word, file: Word): string {
36  const separator = dir.value.includes('\\') && !dir.value.includes('/') ? '\\' : '/'
37  const path = `${dir.value.replace(/[\\/]+$/, '')}${separator}${file.value}`
38  return /\s/.test(path) ? `"${path}"` : path
39}
40
41const isRelativeScript = (word: Word): boolean =>
42  !word.value.startsWith('-') && !word.value.startsWith('~') && !/^([A-Za-z]:|[\\/])/.test(word.value)
43
44function isReadOnlyGit(args: Word[]): boolean {
45  const subcommand = args.find(arg => !arg.value.startsWith('-'))
46  if (subcommand?.value === 'branch') return args.some(arg => arg.value === '--show-current')
47  return subcommand !== undefined && READ_ONLY_GIT.has(subcommand.value)
48}
49
50function suggestScript(head: Word, args: Word[], dir: Word): Suggestion {
51  if (args.some(arg => INLINE_CODE_FLAGS.has(arg.value))) return NONE
52
53  const index = args.findIndex(arg => !arg.value.startsWith('-'))
54  const script = args[index]
55  if (!script || !isRelativeScript(script)) return NONE
56
57  const rewritten = args.map((arg, i) => (i === index ? { ...arg, raw: joinPath(dir, script) } : arg))
58  return checked(extend(head.raw, rewritten))
59}
60
61function suggestDotnet(head: Word, args: Word[], dir: string): Suggestion {
62  const [verb, ...others] = args
63  if (verb && DOTNET_DIR_VERBS.has(verb.value)) return checked(extend(`${head.raw} ${verb.raw} ${dir}`, others))
64  if (verb?.value === 'run') return checked(extend(`${head.raw} run --project ${dir}`, others))
65  return NONE
66}
67
68/**
69 * The one-command form of `cd <dir> && <rest>`, in the tool's own directory
70 * flag, or NONE when there is no form that provably does the same job.
71 */
72export function suggest(chain: CdChain, shell: Shell): Suggestion {
73  const [head, ...args] = splitWords(chain.rest, shell)
74  const [dir] = splitWords(chain.dir, shell)
75  if (!head || !dir) return NONE
76
77  switch (commandName(head)) {
78    case 'pnpm':
79      return checked(extend(`${head.raw} --dir ${chain.dir}`, args))
80    case 'npm':
81      return checked(extend(`${head.raw} --prefix ${chain.dir}`, args))
82    case 'yarn':
83      return checked(extend(`${head.raw} --cwd ${chain.dir}`, args))
84    case 'make':
85      return checked(extend(`${head.raw} -C ${chain.dir}`, args))
86    case 'git': {
87      const command = extend(`${head.raw} -C ${chain.dir}`, args)
88      return isReadOnlyGit(args) ? unchecked(command) : checked(command)
89    }
90    case 'dotnet':
91      return suggestDotnet(head, args, chain.dir)
92    case 'python':
93    case 'python3':
94    case 'py':
95    case 'node':
96      return suggestScript(head, args, dir)
97    default:
98      return NONE
99  }
100}
101
hooks/verdict.ts 46 lines
1import type { CdChain } from './chain'
2import type { Shell } from './scan'
3import type { Suggestion } from './suggest'
4import type { Mode } from '../types'
5
6export type Decision = 'allow' | 'ask' | 'deny'
7
8const MODES: readonly Mode[] = ['teach', 'watch', 'off']
9const FALLBACK =
10  'the shell already starts in the project directory; use paths relative to it, or absolute paths with forward slashes'
11export const DESCRIPTION_NOTE =
12  "Don't chain a directory change (`cd X && …`); run commands from the session directory with absolute paths or the tool's own directory flag."
13
14export function shellOf(tool: string): Shell | undefined {
15  if (tool === 'Bash') return 'bash'
16  if (tool === 'PowerShell') return 'powershell'
17  return undefined
18}
19
20/** The command of a Bash or PowerShell call, from a tool input of unknown shape. */
21export function commandOf(input: unknown): string | undefined {
22  if (typeof input !== 'object' || input === null || !('command' in input)) return undefined
23  return typeof input.command === 'string' ? input.command : undefined
24}
25
26export function parseMode(text: string): Mode | undefined {
27  return MODES.find(mode => mode === text.trim().toLowerCase())
28}
29
30const RANK: Record<Decision, number> = { allow: 0, ask: 1, deny: 2 }
31
32/** True when `suggested` is gated at least as strictly as `plain`. */
33export const isAsGated = (suggested: Decision, plain: Decision): boolean =>
34  RANK[suggested] >= RANK[plain]
35
36/** What the model reads when a chain is bounced. */
37export function bounceReason(suggestion: Suggestion, chain: CdChain): string {
38  const command = suggestion.command ?? FALLBACK
39  const more = chain.hasMore && suggestion.command !== undefined ? ' (and the same for the commands after it)' : ''
40  return `stay-put: run it without the directory change: ${command}${more}`
41}
42
43/** The row above the prompt after a bounce. */
44export const bandText = (caught: number, fixed: number): string =>
45  `stay-put · ${caught} caught · ${fixed} fixed`
46
hooks/scan.ts 261 lines
1// A small shell scanner: it splits a command into top-level segments and a
2// segment into words. It understands only what the cd-chain check needs:
3// quotes, here-documents, comments, command substitution and line
4// continuations in Bash and PowerShell. It never evaluates anything.
5
6export type Shell = 'bash' | 'powershell'
7
8/** What ended a segment. A newline counts as `;`. */
9export type Separator = '&&' | '||' | '|' | ';' | '&'
10
11export type Segment = {
12  text: string
13  separator: Separator
14}
15
16export type Word = {
17  /** The word as written, quotes included. */
18  raw: string
19  /** The word with quotes removed. Backslashes stay, so Windows paths survive. */
20  value: string
21  /** False when the shell would expand the word (`$x`, `$(…)`, a backtick). */
22  isStatic: boolean
23}
24
25const isSpace = (char: string | undefined): boolean =>
26  char === ' ' || char === '\t' || char === '\n'
27
28const escapeChar = (shell: Shell): string => (shell === 'bash' ? '\\' : '`')
29
30/** Index just past the quote that opens at `start`, or the end of input. */
31export function skipQuoted(text: string, start: number, shell: Shell): number {
32  const quote = text[start]
33  let i = start + 1
34  while (i < text.length) {
35    const char = text[i]
36    if (quote === '"' && char === escapeChar(shell)) {
37      i += 2
38    } else if (char === quote) {
39      // PowerShell writes a quote inside single quotes as two.
40      if (shell === 'powershell' && quote === "'" && text[i + 1] === "'") {
41        i += 2
42      } else {
43        return i + 1
44      }
45    } else {
46      i += 1
47    }
48  }
49  return text.length
50}
51
52/** Index just past the `$(…)` that opens at `start`, parentheses nested. */
53function skipSubstitution(text: string, start: number, shell: Shell): number {
54  let depth = 0
55  let i = start + 1
56  while (i < text.length) {
57    const char = text[i]
58    if (char === "'" || char === '"') {
59      i = skipQuoted(text, i, shell)
60      continue
61    }
62    if (char === '(') depth += 1
63    if (char === ')') {
64      depth -= 1
65      if (depth < 0) return i + 1
66    }
67    i += 1
68  }
69  return text.length
70}
71
72/** Index just past a Bash `<<WORD` operator, and the delimiter it names. */
73function readHeredocOperator(
74  text: string,
75  start: number,
76): { end: number; delimiter: string } | undefined {
77  let i = start + 2
78  if (text[i] === '<') return undefined
79  if (text[i] === '-') i += 1
80  while (text[i] === ' ' || text[i] === '\t') i += 1
81  const quote = text[i] === "'" || text[i] === '"' ? text[i] : undefined
82  if (quote) i += 1
83  const from = i
84  while (i < text.length && !isSpace(text[i]) && text[i] !== quote && !';&|()<>'.includes(text[i] ?? '')) {
85    i += 1
86  }
87  const delimiter = text.slice(from, i)
88  if (delimiter === '') return undefined
89  return { end: quote ? i + 1 : i, delimiter }
90}
91
92/** Index of the line after the one that holds only `delimiter`. */
93function skipHeredocBody(text: string, from: number, delimiter: string): number {
94  let lineStart = from
95  while (lineStart < text.length) {
96    const lineEnd = text.indexOf('\n', lineStart)
97    const end = lineEnd === -1 ? text.length : lineEnd
98    if (text.slice(lineStart, end).trim() === delimiter) return end
99    if (lineEnd === -1) return text.length
100    lineStart = lineEnd + 1
101  }
102  return text.length
103}
104
105/** Index just past a PowerShell here-string that opens at `start` (`@'` or `@"`). */
106function skipHereString(text: string, start: number): number {
107  const closer = `\n${text[start + 1]}@`
108  const end = text.indexOf(closer, start)
109  return end === -1 ? text.length : end + closer.length
110}
111
112function isHereStringOpen(text: string, i: number): boolean {
113  if (text[i] !== '@' || (text[i + 1] !== "'" && text[i + 1] !== '"')) return false
114  let j = i + 2
115  while (text[j] === ' ' || text[j] === '\t') j += 1
116  return text[j] === '\n'
117}
118
119/**
120 * Splits `source` at the top-level `&&`, `||`, `|`, `;`, `&` and newlines.
121 * Text inside quotes, `$(…)`, here-documents, here-strings and comments never
122 * splits a segment, and comments and here-document bodies are dropped.
123 */
124export function splitSegments(source: string, shell: Shell): Segment[] {
125  const text = source.replace(/\r\n?/g, '\n')
126  const segments: Segment[] = []
127  const heredocs: string[] = []
128  let current = ''
129  let i = 0
130
131  const end = (separator: Separator): void => {
132    segments.push({ text: current.trim(), separator })
133    current = ''
134  }
135
136  while (i < text.length) {
137    const char = text[i] as string
138    const next = text[i + 1]
139    const isWordStart = current === '' || isSpace(current.at(-1))
140
141    if (char === "'" || char === '"') {
142      const stop = skipQuoted(text, i, shell)
143      current += text.slice(i, stop)
144      i = stop
145    } else if (char === '$' && next === '(') {
146      const stop = skipSubstitution(text, i + 1, shell)
147      current += text.slice(i, stop)
148      i = stop
149    } else if (char === '`' && shell === 'bash') {
150      const close = text.indexOf('`', i + 1)
151      const stop = close === -1 ? text.length : close + 1
152      current += text.slice(i, stop)
153      i = stop
154    } else if (char === escapeChar(shell)) {
155      if (next === '\n') {
156        current += ' '
157        i += 2
158      } else {
159        current += text.slice(i, i + 2)
160        i += 2
161      }
162    } else if (shell === 'powershell' && isHereStringOpen(text, i)) {
163      const stop = skipHereString(text, i)
164      current += '""'
165      i = stop
166    } else if (shell === 'powershell' && char === '<' && next === '#') {
167      const close = text.indexOf('#>', i + 2)
168      i = close === -1 ? text.length : close + 2
169    } else if (char === '#' && isWordStart) {
170      const newline = text.indexOf('\n', i)
171      i = newline === -1 ? text.length : newline
172    } else if (shell === 'bash' && char === '<' && next === '<') {
173      const heredoc = readHeredocOperator(text, i)
174      if (heredoc) {
175        heredocs.push(heredoc.delimiter)
176        current += text.slice(i, heredoc.end)
177        i = heredoc.end
178      } else {
179        current += '<<'
180        i += 2
181      }
182    } else if (char === '\n') {
183      end(';')
184      i += 1
185      for (const delimiter of heredocs.splice(0)) {
186        i = Math.min(text.length, skipHeredocBody(text, i, delimiter) + 1)
187      }
188    } else if (char === '&' && next === '&') {
189      end('&&')
190      i += 2
191    } else if (char === '|' && next === '|') {
192      end('||')
193      i += 2
194    } else if (char === '|') {
195      end('|')
196      i += 1
197    } else if (char === ';') {
198      end(';')
199      i += 1
200    } else if (char === '&') {
201      // `2>&1`, `>&2` and `&>` are redirections, not backgrounding.
202      if (text[i - 1] === '>' || text[i - 1] === '<' || next === '>') {
203        current += char
204      } else {
205        end('&')
206      }
207      i += 1
208    } else if (shell === 'bash' && char === '(' && current.trim() === '') {
209      i += 1
210    } else if (shell === 'bash' && char === ')') {
211      end(';')
212      i += 1
213    } else {
214      current += char
215      i += 1
216    }
217  }
218
219  end(';')
220  return segments
221}
222
223/** Splits one segment into words, quotes honoured. */
224export function splitWords(segment: string, shell: Shell): Word[] {
225  const words: Word[] = []
226  let raw = ''
227  let value = ''
228  let isStatic = true
229  let i = 0
230
231  const flush = (): void => {
232    if (raw !== '') words.push({ raw, value, isStatic })
233    raw = ''
234    value = ''
235    isStatic = true
236  }
237
238  while (i < segment.length) {
239    const char = segment[i] as string
240    if (isSpace(char)) {
241      flush()
242      i += 1
243    } else if (char === "'" || char === '"') {
244      const stop = skipQuoted(segment, i, shell)
245      const body = segment.slice(i + 1, segment[stop - 1] === char ? stop - 1 : stop)
246      if (char === '"' && /[$`]/.test(body)) isStatic = false
247      raw += segment.slice(i, stop)
248      value += body
249      i = stop
250    } else {
251      if (char === '$' || char === '`') isStatic = false
252      raw += char
253      value += char
254      i += 1
255    }
256  }
257
258  flush()
259  return words
260}
261
types/index.d.ts 18 lines
1export type Mode = 'teach' | 'watch' | 'off'
2
3declare module 'claude-code' {
4  interface PluginState {
5    'stay-put': {
6      mode: Mode
7      /** Directory-change chains seen this session. */
8      caught: number
9      /** Bounced chains whose next shell call ran without the directory change. */
10      fixed: number
11      /** True from a bounce until the next shell call. */
12      isAwaitingFix: boolean
13      /** True from a bounce until the next prompt, while the band shows. */
14      isRecent: boolean
15    }
16  }
17}
18