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

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.
claude plugin install stay-put@ground-rules
Needs Claude Code 2.1.287 or later. See the repository README for updating and uninstalling.
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.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.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.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 || ….
Command after the cd | Suggested form | |||||
|---|---|---|---|---|---|---|
pnpm, npm, yarn, make | pnpm --dir <dir>, npm --prefix <dir>, yarn --cwd <dir>, make -C <dir> | |||||
| `dotnet build\ | test\ | restore\ | clean\ | publish\ | pack` | dotnet <verb> <dir> |
dotnet run | dotnet run --project <dir> | |||||
python, python3, py, node with a relative script | the 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.
/stay-put teach|watch|off, or the same buttons in /stay-put.
| Mode | Behaviour |
|---|---|
teach (default) | Describe and bounce. |
watch | Describe and count. Nothing is denied. |
off | Nothing. |
The mode setting in the plugin's options sets the default. A mode chosen with the command wins over it.
| Hooks | session.start, command.run, tool.describe, tool.check, classic.PostToolUse, prompt.submit, and ui.render for the band and the pane. |
| Reads | The command text of Bash and PowerShell calls. |
| Runs | Nothing. It asks Claude Code for permission decisions with $.tool.check. |
| Stores | mode, and one n:<session> key per session with two counters. |
| Never | tool.call, $.process, $.http, $.model, the session directory. |
reach.json pins the exact hooks and calls.
tool.check carries no directory, so a worktree subagent gets the same verdict as the main thread.cd "$REPO" && make) is not recognised.hooks/register.tsx 169 lines1import { 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}
169hooks/chain.ts 98 lines1import { 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}
98hooks/counts.ts 21 lines1export 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 })
21hooks/suggest.ts 101 lines1import 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}
101hooks/verdict.ts 46 lines1import 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`
46hooks/scan.ts 261 lines1// 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}
261types/index.d.ts 18 lines1export 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