jackherd 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/INSTALL.md +4 -2
- package/{NOTICE → NOTICE.txt} +1 -1
- package/README.md +94 -1033
- package/assets/icons/bark-dark.svg +1 -1
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl-dark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide-dark.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear-dark.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes-dark.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan-dark.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
- package/dist/config.d.ts +11 -5
- package/dist/config.js +53 -21
- package/dist/plan-format.d.ts +73 -7
- package/dist/plan-format.js +180 -100
- package/docs/workflow-dark.svg +1 -1
- package/docs/workflow.svg +1 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
- package/kit/hooks/hooks/approval-gate/index.ts +10 -8
- package/kit/hooks/hooks/approval-gate/run.ts +21 -12
- package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
- package/kit/hooks/hooks/git-guard/decision.ts +189 -15
- package/kit/hooks/hooks/git-guard/index.ts +13 -3
- package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
- package/kit/hooks/hooks/git-guard/repository.ts +63 -0
- package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
- package/kit/hooks/hooks/merge-guard/decision.ts +342 -102
- package/kit/hooks/hooks/merge-guard/index.ts +6 -4
- package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
- package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
- package/kit/hooks/hooks/merge-guard/run.ts +118 -29
- package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
- package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
- package/kit/hooks/hooks/shared/base-branch.ts +23 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
- package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
- package/kit/hooks/hooks/shared/command.ts +14 -2
- package/kit/hooks/hooks/shared/commit.ts +2 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
- package/kit/hooks/hooks/shared/json.ts +10 -0
- package/kit/hooks/hooks/shared/lane.ts +57 -0
- package/kit/hooks/hooks/shared/once.ts +8 -0
- package/kit/hooks/hooks/shared/own-pane.ts +31 -0
- package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
- package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
- package/kit/hooks/hooks/shared/program-position.ts +148 -5
- package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
- package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
- package/kit/hooks/hooks/shared/shell.ts +167 -20
- package/kit/hooks/hooks.json +11 -2
- package/kit/hooks/statusline.sh +29 -16
- package/kit/review/skills/diagnose/SKILL.md +1 -1
- package/kit/review/skills/review/SKILL.md +2 -0
- package/kit/rules/rules/kit/testing.md +2 -5
- package/kit/rules/rules/kit/writing-for-agents.md +3 -1
- package/kit/workflow/agents/spec-review.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/grill/SKILL.md +1 -1
- package/kit/workflow/skills/implement/SKILL.md +10 -11
- package/kit/workflow/skills/orchestrate/SKILL.md +155 -117
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
- package/kit/workflow/skills/plan/SKILL.md +5 -2
- package/kit/workflow/skills/plan/references/template-English.md +6 -0
- package/kit/workflow/skills/plan/references/template-German.md +6 -0
- package/package.json +3 -2
- package/recipes/README.md +21 -0
- package/recipes/github-issues.md +23 -0
- package/recipes/github-projects.md +22 -0
- package/recipes/jira-cli.md +26 -0
- package/recipes/jira-rest.md +24 -0
- package/recipes/linear.md +25 -0
- package/recipes/sentry.md +18 -0
- package/recipes/sources/jira.ts +51 -0
- package/recipes/sources/linear.ts +19 -0
- package/recipes/sources/sentry.ts +12 -0
- package/recipes/sources.md +80 -0
- package/recipes/status-page.md +19 -0
- package/schemas/jackherd.schema.json +42 -18
- package/src/check/command.ts +2 -2
- package/src/check/parallel-report.ts +19 -9
- package/src/cli.ts +1 -0
- package/src/config/context-shares.ts +130 -0
- package/src/config/legacy-names.ts +8 -0
- package/src/config/read-config.ts +12 -4
- package/src/config/schema.ts +75 -20
- package/src/context/auto-compact.ts +68 -0
- package/src/context/command.ts +68 -17
- package/src/context/coordinator-name.ts +27 -9
- package/src/context/index.ts +0 -1
- package/src/context/level.ts +18 -11
- package/src/context/report.ts +6 -3
- package/src/plan-format/dependencies.ts +144 -32
- package/src/plan-format/index.ts +6 -0
- package/src/plan-format/lanes.ts +6 -0
- package/src/plan-format/progress.ts +19 -8
- package/src/plans/command.ts +14 -5
- package/src/plans/current-phase.ts +63 -7
- package/src/plans/current-task.ts +51 -0
- package/src/plans/ensure-panes.ts +25 -8
- package/src/plans/frame.ts +99 -0
- package/src/plans/git.ts +8 -0
- package/src/plans/herd-memory.ts +107 -0
- package/src/plans/lane-plans.ts +5 -3
- package/src/plans/lane-summary.ts +222 -0
- package/src/plans/layout-phase.ts +148 -0
- package/src/plans/layout-summary.ts +140 -0
- package/src/plans/layout.ts +164 -420
- package/src/plans/live.ts +11 -6
- package/src/plans/plan-panes.ts +15 -0
- package/src/plans/planned.ts +9 -42
- package/src/plans/selection.ts +2 -6
- package/src/plans/settings.ts +2 -7
- package/src/plans/snapshot.ts +38 -39
- package/src/plans/symbols.ts +2 -2
- package/src/plans/texts.ts +32 -31
- package/src/plans/view.ts +0 -1
- package/src/plans/worked-phases.ts +2 -2
- package/src/settled/board-row.ts +133 -0
- package/src/settled/check.ts +129 -0
- package/src/settled/command.ts +65 -0
- package/src/settled/evidence-version.ts +14 -0
- package/src/settled/options.ts +69 -0
- package/src/settled/send.ts +27 -0
- package/src/settled/settle.ts +87 -0
- package/src/settled/topics.ts +56 -0
- package/src/sync/command.ts +16 -1
- package/src/sync/render.ts +13 -4
- package/src/sync/sync.ts +25 -5
- package/src/terminal/herdr.ts +46 -10
- package/src/terminal/none.ts +1 -0
- package/src/terminal/types.ts +15 -2
- package/src/update/command.ts +4 -2
- package/src/update/update.ts +33 -9
- package/src/vocabulary/index.ts +47 -11
- package/src/watch/appointments.ts +205 -0
- package/src/watch/command.ts +62 -19
- package/src/watch/lane-report.ts +11 -3
- package/src/watch/texts.ts +35 -59
- package/src/watch/watch-events.ts +21 -28
- package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { isProgramPosition } from './program-position'
|
|
2
|
+
import { isExpanded, type SimpleCommand, type Word } from './shell'
|
|
3
|
+
|
|
4
|
+
/** The aliases and functions of a shell, by name, with the text each one stands for. */
|
|
5
|
+
export type ShellDefinitions = { aliases: ReadonlyMap<string, string>; functions: ReadonlyMap<string, string> }
|
|
6
|
+
|
|
7
|
+
const SHELL_DEFINITION_KIND = { alias: 'alias', function: 'function' } as const
|
|
8
|
+
|
|
9
|
+
export type ShellDefinitionKind = (typeof SHELL_DEFINITION_KIND)[keyof typeof SHELL_DEFINITION_KIND]
|
|
10
|
+
|
|
11
|
+
/** A word at a program position that an alias or function stands for, whose body runs a watched program. */
|
|
12
|
+
export type ShellDefinitionHit = { kind: ShellDefinitionKind; name: string; body: string }
|
|
13
|
+
|
|
14
|
+
export const NO_DEFINITIONS: ShellDefinitions = { aliases: new Map(), functions: new Map() }
|
|
15
|
+
|
|
16
|
+
/** `alias -- name=value` (zsh) or `alias name='value'` (bash), also indented inside an `if`. */
|
|
17
|
+
const ALIAS_LINE = /^\s*alias\s+(?:--\s+)?(.+)$/
|
|
18
|
+
/** `name () {` (zsh), `name () ` with `{` on the next line (bash), or `function name {`. */
|
|
19
|
+
const FUNCTION_HEADER = /^(?:function\s+([^\s(){}]+)\s*(?:\(\))?|([^\s(){}=]+)\s*\(\))\s*\{?\s*$/
|
|
20
|
+
const FUNCTION_END = '}'
|
|
21
|
+
/** The characters a program name is made of, so `git` inside `git_branch` or `$GIT_DIR` is no mention. */
|
|
22
|
+
const NAME_CHARACTER = /[\w.-]/
|
|
23
|
+
/** The words a body may call: whatever stands between shell punctuation. */
|
|
24
|
+
const BODY_WORD = /[^\s;|&(){}<>"'`$=]+/g
|
|
25
|
+
const SHOWN_BODY_LENGTH = 80
|
|
26
|
+
const ALIAS_BUILTIN = 'alias'
|
|
27
|
+
/** An entry of the shell's own alias or function table, which defines one as an assignment: `functions[x]=…` (zsh). */
|
|
28
|
+
const DEFINITION_TABLE_ENTRY = /^(aliases|galiases|saliases|functions|dis_functions|BASH_ALIASES)\[/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Words that are shell builtins or common programs. A word among them is not looked up, so most calls read no
|
|
32
|
+
* snapshot; an alias that shadows one of them is not seen (accepted: such aliases rarely run git).
|
|
33
|
+
*/
|
|
34
|
+
const KNOWN_PROGRAMS = new Set([
|
|
35
|
+
...['.', ':', '[', '[[', '!', '{', '}', 'if', 'then', 'elif', 'else', 'fi', 'for', 'while', 'until', 'do', 'done'],
|
|
36
|
+
...['case', 'esac', 'function', 'time', 'source', 'cd', 'echo', 'printf', 'export', 'unset', 'set', 'test', 'true'],
|
|
37
|
+
...['false', 'read', 'local', 'return', 'exit', 'eval', 'exec', 'command', 'builtin', 'type', 'which', 'whence'],
|
|
38
|
+
...['alias', 'unalias', 'shift', 'wait', 'trap', 'kill', 'jobs', 'fg', 'bg', 'pwd', 'pushd', 'popd', 'let'],
|
|
39
|
+
...['declare', 'typeset', 'readonly', 'getopts', 'hash', 'ulimit', 'umask', 'print', 'setopt', 'unsetopt'],
|
|
40
|
+
...['noglob', 'nohup', 'nice', 'sleep', 'env', 'sudo', 'xargs', 'timeout', 'watch', 'cat', 'ls', 'grep', 'rg'],
|
|
41
|
+
...['sed', 'awk', 'head', 'tail', 'wc', 'sort', 'uniq', 'cut', 'tr', 'tee', 'find', 'mkdir', 'rmdir', 'rm', 'cp'],
|
|
42
|
+
...['mv', 'ln', 'touch', 'chmod', 'chown', 'stat', 'file', 'diff', 'tar', 'gzip', 'zip', 'unzip', 'curl', 'wget'],
|
|
43
|
+
...['jq', 'date', 'ps', 'pgrep', 'pkill', 'df', 'du', 'uname', 'whoami', 'id', 'basename', 'dirname', 'realpath'],
|
|
44
|
+
...['readlink', 'mktemp', 'seq', 'sh', 'bash', 'zsh', 'git', 'gh', 'bun', 'bunx', 'node', 'npm', 'npx', 'pnpm'],
|
|
45
|
+
...['yarn', 'deno', 'python', 'python3', 'pip', 'make', 'docker', 'kubectl', 'tsc', 'biome', 'turbo', 'less'],
|
|
46
|
+
...['ssh', 'scp', 'rsync', 'openssl', 'base64', 'sha256sum', 'column', 'paste', 'comm', 'xxd', 'od', 'tput'],
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
const aliasDefinitionOf = (line: string): string | undefined => line.match(ALIAS_LINE)?.[1]
|
|
50
|
+
|
|
51
|
+
/** Removes the shell quoting of a value: `'…'`, `"…"` and backslash escapes. */
|
|
52
|
+
const unquote = (text: string): string => {
|
|
53
|
+
let result = ''
|
|
54
|
+
for (let index = 0; index < text.length; index++) {
|
|
55
|
+
const character = text[index]
|
|
56
|
+
if (character === "'") {
|
|
57
|
+
const close = text.indexOf("'", index + 1)
|
|
58
|
+
const end = close === -1 ? text.length : close
|
|
59
|
+
result += text.slice(index + 1, end)
|
|
60
|
+
index = end
|
|
61
|
+
} else if (character === '\\') result += text[++index] ?? ''
|
|
62
|
+
else if (character !== '"') result += character
|
|
63
|
+
}
|
|
64
|
+
return result
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `name=value` with either side quoted; the first unquoted `=` splits them. */
|
|
68
|
+
const splitAlias = (definition: string): [string, string] | undefined => {
|
|
69
|
+
let quoted = false
|
|
70
|
+
for (let index = 0; index < definition.length; index++) {
|
|
71
|
+
if (definition[index] === "'") quoted = !quoted
|
|
72
|
+
if (definition[index] === '=' && !quoted)
|
|
73
|
+
return [unquote(definition.slice(0, index)), unquote(definition.slice(index + 1))]
|
|
74
|
+
}
|
|
75
|
+
return undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const functionName = (line: string): string | undefined => {
|
|
79
|
+
const match = line.match(FUNCTION_HEADER)
|
|
80
|
+
return match?.[1] ?? match?.[2]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Reads the aliases and functions of a Claude Code shell snapshot (zsh or bash). A function body runs from its header
|
|
85
|
+
* to the next line that is a lone `}`; every other line is ignored.
|
|
86
|
+
*/
|
|
87
|
+
export const parseShellDefinitions = (text: string): ShellDefinitions => {
|
|
88
|
+
const aliases = new Map<string, string>()
|
|
89
|
+
const functions = new Map<string, string>()
|
|
90
|
+
const lines = text.split('\n')
|
|
91
|
+
for (let index = 0; index < lines.length; index++) {
|
|
92
|
+
const name = functionName(lines[index])
|
|
93
|
+
if (name !== undefined) {
|
|
94
|
+
const end = lines.indexOf(FUNCTION_END, index + 1)
|
|
95
|
+
const last = end === -1 ? lines.length : end
|
|
96
|
+
functions.set(name, lines.slice(index + 1, last).join('\n'))
|
|
97
|
+
index = last
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
const definition = aliasDefinitionOf(lines[index])
|
|
101
|
+
const alias = definition === undefined ? undefined : splitAlias(definition)
|
|
102
|
+
if (alias) aliases.set(alias[0], alias[1])
|
|
103
|
+
}
|
|
104
|
+
return { aliases, functions }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const mergeMaps = (maps: ReadonlyMap<string, string>[]): Map<string, string> => {
|
|
108
|
+
const merged = new Map<string, string>()
|
|
109
|
+
for (const map of maps) {
|
|
110
|
+
for (const [name, body] of map) merged.set(name, merged.has(name) ? `${merged.get(name)}\n${body}` : body)
|
|
111
|
+
}
|
|
112
|
+
return merged
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One set of definitions from several snapshots: a name defined differently in two of them keeps both bodies. */
|
|
116
|
+
export const mergeShellDefinitions = (list: ShellDefinitions[]): ShellDefinitions => ({
|
|
117
|
+
aliases: mergeMaps(list.map(({ aliases }) => aliases)),
|
|
118
|
+
functions: mergeMaps(list.map(({ functions }) => functions)),
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
/** Whether `name` stands in `text` as a whole word, not as part of a longer name. */
|
|
122
|
+
const namesWord = (text: string, name: string): boolean => {
|
|
123
|
+
if (name === '') return false
|
|
124
|
+
for (let at = text.indexOf(name); at !== -1; at = text.indexOf(name, at + 1)) {
|
|
125
|
+
const before = text[at - 1] ?? ' '
|
|
126
|
+
const after = text[at + name.length] ?? ' '
|
|
127
|
+
if (!NAME_CHARACTER.test(before) && !NAME_CHARACTER.test(after)) return true
|
|
128
|
+
}
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const bodiesOf = ({ aliases, functions }: ShellDefinitions, name: string): string[] =>
|
|
133
|
+
[aliases.get(name), functions.get(name)].filter((body) => body !== undefined)
|
|
134
|
+
|
|
135
|
+
/** Whether the text names a program, itself or through another alias or function it calls. */
|
|
136
|
+
const runsProgram = (
|
|
137
|
+
definitions: ShellDefinitions,
|
|
138
|
+
text: string,
|
|
139
|
+
programs: readonly string[],
|
|
140
|
+
seen: Set<string>,
|
|
141
|
+
): boolean => {
|
|
142
|
+
if (programs.some((program) => namesWord(text, program))) return true
|
|
143
|
+
return (text.match(BODY_WORD) ?? []).some((word) => {
|
|
144
|
+
if (seen.has(word)) return false
|
|
145
|
+
seen.add(word)
|
|
146
|
+
return bodiesOf(definitions, word).some((body) => runsProgram(definitions, body, programs, seen))
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** An alias whose value ends in a blank makes the shell expand the next word as well, as if it stood first. */
|
|
151
|
+
const aliasText = (body: string, following: Word[]): string =>
|
|
152
|
+
/\s$/.test(body) ? `${body}${following.map(({ value }) => value).join(' ')}` : body
|
|
153
|
+
|
|
154
|
+
const mayBeDefined = ({ value }: Word): boolean =>
|
|
155
|
+
value !== '' && !value.includes('/') && !isExpanded(value) && !KNOWN_PROGRAMS.has(value)
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The alias or function the word at `index` calls, when it stands at a program position and its body names one of
|
|
159
|
+
* `programs`, also through the aliases and functions it calls in turn. The shell expands an alias only for an
|
|
160
|
+
* unquoted word, and finds a function for a quoted one too. `definitions` is called only for a word that is no known
|
|
161
|
+
* program, so most commands read no snapshot.
|
|
162
|
+
*/
|
|
163
|
+
export const shellDefinitionAt = (
|
|
164
|
+
words: SimpleCommand,
|
|
165
|
+
index: number,
|
|
166
|
+
definitions: () => ShellDefinitions,
|
|
167
|
+
programs: readonly string[],
|
|
168
|
+
): ShellDefinitionHit | undefined => {
|
|
169
|
+
const word = words[index]
|
|
170
|
+
if (!isProgramPosition(words, index) || !mayBeDefined(word)) return undefined
|
|
171
|
+
const known = definitions()
|
|
172
|
+
const runs = (text: string): boolean => runsProgram(known, text, programs, new Set([word.value]))
|
|
173
|
+
const alias = word.literal ? known.aliases.get(word.value) : undefined
|
|
174
|
+
if (alias !== undefined && runs(aliasText(alias, words.slice(index + 1)))) {
|
|
175
|
+
return { kind: SHELL_DEFINITION_KIND.alias, name: word.value, body: alias }
|
|
176
|
+
}
|
|
177
|
+
const body = known.functions.get(word.value)
|
|
178
|
+
if (body !== undefined && runs(body)) return { kind: SHELL_DEFINITION_KIND.function, name: word.value, body }
|
|
179
|
+
return undefined
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* An `alias` command anywhere a program can stand, or an entry of the alias or function table: the definition it adds
|
|
184
|
+
* in the script hides its text in a quoted word.
|
|
185
|
+
*/
|
|
186
|
+
export const definesShellAlias = (words: SimpleCommand): boolean =>
|
|
187
|
+
words.some(
|
|
188
|
+
(word, index) =>
|
|
189
|
+
(word.value === ALIAS_BUILTIN && isProgramPosition(words, index)) || DEFINITION_TABLE_ENTRY.test(word.value),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
/** A program expanded from a variable or a substitution may name a function; one with a slash is a path. */
|
|
193
|
+
const isExpandedProgram = (words: SimpleCommand, index: number): boolean => {
|
|
194
|
+
const { value, literal } = words[index]
|
|
195
|
+
return !literal && isExpanded(value) && !value.includes('/') && isProgramPosition(words, index)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The word a parameter expansion falls back to or assigns: `shipit` in `${x:-shipit}`, `${x-shipit}`, `${a[0]:+shipit}`.
|
|
200
|
+
* A nested expansion matches from the innermost one, so `${x:-${y:-shipit}}` yields `shipit`. The name may be empty
|
|
201
|
+
* (`${:-shipit}`), special (`${@:-shipit}`) or an expansion itself (`${${x}:-shipit}`, zsh).
|
|
202
|
+
*/
|
|
203
|
+
const EXPANSION_OPERAND = /\$\{(?:[^{}:=+?-]|\$\{[^{}]*\})*:?[-=+?]([^{}]*)\}/g
|
|
204
|
+
|
|
205
|
+
/** `text` with each expansion operand beside it, so a name after the operator's `-` still stands as a word. */
|
|
206
|
+
const withExpansionOperands = (text: string): string =>
|
|
207
|
+
[text, ...[...text.matchAll(EXPANSION_OPERAND)].map(([, operand]) => operand)].join('\n')
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A script that lists the shell's functions can take a function's name from there without writing it out: zsh's
|
|
211
|
+
* `functions`, `dis_functions` and `functions_source` tables or builtin, `compgen -A function`/`-c`/`-A command`
|
|
212
|
+
* (also bundled, `-bA function`), `declare`/`typeset` with `-f`, `-F` or `+f` (`typeset -fm`, `declare -Fp`),
|
|
213
|
+
* `whence`/`which`/`where`/`type -m`, and a bare `set`, which prints the functions in bash.
|
|
214
|
+
*/
|
|
215
|
+
const READS_FUNCTION_TABLE = new RegExp(
|
|
216
|
+
[
|
|
217
|
+
/(?:^|[^\w.-])(?:dis_)?functions(?:_source)?(?:[^\w.-]|$)/,
|
|
218
|
+
/\bcompgen\b[^\n;|&]*-(?:[A-Za-z]*A\s*(?:function|command)\b|[A-Za-z]*c)/,
|
|
219
|
+
/\b(?:declare|typeset)\b[^\n;|&]*\s[+-][A-Za-z]*[fF]/,
|
|
220
|
+
/(?:^|[\s;|&(`])(?:whence|which|where|type)\s[^\n;|&]*-[A-Za-z]*m/,
|
|
221
|
+
/(?:^|[^\w.-])set\s*(?:$|[|;&)`])/,
|
|
222
|
+
]
|
|
223
|
+
.map(({ source }) => source)
|
|
224
|
+
.join('|'),
|
|
225
|
+
'm',
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Whether a program expanded from a variable or a substitution (`f=shipit; $f`) may call a function of the snapshot
|
|
230
|
+
* whose body names one of `programs`. Only a function whose name stands as a word in `text` counts: the script as
|
|
231
|
+
* written and its decoded words, where a variable or substitution would take the name from. The shell expands no alias
|
|
232
|
+
* from an expansion. `definitions` is called only when such a program stands in the command.
|
|
233
|
+
*/
|
|
234
|
+
export const mayRunDefinedFunction = (
|
|
235
|
+
words: SimpleCommand,
|
|
236
|
+
definitions: () => ShellDefinitions,
|
|
237
|
+
programs: readonly string[],
|
|
238
|
+
text: string,
|
|
239
|
+
): boolean => {
|
|
240
|
+
if (!words.some((_word, index) => isExpandedProgram(words, index))) return false
|
|
241
|
+
const known = definitions()
|
|
242
|
+
const searched = withExpansionOperands(text)
|
|
243
|
+
const anyName = READS_FUNCTION_TABLE.test(text)
|
|
244
|
+
return [...known.functions].some(
|
|
245
|
+
([name, body]) => (anyName || namesWord(searched, name)) && runsProgram(known, body, programs, new Set()),
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const shown = (body: string): string => {
|
|
250
|
+
const line = body.trim().replace(/\s+/g, ' ')
|
|
251
|
+
return line.length > SHOWN_BODY_LENGTH ? `${line.slice(0, SHOWN_BODY_LENGTH)}…` : line
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** What a guard tells the agent about an alias or function: what it stands for, and to call the command written out. */
|
|
255
|
+
export const shellDefinitionHint = ({ kind, name, body }: ShellDefinitionHit): string =>
|
|
256
|
+
kind === SHELL_DEFINITION_KIND.alias
|
|
257
|
+
? `\`${name}\` is a shell alias for \`${shown(body)}\`, which hides the command from the permission rules. Run the command written out instead, as \`${shown(body)} …\`.`
|
|
258
|
+
: `\`${name}\` is a shell function, which hides the commands it runs from the permission rules. Run them written out instead.`
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { mergeShellDefinitions, NO_DEFINITIONS, parseShellDefinitions, type ShellDefinitions } from './shell-aliases'
|
|
5
|
+
|
|
6
|
+
/** `snapshot-<shell>-<milliseconds>-<random>.sh`, as Claude Code names the shell snapshot of a session. */
|
|
7
|
+
const SNAPSHOT_FILE = /^snapshot-[a-z]+-(\d+)-[A-Za-z0-9]+\.sh$/
|
|
8
|
+
/** Enough for every session that may still run; a bound, so a directory nobody cleans cannot slow the guard down. */
|
|
9
|
+
const MAX_SNAPSHOTS = 32
|
|
10
|
+
const MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024
|
|
11
|
+
/** How far a snapshot's time may lie ahead of the clock; a later one is no session's and could push the real ones out. */
|
|
12
|
+
const CLOCK_SLACK_MILLISECONDS = 60_000
|
|
13
|
+
|
|
14
|
+
/** Where Claude Code keeps the shell snapshots its Bash tool sources: in its config directory, which CLAUDE_CONFIG_DIR moves. */
|
|
15
|
+
export const snapshotDirectory = (
|
|
16
|
+
env: Record<string, string | undefined> = process.env,
|
|
17
|
+
home: string = homedir(),
|
|
18
|
+
): string => join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'shell-snapshots')
|
|
19
|
+
|
|
20
|
+
const snapshotCandidates = (names: string[], now: number): { name: string; created: number }[] =>
|
|
21
|
+
names.flatMap((name) => {
|
|
22
|
+
const created = Number(name.match(SNAPSHOT_FILE)?.[1])
|
|
23
|
+
return Number.isNaN(created) || created > now + CLOCK_SLACK_MILLISECONDS ? [] : [{ name, created }]
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
/** A regular file: a directory or a named pipe with a snapshot's name is skipped, and a pipe would block the read. */
|
|
27
|
+
const isRegularFile = (path: string): boolean => {
|
|
28
|
+
try {
|
|
29
|
+
return statSync(path).isFile()
|
|
30
|
+
} catch {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const newestSnapshots = (directory: string, names: string[], now: number): string[] =>
|
|
36
|
+
snapshotCandidates(names, now)
|
|
37
|
+
.sort((left, right) => right.created - left.created)
|
|
38
|
+
.filter(({ name }) => isRegularFile(join(directory, name)))
|
|
39
|
+
.slice(0, MAX_SNAPSHOTS)
|
|
40
|
+
.map(({ name }) => join(directory, name))
|
|
41
|
+
|
|
42
|
+
const readSnapshot = (path: string): ShellDefinitions[] => {
|
|
43
|
+
try {
|
|
44
|
+
if (statSync(path).size > MAX_SNAPSHOT_BYTES) return []
|
|
45
|
+
return [parseShellDefinitions(readFileSync(path, 'utf8'))]
|
|
46
|
+
} catch {
|
|
47
|
+
return []
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The aliases and functions of every recent snapshot together. The hook input does not say which snapshot the session's
|
|
53
|
+
* Bash tool sourced, so a name defined in any of them counts. A missing directory or an unreadable snapshot adds
|
|
54
|
+
* nothing, and the guards decide as they would without aliases. A snapshot whose time lies after `now` is skipped.
|
|
55
|
+
*/
|
|
56
|
+
export const readShellDefinitions = (
|
|
57
|
+
directory: string = snapshotDirectory(),
|
|
58
|
+
now: number = Date.now(),
|
|
59
|
+
): ShellDefinitions => {
|
|
60
|
+
let names: string[]
|
|
61
|
+
try {
|
|
62
|
+
names = readdirSync(directory)
|
|
63
|
+
} catch {
|
|
64
|
+
return NO_DEFINITIONS
|
|
65
|
+
}
|
|
66
|
+
const snapshots = newestSnapshots(directory, names, now).flatMap(readSnapshot)
|
|
67
|
+
return snapshots.length === 0 ? NO_DEFINITIONS : mergeShellDefinitions(snapshots)
|
|
68
|
+
}
|
|
@@ -27,6 +27,22 @@ type Handler = (cursor: Cursor, state: ListState) => boolean
|
|
|
27
27
|
|
|
28
28
|
export class ShellParseError extends Error {}
|
|
29
29
|
|
|
30
|
+
/** What a guard tells the agent when it refuses or asks for a script `parseShell` cannot read. */
|
|
31
|
+
export const UNREADABLE_ADVICE =
|
|
32
|
+
'Avoid quotes, substitutions and operators inside `${…}` and `$((…))`, close every quote and here-document, or put the script in a file and run that.'
|
|
33
|
+
|
|
34
|
+
/** `<<EOF` or `<<-EOF` with an unquoted delimiter, not a here-string. */
|
|
35
|
+
const EXPANDING_HERE_DOCUMENT = /(^|[^<])<<-?[ \t]*[A-Za-z_]/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What a guard adds when the script holds a here-document with an unquoted delimiter: the shell runs `$(…)` and
|
|
39
|
+
* backticks in its body, which plain text such as Markdown rarely means. Empty for any other script.
|
|
40
|
+
*/
|
|
41
|
+
export const expandingHereDocumentAdvice = (script: string): string =>
|
|
42
|
+
EXPANDING_HERE_DOCUMENT.test(script)
|
|
43
|
+
? "The shell runs `$(…)` and backticks in a here-document with an unquoted delimiter; for plain text, quote it (`<<'EOF'`)."
|
|
44
|
+
: ''
|
|
45
|
+
|
|
30
46
|
const BLANKS = ' \t'
|
|
31
47
|
const OPERATORS = '&;|'
|
|
32
48
|
const REDIRECTION_CHARS = '<>&|'
|
|
@@ -35,6 +51,9 @@ const DELIMITER_END = ' \t\n;&|<>()'
|
|
|
35
51
|
/** Marks where an expansion stood in a word (`$z` keeps its name after it): the value is unknown before the shell runs. */
|
|
36
52
|
const EXPANSION = '$'
|
|
37
53
|
|
|
54
|
+
/** Whether an expansion stood in the word's value, so the shell decides the text only when it runs. */
|
|
55
|
+
export const isExpanded = (value: string): boolean => value.includes(EXPANSION)
|
|
56
|
+
|
|
38
57
|
const peek = (cursor: Cursor, offset = 0): string => cursor.source[cursor.index + offset] ?? ''
|
|
39
58
|
|
|
40
59
|
/** Whether the character is one of `chars`; the empty string past the end of input never is. */
|
|
@@ -68,10 +87,13 @@ const appendShaped = (state: ListState, text: string): void => {
|
|
|
68
87
|
/** `{a,b}` and `{a..b}` expand into other words before the program sees them. */
|
|
69
88
|
const BRACE_EXPANSION = /\{[^}]*(,|\.\.)[^}]*\}/
|
|
70
89
|
|
|
90
|
+
/** Whether the shell would expand braces in the word's value into several words. */
|
|
91
|
+
export const expandsBraces = (value: string): boolean => BRACE_EXPANSION.test(value)
|
|
92
|
+
|
|
71
93
|
const endWord = (state: ListState): void => {
|
|
72
94
|
const { word } = state
|
|
73
95
|
if (!word) return
|
|
74
|
-
if (
|
|
96
|
+
if (expandsBraces(word.value)) word.literal = false
|
|
75
97
|
state.word = undefined
|
|
76
98
|
if (state.dropNextWord) state.dropNextWord = false
|
|
77
99
|
else state.words.push(word)
|
|
@@ -90,11 +112,21 @@ const endCommand = (state: ListState): void => {
|
|
|
90
112
|
*/
|
|
91
113
|
const MAYBE_COMMAND = /\$\(|[<>]\(|[`'";&|\n\\]/
|
|
92
114
|
|
|
115
|
+
/**
|
|
116
|
+
* `${(e)x}` (zsh flags) and `${x@P}` (bash prompt expansion) evaluate the variable's value as shell text, so a
|
|
117
|
+
* substitution stored in it runs.
|
|
118
|
+
*/
|
|
119
|
+
const EVALUATING_EXPANSION = /^\$\{\(|@P/
|
|
120
|
+
|
|
93
121
|
/** Rather than parse a command inside an expansion, the whole text is refused as unparsable. */
|
|
94
122
|
const assertNoHiddenCommand = (text: string): void => {
|
|
95
123
|
if (MAYBE_COMMAND.test(text)) throw new ShellParseError('a command or quote inside an expansion')
|
|
96
124
|
}
|
|
97
125
|
|
|
126
|
+
const assertNotEvaluating = (text: string): void => {
|
|
127
|
+
if (EVALUATING_EXPANSION.test(text)) throw new ShellParseError('an expansion that evaluates its value')
|
|
128
|
+
}
|
|
129
|
+
|
|
98
130
|
const skipBalanced = (cursor: Cursor, open: string, close: string): void => {
|
|
99
131
|
const start = cursor.index
|
|
100
132
|
let depth = 0
|
|
@@ -107,19 +139,60 @@ const skipBalanced = (cursor: Cursor, open: string, close: string): void => {
|
|
|
107
139
|
throw new ShellParseError(`missing ${close}`)
|
|
108
140
|
}
|
|
109
141
|
|
|
110
|
-
|
|
142
|
+
/** Where the line at the cursor ends: at its newline, or at the end of input. */
|
|
143
|
+
const lineEnd = (cursor: Cursor): number => {
|
|
144
|
+
const newline = cursor.source.indexOf('\n', cursor.index)
|
|
145
|
+
return newline === -1 ? cursor.source.length : newline
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const isDelimiterLine = (cursor: Cursor, { delimiter, stripTabs }: Heredoc): boolean => {
|
|
149
|
+
const line = cursor.source.slice(cursor.index, lineEnd(cursor))
|
|
150
|
+
return (stripTabs ? line.replace(/^\t+/, '') : line) === delimiter
|
|
151
|
+
}
|
|
111
152
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Reads the commands of an expanding body. A backslash escapes the next character, so `\`` and `\$`
|
|
155
|
+
* stay text; `$(…)` and backticks run. The body ends at its delimiter line before the shell expands
|
|
156
|
+
* it, so a substitution that does not close inside the body is unparsable.
|
|
157
|
+
*/
|
|
158
|
+
const readExpandingBody = (body: string): SimpleCommand[] => {
|
|
159
|
+
const cursor: Cursor = { source: body, index: 0 }
|
|
160
|
+
const state = newListState()
|
|
114
161
|
while (!atEnd(cursor)) {
|
|
115
|
-
|
|
116
|
-
const lineEnd = newline === -1 ? cursor.source.length : newline
|
|
117
|
-
const line = cursor.source.slice(cursor.index, lineEnd)
|
|
118
|
-
cursor.index = lineEnd + 1
|
|
119
|
-
if ((stripTabs ? line.replace(/^\t+/, '') : line) === delimiter) return
|
|
120
|
-
if (expands && SUBSTITUTION.test(line)) throw new ShellParseError('a command inside a here-document')
|
|
162
|
+
if (!BODY_HANDLERS.some((handle) => handle(cursor, state))) cursor.index++
|
|
121
163
|
}
|
|
122
|
-
|
|
164
|
+
return state.commands
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const skipLine = (cursor: Cursor): void => {
|
|
168
|
+
cursor.index = lineEnd(cursor) + 1
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** An odd number of backslashes at the end of the line: the last one joins the next line to it. */
|
|
172
|
+
const CONTINUED_LINE = /(?:^|[^\\])(?:\\\\)*\\$/
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* In an expanding body `\⏎` joins two lines before the shell looks for the delimiter, so `EO\⏎F` ends the body
|
|
176
|
+
* where the parse would not: such a body is refused as unparsable.
|
|
177
|
+
*/
|
|
178
|
+
const assertNotContinued = (cursor: Cursor): void => {
|
|
179
|
+
if (CONTINUED_LINE.test(cursor.source.slice(cursor.index, lineEnd(cursor)))) {
|
|
180
|
+
throw new ShellParseError('a line continuation in a here-document body')
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Skips the body; in an expanding body the commands of its substitutions run, so they are kept. */
|
|
185
|
+
const readHeredocBody = (cursor: Cursor, heredoc: Heredoc, commands: SimpleCommand[]): void => {
|
|
186
|
+
const start = cursor.index
|
|
187
|
+
while (!atEnd(cursor)) {
|
|
188
|
+
if (isDelimiterLine(cursor, heredoc)) {
|
|
189
|
+
if (heredoc.expands) commands.push(...readExpandingBody(cursor.source.slice(start, cursor.index)))
|
|
190
|
+
return skipLine(cursor)
|
|
191
|
+
}
|
|
192
|
+
if (heredoc.expands) assertNotContinued(cursor)
|
|
193
|
+
skipLine(cursor)
|
|
194
|
+
}
|
|
195
|
+
throw new ShellParseError(`here-document without its ${heredoc.delimiter} line`)
|
|
123
196
|
}
|
|
124
197
|
|
|
125
198
|
const readHeredocHeader = (cursor: Cursor, stripTabs: boolean): Heredoc => {
|
|
@@ -161,7 +234,7 @@ const newline: Handler = (cursor, state) => {
|
|
|
161
234
|
if (peek(cursor) !== '\n') return false
|
|
162
235
|
endCommand(state)
|
|
163
236
|
cursor.index++
|
|
164
|
-
for (const heredoc of state.heredocs) readHeredocBody(cursor, heredoc)
|
|
237
|
+
for (const heredoc of state.heredocs) readHeredocBody(cursor, heredoc, state.commands)
|
|
165
238
|
state.heredocs = []
|
|
166
239
|
return true
|
|
167
240
|
}
|
|
@@ -181,7 +254,16 @@ const singleQuoted: Handler = (cursor, state) => {
|
|
|
181
254
|
return true
|
|
182
255
|
}
|
|
183
256
|
|
|
184
|
-
const ANSI_C_ESCAPES: Record<string, string> = {
|
|
257
|
+
const ANSI_C_ESCAPES: Record<string, string> = {
|
|
258
|
+
n: '\n',
|
|
259
|
+
t: '\t',
|
|
260
|
+
r: '\r',
|
|
261
|
+
e: '\u001b',
|
|
262
|
+
a: '\u0007',
|
|
263
|
+
b: '\b',
|
|
264
|
+
f: '\f',
|
|
265
|
+
v: '\v',
|
|
266
|
+
}
|
|
185
267
|
const MAX_CODE_POINT = 0x10ffff
|
|
186
268
|
const ANSI_C_NUMERIC = /^(x[0-9a-fA-F]{1,2}|u[0-9a-fA-F]{1,4}|U[0-9a-fA-F]{1,8}|[0-7]{1,3})/
|
|
187
269
|
|
|
@@ -217,15 +299,41 @@ const ansiCQuoted: Handler = (cursor, state) => {
|
|
|
217
299
|
return true
|
|
218
300
|
}
|
|
219
301
|
|
|
302
|
+
/**
|
|
303
|
+
* A backtick substitution ends at its first unescaped backtick, and inside it `` \` `` opens a nested one that the
|
|
304
|
+
* parse would read as text: such a substitution is refused as unparsable. The cursor stands after the opening one.
|
|
305
|
+
*/
|
|
306
|
+
const assertNoNestedBacktick = ({ source, index }: Cursor): void => {
|
|
307
|
+
for (let at = index; at < source.length && source[at] !== '`'; at++) {
|
|
308
|
+
if (source[at] !== '\\') continue
|
|
309
|
+
if (source[at + 1] === '`') throw new ShellParseError('an escaped backtick inside backticks')
|
|
310
|
+
at++
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Parses a substitution's list up to its terminator. A `case` in it is refused as unparsable: the
|
|
316
|
+
* `)` of its patterns would close the substitution early and leave the rest of its commands unread.
|
|
317
|
+
*/
|
|
318
|
+
const parseSubstitution = (cursor: Cursor, terminator: Terminator): SimpleCommand[] => {
|
|
319
|
+
if (terminator === '`') assertNoNestedBacktick(cursor)
|
|
320
|
+
const commands = parseList(cursor, terminator)
|
|
321
|
+
if (commands.some(([program]) => program.value === 'case')) throw new ShellParseError('a case inside a substitution')
|
|
322
|
+
return commands
|
|
323
|
+
}
|
|
324
|
+
|
|
220
325
|
const substitute = (cursor: Cursor, state: ListState, skip: number, terminator: Terminator): void => {
|
|
221
326
|
cursor.index += skip
|
|
222
|
-
state.commands.push(...
|
|
327
|
+
state.commands.push(...parseSubstitution(cursor, terminator))
|
|
223
328
|
appendShaped(state, EXPANSION)
|
|
224
329
|
}
|
|
225
330
|
|
|
226
331
|
const startsCommandSubstitution = (cursor: Cursor): boolean =>
|
|
227
332
|
peek(cursor) === '$' && peek(cursor, 1) === '(' && peek(cursor, 2) !== '('
|
|
228
333
|
|
|
334
|
+
const startsArithmetic = (cursor: Cursor): boolean =>
|
|
335
|
+
peek(cursor) === '$' && peek(cursor, 1) === '(' && peek(cursor, 2) === '('
|
|
336
|
+
|
|
229
337
|
const readDoubleQuotedEscape = (cursor: Cursor, state: ListState): void => {
|
|
230
338
|
const next = peek(cursor, 1)
|
|
231
339
|
if (next !== '\n') appendShaped(state, isOneOf('$`"\\', next) ? next : `\\${next}`)
|
|
@@ -238,10 +346,32 @@ const backtick: Handler = (cursor, state) => {
|
|
|
238
346
|
return true
|
|
239
347
|
}
|
|
240
348
|
|
|
241
|
-
|
|
242
|
-
|
|
349
|
+
/** Whether the inner parenthesis of `((…))` closes right before the outer one, as arithmetic does. */
|
|
350
|
+
const innerClosesLast = (text: string): boolean => {
|
|
351
|
+
let depth = 0
|
|
352
|
+
for (let at = 1; at < text.length; at++) {
|
|
353
|
+
if (text[at] === '(') depth++
|
|
354
|
+
if (text[at] === ')' && --depth === 0) return at === text.length - 2
|
|
355
|
+
}
|
|
356
|
+
return false
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Skips `$((…))` from the cursor on its `$`. Text that opens like it but closes differently, such
|
|
361
|
+
* as `$((cmd) )`, is a command substitution of a subshell and is refused as unparsable.
|
|
362
|
+
*/
|
|
363
|
+
const skipArithmetic = (cursor: Cursor): void => {
|
|
243
364
|
cursor.index++
|
|
365
|
+
const start = cursor.index
|
|
244
366
|
skipBalanced(cursor, '(', ')')
|
|
367
|
+
if (!innerClosesLast(cursor.source.slice(start, cursor.index))) {
|
|
368
|
+
throw new ShellParseError('a command substitution that opens with a subshell')
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const arithmetic: Handler = (cursor, state) => {
|
|
373
|
+
if (!startsArithmetic(cursor)) return false
|
|
374
|
+
skipArithmetic(cursor)
|
|
245
375
|
appendShaped(state, EXPANSION)
|
|
246
376
|
return true
|
|
247
377
|
}
|
|
@@ -252,8 +382,22 @@ const commandSubstitution: Handler = (cursor, state) => {
|
|
|
252
382
|
return true
|
|
253
383
|
}
|
|
254
384
|
|
|
255
|
-
/**
|
|
256
|
-
|
|
385
|
+
/**
|
|
386
|
+
* Inside double quotes and here-document bodies a `${…}` stays text the parse does not follow, so this only refuses
|
|
387
|
+
* one that evaluates its value; it never consumes the text and leaves it to the next handler.
|
|
388
|
+
*/
|
|
389
|
+
const evaluatingExpansion: Handler = (cursor) => {
|
|
390
|
+
if (peek(cursor) !== '$' || peek(cursor, 1) !== '{') return false
|
|
391
|
+
const close = cursor.source.indexOf('}', cursor.index)
|
|
392
|
+
assertNotEvaluating(cursor.source.slice(cursor.index, close === -1 ? undefined : close + 1))
|
|
393
|
+
return false
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Inside double quotes only substitutions still open a command. */
|
|
397
|
+
const QUOTED_HANDLERS: Handler[] = [evaluatingExpansion, backtick, commandSubstitution]
|
|
398
|
+
|
|
399
|
+
/** In an expanding here-document body an escape keeps the next character text; substitutions run. */
|
|
400
|
+
const BODY_HANDLERS: Handler[] = [escapedChar, evaluatingExpansion, backtick, arithmetic, commandSubstitution]
|
|
257
401
|
|
|
258
402
|
const doubleQuoted: Handler = (cursor, state) => {
|
|
259
403
|
if (peek(cursor) !== '"') return false
|
|
@@ -284,8 +428,10 @@ const localeQuoted: Handler = (cursor, state) => {
|
|
|
284
428
|
const parameterExpansion: Handler = (cursor, state) => {
|
|
285
429
|
if (peek(cursor) !== '$') return false
|
|
286
430
|
if (peek(cursor, 1) === '{') {
|
|
431
|
+
const start = cursor.index
|
|
287
432
|
cursor.index++
|
|
288
433
|
skipBalanced(cursor, '{', '}')
|
|
434
|
+
assertNotEvaluating(cursor.source.slice(start, cursor.index))
|
|
289
435
|
} else cursor.index++
|
|
290
436
|
appendShaped(state, EXPANSION)
|
|
291
437
|
return true
|
|
@@ -317,7 +463,8 @@ const hereDocument: Handler = (cursor, state) => {
|
|
|
317
463
|
return true
|
|
318
464
|
}
|
|
319
465
|
|
|
320
|
-
const isFileDescriptor = (word: Word | undefined): boolean =>
|
|
466
|
+
const isFileDescriptor = (word: Word | undefined): boolean =>
|
|
467
|
+
word !== undefined && word.literal && /^\d+$/.test(word.value)
|
|
321
468
|
|
|
322
469
|
const redirection: Handler = (cursor, state) => {
|
|
323
470
|
const char = peek(cursor)
|
|
@@ -402,6 +549,6 @@ const parseList = (cursor: Cursor, terminator: Terminator): SimpleCommand[] => {
|
|
|
402
549
|
* Splits a shell script into its simple commands, the way a permission rule sees them: at `&&`,
|
|
403
550
|
* `||`, `;`, `|`, `&` and newlines, and into subshells, command substitutions and process
|
|
404
551
|
* substitutions. Here-document bodies, comments and redirection targets are not commands and are
|
|
405
|
-
* dropped. Throws `ShellParseError` on an unterminated quote or substitution.
|
|
552
|
+
* dropped, except the substitutions in the body of a here-document with an unquoted delimiter, which run. Throws `ShellParseError` on an unterminated quote or substitution.
|
|
406
553
|
*/
|
|
407
554
|
export const parseShell = (script: string): SimpleCommand[] => parseList({ source: script, index: 0 }, undefined)
|
package/kit/hooks/hooks.json
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"matcher": "Bash",
|
|
40
40
|
"handler": {
|
|
41
41
|
"type": "command",
|
|
42
|
-
"command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/merge-guard/index.ts '{{kit.guard.selfMergeLabel}}' || exit 2",
|
|
42
|
+
"command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/merge-guard/index.ts '{{kit.guard.selfMergeLabel}}' '{{kit.guard.planApproval.approve}}' || exit 2",
|
|
43
43
|
"timeout": 10,
|
|
44
44
|
"onFailure": "block"
|
|
45
45
|
}
|
|
@@ -61,12 +61,21 @@
|
|
|
61
61
|
"timeout": 10
|
|
62
62
|
}
|
|
63
63
|
},
|
|
64
|
+
"plan-approval": {
|
|
65
|
+
"event": "PostToolUse",
|
|
66
|
+
"matcher": "AskUserQuestion",
|
|
67
|
+
"handler": {
|
|
68
|
+
"type": "command",
|
|
69
|
+
"command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/merge-guard/plan-approval-record.ts '{{kit.guard.planApproval.approve}}' '{{kit.guard.planApproval.revoke}}'",
|
|
70
|
+
"timeout": 10
|
|
71
|
+
}
|
|
72
|
+
},
|
|
64
73
|
"watch-guard": {
|
|
65
74
|
"event": "PreToolUse",
|
|
66
75
|
"matcher": "Bash",
|
|
67
76
|
"handler": {
|
|
68
77
|
"type": "command",
|
|
69
|
-
"command": "input=$(cat); case \"$input\" in *watch*) printf '%s' \"$input\" | bun
|
|
78
|
+
"command": "cd \"$CLAUDE_PROJECT_DIR\" && { input=$(cat); case \"$input\" in *watch*) printf '%s' \"$input\" | bun .claude/hooks/watch-guard/index.ts;; esac; }",
|
|
70
79
|
"timeout": 10
|
|
71
80
|
}
|
|
72
81
|
}
|