jackherd 1.7.1 → 1.8.1
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 +47 -0
- package/INSTALL.md +2 -0
- package/{NOTICE → NOTICE.txt} +1 -1
- package/README.md +86 -1037
- package/assets/branding/README.md +5 -7
- package/assets/branding/jackherd-logo-outline.svg +1 -0
- package/assets/branding/outline.test.ts +30 -0
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-OBUWTQ6Z.js → chunk-OU2KALXP.js} +26 -10
- package/dist/config.d.ts +11 -5
- package/dist/config.js +53 -21
- package/dist/plan-format.d.ts +75 -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 +344 -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/merge-guard/xargs-program.ts +182 -0
- 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 +156 -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 +4 -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/read-config.ts +16 -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-standing.ts +5 -4
- 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/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 +2 -0
- package/src/update/config-shares.ts +30 -0
- package/src/update/update.ts +12 -1
- package/src/vocabulary/index.ts +51 -11
- package/src/watch/appointments.ts +206 -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/assets/icons/bark-dark.svg +0 -1
- package/assets/icons/bowl-dark.svg +0 -1
- package/assets/icons/decide-dark.svg +0 -1
- package/assets/icons/ear-dark.svg +0 -1
- package/assets/icons/lanes-dark.svg +0 -1
- package/assets/icons/plan-dark.svg +0 -1
- package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { posix } from 'node:path'
|
|
2
|
-
import type
|
|
2
|
+
import { isExpanded, type SimpleCommand, type Word } from './shell'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Words Claude Code strips before it matches a Bash rule, so they do not hide the command. Its
|
|
@@ -11,10 +11,24 @@ export const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/
|
|
|
11
11
|
const DURATION = /^\d+(\.\d+)?[smhd]?$/
|
|
12
12
|
const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'mksh', 'fish'])
|
|
13
13
|
const SHELL_COMMAND_FLAG = /^-[a-z]*c[a-z]*$/
|
|
14
|
+
const FUNCTION_KEYWORD = 'function'
|
|
15
|
+
/** zsh's precommand, `- eval …`; as a word it is also stdin (`cat -`), so it counts only where a command stands. */
|
|
16
|
+
const ZSH_PRECOMMAND = '-'
|
|
17
|
+
/**
|
|
18
|
+
* Shell words that run a command after names or counts of their own: `function` opens a body (`function f g {`),
|
|
19
|
+
* `coproc` may take a name, zsh's `repeat` a count and `nocorrect` none.
|
|
20
|
+
*/
|
|
21
|
+
const SHELL_RUNNERS = [FUNCTION_KEYWORD, 'coproc', 'repeat', 'nocorrect']
|
|
22
|
+
const SHELL_COMMAND_OPENERS = new Set([...SHELL_RUNNERS, ZSH_PRECOMMAND])
|
|
23
|
+
/** The builtin that runs its arguments, joined, as a script in the same shell. */
|
|
24
|
+
const EVAL_BUILTIN = 'eval'
|
|
25
|
+
/** The builtin that runs its action text later in the same shell. */
|
|
26
|
+
const TRAP_BUILTIN = 'trap'
|
|
14
27
|
|
|
15
28
|
/** Programs that run their arguments as a command, so a program right after them is the one that runs. */
|
|
16
29
|
const COMMAND_RUNNERS = new Set([
|
|
17
30
|
...RULE_TRANSPARENT_WRAPPERS,
|
|
31
|
+
...SHELL_RUNNERS,
|
|
18
32
|
'env',
|
|
19
33
|
'sudo',
|
|
20
34
|
'doas',
|
|
@@ -67,16 +81,145 @@ const shellCommandScript = (words: SimpleCommand, shellIndex: number): string |
|
|
|
67
81
|
return words.slice(flag + 1).find((word) => !isOption(word))?.value
|
|
68
82
|
}
|
|
69
83
|
|
|
84
|
+
/** The action of `trap <action> <signal>…`: the first word after the options and `--`; the shell runs it later itself. */
|
|
85
|
+
const trapAction = (words: SimpleCommand, trapIndex: number): string | undefined => {
|
|
86
|
+
const rest = words.slice(trapIndex + 1)
|
|
87
|
+
const start = rest.findIndex((word) => !isOption(word) || word.value === '--')
|
|
88
|
+
const action = rest[start]?.value === '--' ? rest[start + 1] : rest[start]
|
|
89
|
+
return action?.value
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether a word before a builtin leaves it the command: an option or its value, an assignment, a duration
|
|
94
|
+
* (`sudo -u me`, `env X=1`, `timeout 30`, `repeat 1`), a reserved word, or the name after `function`.
|
|
95
|
+
*/
|
|
96
|
+
const leadsToBuiltin = (words: SimpleCommand, index: number): boolean => {
|
|
97
|
+
const { value } = words[index]
|
|
98
|
+
const previous = words[index - 1]
|
|
99
|
+
return (
|
|
100
|
+
isOption(words[index]) ||
|
|
101
|
+
isOption(previous) ||
|
|
102
|
+
ASSIGNMENT.test(value) ||
|
|
103
|
+
DURATION.test(value) ||
|
|
104
|
+
RESERVED_WORDS.has(value) ||
|
|
105
|
+
(previous !== undefined && programName(previous) === FUNCTION_KEYWORD)
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Where in a command a builtin runs as the command itself: at the rule start, right after a runner that is itself the
|
|
111
|
+
* command (with its options), or anywhere after a shell word that opens a command and stands as the command itself,
|
|
112
|
+
* whatever names or counts follow it (`function f g {`, `coproc NAME {`, `repeat $n`, zsh's `- eval`). No external
|
|
113
|
+
* runner starts a builtin from a later argument, so `timeout 30 rg eval` searches for the word. One pass per command; each word looks back only to the runner before it.
|
|
114
|
+
*/
|
|
115
|
+
const builtinPositions = (words: SimpleCommand): boolean[] => {
|
|
116
|
+
const start = ruleStart(words)
|
|
117
|
+
const positions: boolean[] = []
|
|
118
|
+
let afterOpener = false
|
|
119
|
+
for (let index = 0; index < words.length; index++) {
|
|
120
|
+
let before = index - 1
|
|
121
|
+
while (before >= 0 && !COMMAND_RUNNERS.has(programName(words[before])) && leadsToBuiltin(words, before)) before--
|
|
122
|
+
const afterRunner = before >= 0 && COMMAND_RUNNERS.has(programName(words[before])) && positions[before]
|
|
123
|
+
positions[index] = index === start || afterRunner || afterOpener
|
|
124
|
+
if (positions[index] && SHELL_COMMAND_OPENERS.has(words[index].value)) afterOpener = true
|
|
125
|
+
}
|
|
126
|
+
return positions
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One pass per command, since every word of it is asked: the positions of a command are its own. */
|
|
130
|
+
const positionsByCommand = new WeakMap<SimpleCommand, boolean[]>()
|
|
131
|
+
|
|
132
|
+
const isBuiltinPosition = (words: SimpleCommand, index: number): boolean => {
|
|
133
|
+
const known = positionsByCommand.get(words)
|
|
134
|
+
if (known) return known[index]
|
|
135
|
+
const positions = builtinPositions(words)
|
|
136
|
+
positionsByCommand.set(words, positions)
|
|
137
|
+
return positions[index]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Whether the word runs as `builtin`: named so where a command stands, also quoted (`'trap'`) or escaped (`\trap`). */
|
|
141
|
+
const isBuiltinAt = (words: SimpleCommand, index: number, builtin: string): boolean =>
|
|
142
|
+
programName(words[index]) === builtin && isBuiltinPosition(words, index)
|
|
143
|
+
|
|
70
144
|
export const inlineScript = (words: SimpleCommand, index: number): string | undefined => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
145
|
+
if (isBuiltinAt(words, index, EVAL_BUILTIN))
|
|
146
|
+
return words
|
|
147
|
+
.slice(index + 1)
|
|
148
|
+
.map(({ value }) => value)
|
|
149
|
+
.join(' ')
|
|
150
|
+
if (isBuiltinAt(words, index, TRAP_BUILTIN)) return trapAction(words, index)
|
|
151
|
+
return isShell(words[index]) ? shellCommandScript(words, index) : undefined
|
|
74
152
|
}
|
|
75
153
|
|
|
154
|
+
/** Interpreters that run program text given on the command line or read from their input. */
|
|
155
|
+
const INTERPRETERS = new Set(['python', 'python3', 'node', 'nodejs', 'bun', 'deno', 'perl', 'ruby', 'php', 'lua'])
|
|
156
|
+
const INLINE_PROGRAM_OPTIONS = new Set(['-c', '-e', '-E', '-p', '-pe', '--eval', '--command', '--print', '-r'])
|
|
157
|
+
const VERSIONED_PYTHON = /^python\d+(\.\d+)*$/
|
|
158
|
+
/** A cluster of short options that ends in the one taking the program text: `python3 -Ic`, `perl -lne`. */
|
|
159
|
+
const INLINE_PROGRAM_CLUSTER = /^-[A-Za-z]*[ce]$/
|
|
160
|
+
/** An operand that names the interpreter's own input as the program file. */
|
|
161
|
+
const INPUT_OPERAND = /^(-|\/dev\/stdin|\/dev\/fd\/0|\/proc\/self\/fd\/0)$/
|
|
162
|
+
/** Options that print and exit, so an interpreter without an operand reads no program. */
|
|
163
|
+
const PRINT_AND_EXIT = new Set(['--version', '-v', '-V', '--help', '-h'])
|
|
164
|
+
/** Interpreters that, without an operand, print their help instead of reading a program; they read one after `run`. */
|
|
165
|
+
const SUBCOMMAND_INTERPRETERS = new Set(['bun'])
|
|
166
|
+
const RUN_SUBCOMMAND = 'run'
|
|
167
|
+
/** An operand that names a script file: the words after it are the script's arguments, not the interpreter's options. */
|
|
168
|
+
const SCRIPT_FILE = /\.(py|js|mjs|cjs|ts|mts|cts|jsx|tsx|rb|pl|pm|php|lua)$/i
|
|
169
|
+
|
|
170
|
+
const isInterpreter = (word: Word): boolean => {
|
|
171
|
+
const name = programName(word)
|
|
172
|
+
return INTERPRETERS.has(name) || VERSIONED_PYTHON.test(name)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const isInlineProgramOption = ({ value }: Word): boolean =>
|
|
176
|
+
INLINE_PROGRAM_OPTIONS.has(value.split('=')[0]) || INLINE_PROGRAM_CLUSTER.test(value)
|
|
177
|
+
|
|
178
|
+
/** The operand that names the program: the first one, or for `bun run` the one after `run`; `-` counts as one. */
|
|
179
|
+
const programOperand = (interpreter: Word, rest: Word[]): Word | undefined => {
|
|
180
|
+
const operands = rest.filter((word) => !isOption(word) || word.value === '-')
|
|
181
|
+
const afterRun = SUBCOMMAND_INTERPRETERS.has(programName(interpreter)) && operands[0]?.value === RUN_SUBCOMMAND
|
|
182
|
+
return afterRun ? operands[1] : operands[0]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Whether the interpreter reads its program from its input (`python3 -`, `node` fed by a pipe, `bun -`) or from an
|
|
187
|
+
* expansion it cannot show (`python3 <(…)`).
|
|
188
|
+
*/
|
|
189
|
+
const readsProgramFromInput = (interpreter: Word, rest: Word[]): boolean => {
|
|
190
|
+
const operand = programOperand(interpreter, rest)
|
|
191
|
+
if (operand === undefined) {
|
|
192
|
+
return (
|
|
193
|
+
!SUBCOMMAND_INTERPRETERS.has(programName(interpreter)) && !rest.some(({ value }) => PRINT_AND_EXIT.has(value))
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
// A quoted operand is still a file name; only an expansion (`$f`, `<(…)`) hides where the program comes from.
|
|
197
|
+
return isExpanded(operand.value) || INPUT_OPERAND.test(operand.value)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The words up to a script file operand. Only a file name ends them: any other word could be an option's value
|
|
202
|
+
* (`python3 -W ignore -c …`), and stopping there would miss the `-c` behind it.
|
|
203
|
+
*/
|
|
204
|
+
const interpreterOptions = (rest: Word[]): Word[] => {
|
|
205
|
+
const scriptFile = rest.findIndex((word) => !isOption(word) && SCRIPT_FILE.test(word.value))
|
|
206
|
+
return scriptFile === -1 ? rest : rest.slice(0, scriptFile)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* An interpreter, anywhere a program can stand, handed program text (`python3 -c`, `node -e`) or reading it from its
|
|
211
|
+
* input (`python3 - <<EOF`, `… | node`): the program can start any command, and no parse of the shell sees it.
|
|
212
|
+
*/
|
|
213
|
+
export const runsInterpreterProgram = (words: SimpleCommand): boolean =>
|
|
214
|
+
words.some((word, index) => {
|
|
215
|
+
if (!isProgramPosition(words, index) || !isInterpreter(word)) return false
|
|
216
|
+
const rest = words.slice(index + 1)
|
|
217
|
+
return interpreterOptions(rest).some(isInlineProgramOption) || readsProgramFromInput(word, rest)
|
|
218
|
+
})
|
|
219
|
+
|
|
76
220
|
/** A shell at the rule start with no script argument reads its script from stdin: a pipe, a here-document. */
|
|
77
221
|
export const readsScriptFromInput = (words: SimpleCommand): boolean => {
|
|
78
222
|
const start = ruleStart(words)
|
|
79
223
|
const shell = words[start]
|
|
80
224
|
return shell !== undefined && isShell(shell) && words.slice(start + 1).every(isOption)
|
|
81
225
|
}
|
|
82
|
-
|
|
@@ -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
|
+
}
|