jackherd 0.0.1 → 1.0.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 +61 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +1121 -2
- package/assets/branding/README.md +62 -0
- package/assets/branding/jackherd-icon-256.png +0 -0
- package/assets/branding/jackherd-icon-512.png +0 -0
- package/assets/branding/jackherd-icon-64.png +0 -0
- package/assets/branding/jackherd-icon-small-32.png +0 -0
- package/assets/branding/jackherd-icon-small-64.png +0 -0
- package/assets/branding/jackherd-icon-small.svg +1 -0
- package/assets/branding/jackherd-icon.svg +1 -0
- package/assets/branding/jackherd-logo-1024.png +0 -0
- package/assets/branding/jackherd-logo-512.png +0 -0
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -0
- package/assets/branding/jackherd-logo-mark-1024.png +0 -0
- package/assets/branding/jackherd-logo-mark-512.png +0 -0
- package/assets/branding/jackherd-logo-mark.svg +1 -0
- package/assets/branding/jackherd-logo.svg +1 -0
- package/dist/chunk-R6MGHCD7.js +93 -0
- package/dist/config.d.ts +167 -0
- package/dist/config.js +401 -0
- package/dist/plan-format.d.ts +325 -0
- package/dist/plan-format.js +636 -0
- package/docs/lanes.mmd +9 -0
- package/docs/lanes.svg +1 -0
- package/docs/skills.mmd +10 -0
- package/docs/skills.svg +1 -0
- package/docs/workflow.svg +172 -0
- package/herdr-plugin/herdr-plugin.toml +22 -0
- package/herdr-plugin/open-plans.ts +43 -0
- package/herdr-plugin/restore-plans.ts +107 -0
- package/install.sh +157 -0
- package/kit/hooks/hooks/git-guard/decision.ts +438 -0
- package/kit/hooks/hooks/git-guard/index.ts +38 -0
- package/kit/hooks/hooks/git-guard/shell.ts +407 -0
- package/kit/hooks/hooks/watch-guard/background.ts +36 -0
- package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
- package/kit/hooks/hooks/watch-guard/index.ts +11 -0
- package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
- package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
- package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
- package/kit/hooks/hooks.json +30 -0
- package/kit/hooks/statusline.sh +72 -0
- package/kit/review/agents/devops.md +11 -0
- package/kit/review/agents/public-content.md +39 -0
- package/kit/review/agents/security-audit.md +11 -0
- package/kit/review/agents/uncle-bob.md +11 -0
- package/kit/review/skills/devops/SKILL.md +50 -0
- package/kit/review/skills/diagnose/SKILL.md +140 -0
- package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
- package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
- package/kit/review/skills/pre-commit/SKILL.md +100 -0
- package/kit/review/skills/review/SKILL.md +43 -0
- package/kit/review/skills/security-audit/SKILL.md +65 -0
- package/kit/review/skills/uncle-bob/SKILL.md +43 -0
- package/kit/rules/rules/kit/arc42.md +89 -0
- package/kit/rules/rules/kit/architecture-files.md +87 -0
- package/kit/rules/rules/kit/code-style.md +159 -0
- package/kit/rules/rules/kit/pull-requests.md +59 -0
- package/kit/rules/rules/kit/testing.md +91 -0
- package/kit/rules/rules/kit/writing-for-agents.md +110 -0
- package/kit/workflow/agents/explore.md +25 -0
- package/kit/workflow/agents/implement.md +16 -0
- package/kit/workflow/agents/plan.md +13 -0
- package/kit/workflow/agents/spec-review.md +96 -0
- package/kit/workflow/skills/follow/SKILL.md +48 -0
- package/kit/workflow/skills/grill/SKILL.md +60 -0
- package/kit/workflow/skills/implement/SKILL.md +107 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
- package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
- package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
- package/kit/workflow/skills/plan/SKILL.md +118 -0
- package/kit/workflow/skills/plan/references/template-English.md +92 -0
- package/kit/workflow/skills/plan/references/template-German.md +92 -0
- package/kit/workflow/skills/start/SKILL.md +47 -0
- package/package.json +63 -5
- package/schemas/jackherd.schema.json +601 -0
- package/src/check/changes.ts +45 -0
- package/src/check/command-line.ts +12 -0
- package/src/check/command.ts +136 -0
- package/src/check/deferral-arrows.ts +41 -0
- package/src/check/execute.ts +29 -0
- package/src/check/glob.ts +15 -0
- package/src/check/plan-structure-check.ts +135 -0
- package/src/check/plan.ts +106 -0
- package/src/check/public-content-check.ts +32 -0
- package/src/check/public-content-facts.ts +177 -0
- package/src/check/public-content.ts +216 -0
- package/src/check/report.ts +55 -0
- package/src/check/result.ts +42 -0
- package/src/check/roots.ts +36 -0
- package/src/check/routing.ts +22 -0
- package/src/check/schema.ts +83 -0
- package/src/check/settings.ts +44 -0
- package/src/claude/paths.ts +14 -0
- package/src/cli.ts +46 -0
- package/src/config/command.ts +45 -0
- package/src/config/detect.ts +196 -0
- package/src/config/index.ts +2 -0
- package/src/config/json-schema.ts +5 -0
- package/src/config/legacy-names.ts +56 -0
- package/src/config/link-kinds.ts +25 -0
- package/src/config/location.ts +46 -0
- package/src/config/read-config.ts +42 -0
- package/src/config/repository-facts.ts +19 -0
- package/src/config/repository-values.ts +46 -0
- package/src/config/schema.ts +302 -0
- package/src/context/command.ts +39 -0
- package/src/context/coordinator-name.ts +16 -0
- package/src/context/fill.ts +69 -0
- package/src/context/index.ts +10 -0
- package/src/context/level.ts +22 -0
- package/src/context/options.ts +29 -0
- package/src/context/report.ts +42 -0
- package/src/context/session-log.ts +91 -0
- package/src/plan-format/drift.ts +69 -0
- package/src/plan-format/header.ts +27 -0
- package/src/plan-format/index.ts +9 -0
- package/src/plan-format/lanes.ts +128 -0
- package/src/plan-format/markdown.ts +98 -0
- package/src/plan-format/phase-tally.ts +55 -0
- package/src/plan-format/phases.ts +224 -0
- package/src/plan-format/plan-structure.ts +150 -0
- package/src/plan-format/progress.ts +202 -0
- package/src/plan-format/sprint-document.ts +69 -0
- package/src/plan-format/terminal-text.ts +28 -0
- package/src/plans/board-choice.ts +60 -0
- package/src/plans/board-pick.ts +72 -0
- package/src/plans/board-rows.ts +62 -0
- package/src/plans/command.ts +60 -0
- package/src/plans/ensure-panes.ts +71 -0
- package/src/plans/error-text.ts +5 -0
- package/src/plans/fetch.ts +96 -0
- package/src/plans/git.ts +145 -0
- package/src/plans/github.ts +35 -0
- package/src/plans/lane-discovery.ts +34 -0
- package/src/plans/lane-plans.ts +80 -0
- package/src/plans/layout.ts +881 -0
- package/src/plans/link-chips.ts +74 -0
- package/src/plans/live.ts +336 -0
- package/src/plans/markdown-html.ts +140 -0
- package/src/plans/options.ts +15 -0
- package/src/plans/phase-source.ts +71 -0
- package/src/plans/plan-chips.ts +75 -0
- package/src/plans/plan-panes.ts +206 -0
- package/src/plans/planned.ts +117 -0
- package/src/plans/pull-requests.ts +107 -0
- package/src/plans/restart-decision.ts +42 -0
- package/src/plans/selection.ts +352 -0
- package/src/plans/settings.ts +68 -0
- package/src/plans/single-flight.ts +25 -0
- package/src/plans/snapshot.ts +300 -0
- package/src/plans/sprint-export.ts +103 -0
- package/src/plans/sprint-html.ts +301 -0
- package/src/plans/sprint-page.ts +287 -0
- package/src/plans/supervisor.ts +124 -0
- package/src/plans/symbols.ts +54 -0
- package/src/plans/texts.ts +103 -0
- package/src/plans/unlisted-lanes.ts +45 -0
- package/src/plans/view.ts +68 -0
- package/src/plans/worked-phases.ts +53 -0
- package/src/sync/checksum.ts +13 -0
- package/src/sync/choice.ts +14 -0
- package/src/sync/command-context.ts +113 -0
- package/src/sync/command.ts +94 -0
- package/src/sync/error-message.ts +1 -0
- package/src/sync/gitignore.ts +45 -0
- package/src/sync/global-kit.ts +176 -0
- package/src/sync/herdr-plugin.ts +175 -0
- package/src/sync/if-changed.ts +109 -0
- package/src/sync/init-command.ts +239 -0
- package/src/sync/init.ts +136 -0
- package/src/sync/kit-hooks.ts +18 -0
- package/src/sync/kit-path.ts +77 -0
- package/src/sync/kit-source.ts +169 -0
- package/src/sync/kit.ts +9 -0
- package/src/sync/legacy-files.ts +116 -0
- package/src/sync/manifest.ts +44 -0
- package/src/sync/next-steps.ts +42 -0
- package/src/sync/plan-file.ts +154 -0
- package/src/sync/plan-hooks.ts +165 -0
- package/src/sync/project-fs.ts +132 -0
- package/src/sync/render.ts +60 -0
- package/src/sync/report.ts +96 -0
- package/src/sync/settings-hooks.ts +90 -0
- package/src/sync/settings-migration.ts +94 -0
- package/src/sync/settings-offer.ts +168 -0
- package/src/sync/setup-questions.ts +70 -0
- package/src/sync/setup.ts +52 -0
- package/src/sync/sync-plan.ts +120 -0
- package/src/sync/sync-result.ts +9 -0
- package/src/sync/sync.ts +307 -0
- package/src/sync/tools.ts +52 -0
- package/src/terminal/herdr.ts +91 -0
- package/src/terminal/index.ts +13 -0
- package/src/terminal/none.ts +7 -0
- package/src/terminal/open.ts +39 -0
- package/src/terminal/text.ts +159 -0
- package/src/terminal/types.ts +30 -0
- package/src/terminal/width.ts +90 -0
- package/src/update/command.ts +65 -0
- package/src/update/package-manager.ts +56 -0
- package/src/update/update.ts +105 -0
- package/src/version/installed-version.ts +32 -0
- package/src/version/release.ts +22 -0
- package/src/version/skill-diff.ts +63 -0
- package/src/version/started-kit.ts +14 -0
- package/src/vocabulary/index.ts +132 -0
- package/src/watch/base-tip.ts +18 -0
- package/src/watch/command.ts +919 -0
- package/src/watch/coordinator-fill.ts +76 -0
- package/src/watch/feedback-read.ts +73 -0
- package/src/watch/feedback.ts +308 -0
- package/src/watch/gh.ts +22 -0
- package/src/watch/github-read.ts +175 -0
- package/src/watch/herdr.ts +11 -0
- package/src/watch/json-guards.ts +9 -0
- package/src/watch/kit-update.ts +18 -0
- package/src/watch/lane-fill.ts +36 -0
- package/src/watch/lane-message.ts +99 -0
- package/src/watch/lane-progress.ts +44 -0
- package/src/watch/lane-report-file.ts +53 -0
- package/src/watch/lane-report.ts +270 -0
- package/src/watch/lane-search.ts +41 -0
- package/src/watch/lane-sources.ts +46 -0
- package/src/watch/lane-watch.ts +121 -0
- package/src/watch/monitor-tool.ts +19 -0
- package/src/watch/options.ts +79 -0
- package/src/watch/plan-badges-read.ts +30 -0
- package/src/watch/plan-badges.ts +66 -0
- package/src/watch/plan-views.ts +59 -0
- package/src/watch/preview-read.ts +60 -0
- package/src/watch/previews.ts +234 -0
- package/src/watch/pull-requests.ts +117 -0
- package/src/watch/single-report.ts +15 -0
- package/src/watch/skill-change.ts +61 -0
- package/src/watch/source-dry-run-command.ts +35 -0
- package/src/watch/source-dry-run.ts +124 -0
- package/src/watch/source-fields.ts +9 -0
- package/src/watch/source-path.ts +40 -0
- package/src/watch/source-run.ts +90 -0
- package/src/watch/sources.ts +344 -0
- package/src/watch/stream-output.ts +14 -0
- package/src/watch/texts.ts +76 -0
- package/src/watch/watch-events.ts +399 -0
- package/src/watch/watcher-lock.ts +145 -0
- package/src/watch/workflow-runs.ts +96 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One word of a simple command, with quotes and escapes removed. `literal` is false when quoting,
|
|
3
|
+
* an escape, an expansion or a line continuation shaped the word, because then the text a
|
|
4
|
+
* permission rule matches is not `value`.
|
|
5
|
+
*/
|
|
6
|
+
export type Word = { value: string; literal: boolean }
|
|
7
|
+
|
|
8
|
+
export type SimpleCommand = Word[]
|
|
9
|
+
|
|
10
|
+
type Cursor = { source: string; index: number }
|
|
11
|
+
|
|
12
|
+
/** `expands` when the delimiter is unquoted: the shell then runs `$(…)` and backticks in the body. */
|
|
13
|
+
type Heredoc = { delimiter: string; stripTabs: boolean; expands: boolean }
|
|
14
|
+
|
|
15
|
+
type ListState = {
|
|
16
|
+
commands: SimpleCommand[]
|
|
17
|
+
words: Word[]
|
|
18
|
+
word: Word | undefined
|
|
19
|
+
nextWordContinued: boolean
|
|
20
|
+
dropNextWord: boolean
|
|
21
|
+
heredocs: Heredoc[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Terminator = ')' | '`' | undefined
|
|
25
|
+
|
|
26
|
+
type Handler = (cursor: Cursor, state: ListState) => boolean
|
|
27
|
+
|
|
28
|
+
export class ShellParseError extends Error {}
|
|
29
|
+
|
|
30
|
+
const BLANKS = ' \t'
|
|
31
|
+
const OPERATORS = '&;|'
|
|
32
|
+
const REDIRECTION_CHARS = '<>&|'
|
|
33
|
+
const DELIMITER_END = ' \t\n;&|<>()'
|
|
34
|
+
|
|
35
|
+
/** Marks where an expansion stood in a word (`$z` keeps its name after it): the value is unknown before the shell runs. */
|
|
36
|
+
const EXPANSION = '$'
|
|
37
|
+
|
|
38
|
+
const peek = (cursor: Cursor, offset = 0): string => cursor.source[cursor.index + offset] ?? ''
|
|
39
|
+
|
|
40
|
+
/** Whether the character is one of `chars`; the empty string past the end of input never is. */
|
|
41
|
+
const isOneOf = (chars: string, char: string): boolean => char !== '' && chars.includes(char)
|
|
42
|
+
|
|
43
|
+
const atEnd = (cursor: Cursor): boolean => cursor.index >= cursor.source.length
|
|
44
|
+
|
|
45
|
+
const newListState = (): ListState => ({
|
|
46
|
+
commands: [],
|
|
47
|
+
words: [],
|
|
48
|
+
word: undefined,
|
|
49
|
+
nextWordContinued: false,
|
|
50
|
+
dropNextWord: false,
|
|
51
|
+
heredocs: [],
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const currentWord = (state: ListState): Word => {
|
|
55
|
+
if (!state.word) {
|
|
56
|
+
state.word = { value: '', literal: !state.nextWordContinued }
|
|
57
|
+
state.nextWordContinued = false
|
|
58
|
+
}
|
|
59
|
+
return state.word
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const appendShaped = (state: ListState, text: string): void => {
|
|
63
|
+
const word = currentWord(state)
|
|
64
|
+
word.value += text
|
|
65
|
+
word.literal = false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `{a,b}` and `{a..b}` expand into other words before the program sees them. */
|
|
69
|
+
const BRACE_EXPANSION = /\{[^}]*(,|\.\.)[^}]*\}/
|
|
70
|
+
|
|
71
|
+
const endWord = (state: ListState): void => {
|
|
72
|
+
const { word } = state
|
|
73
|
+
if (!word) return
|
|
74
|
+
if (BRACE_EXPANSION.test(word.value)) word.literal = false
|
|
75
|
+
state.word = undefined
|
|
76
|
+
if (state.dropNextWord) state.dropNextWord = false
|
|
77
|
+
else state.words.push(word)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const endCommand = (state: ListState): void => {
|
|
81
|
+
endWord(state)
|
|
82
|
+
state.dropNextWord = false
|
|
83
|
+
if (state.words.length > 0) state.commands.push(state.words)
|
|
84
|
+
state.words = []
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Text in `${…}` or `$((…))` that could hold or end a command: a substitution, a quote or an
|
|
89
|
+
* escape (which the plain bracket count below cannot follow), an operator or a newline.
|
|
90
|
+
*/
|
|
91
|
+
const MAYBE_COMMAND = /\$\(|[<>]\(|[`'";&|\n\\]/
|
|
92
|
+
|
|
93
|
+
/** Rather than parse a command inside an expansion, the whole text is refused as unparsable. */
|
|
94
|
+
const assertNoHiddenCommand = (text: string): void => {
|
|
95
|
+
if (MAYBE_COMMAND.test(text)) throw new ShellParseError('a command or quote inside an expansion')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const skipBalanced = (cursor: Cursor, open: string, close: string): void => {
|
|
99
|
+
const start = cursor.index
|
|
100
|
+
let depth = 0
|
|
101
|
+
while (!atEnd(cursor)) {
|
|
102
|
+
const char = peek(cursor)
|
|
103
|
+
cursor.index++
|
|
104
|
+
if (char === open) depth++
|
|
105
|
+
if (char === close && --depth === 0) return assertNoHiddenCommand(cursor.source.slice(start, cursor.index))
|
|
106
|
+
}
|
|
107
|
+
throw new ShellParseError(`missing ${close}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const SUBSTITUTION = /\$\(|`/
|
|
111
|
+
|
|
112
|
+
/** Skips the body. A substitution in an expanding body runs, so it is refused as unparsable. */
|
|
113
|
+
const readHeredocBody = (cursor: Cursor, { delimiter, stripTabs, expands }: Heredoc): void => {
|
|
114
|
+
while (!atEnd(cursor)) {
|
|
115
|
+
const newline = cursor.source.indexOf('\n', cursor.index)
|
|
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')
|
|
121
|
+
}
|
|
122
|
+
throw new ShellParseError(`here-document without its ${delimiter} line`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const readHeredocHeader = (cursor: Cursor, stripTabs: boolean): Heredoc => {
|
|
126
|
+
let delimiter = ''
|
|
127
|
+
let quoted = false
|
|
128
|
+
while (!atEnd(cursor) && !isOneOf(DELIMITER_END, peek(cursor))) {
|
|
129
|
+
const char = peek(cursor)
|
|
130
|
+
if (isOneOf('\'"\\', char)) quoted = true
|
|
131
|
+
else delimiter += char
|
|
132
|
+
cursor.index++
|
|
133
|
+
}
|
|
134
|
+
if (delimiter === '') throw new ShellParseError('here-document without a delimiter')
|
|
135
|
+
return { delimiter, stripTabs, expands: !quoted }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const lineContinuation: Handler = (cursor, state) => {
|
|
139
|
+
if (peek(cursor) !== '\\' || peek(cursor, 1) !== '\n') return false
|
|
140
|
+
cursor.index += 2
|
|
141
|
+
if (state.word) state.word.literal = false
|
|
142
|
+
else state.nextWordContinued = true
|
|
143
|
+
return true
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const escapedChar: Handler = (cursor, state) => {
|
|
147
|
+
if (peek(cursor) !== '\\') return false
|
|
148
|
+
appendShaped(state, peek(cursor, 1))
|
|
149
|
+
cursor.index += 2
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const blank: Handler = (cursor, state) => {
|
|
154
|
+
if (!isOneOf(BLANKS, peek(cursor))) return false
|
|
155
|
+
endWord(state)
|
|
156
|
+
cursor.index++
|
|
157
|
+
return true
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const newline: Handler = (cursor, state) => {
|
|
161
|
+
if (peek(cursor) !== '\n') return false
|
|
162
|
+
endCommand(state)
|
|
163
|
+
cursor.index++
|
|
164
|
+
for (const heredoc of state.heredocs) readHeredocBody(cursor, heredoc)
|
|
165
|
+
state.heredocs = []
|
|
166
|
+
return true
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const comment: Handler = (cursor, state) => {
|
|
170
|
+
if (peek(cursor) !== '#' || state.word) return false
|
|
171
|
+
while (!atEnd(cursor) && peek(cursor) !== '\n') cursor.index++
|
|
172
|
+
return true
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const singleQuoted: Handler = (cursor, state) => {
|
|
176
|
+
if (peek(cursor) !== "'") return false
|
|
177
|
+
const close = cursor.source.indexOf("'", cursor.index + 1)
|
|
178
|
+
if (close === -1) throw new ShellParseError('unterminated single quote')
|
|
179
|
+
appendShaped(state, cursor.source.slice(cursor.index + 1, close))
|
|
180
|
+
cursor.index = close + 1
|
|
181
|
+
return true
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const ANSI_C_ESCAPES: Record<string, string> = { n: '\n', t: '\t', r: '\r', e: '\u001b', a: '\u0007', b: '\b', f: '\f', v: '\v' }
|
|
185
|
+
const MAX_CODE_POINT = 0x10ffff
|
|
186
|
+
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
|
+
|
|
188
|
+
/** Decodes one escape of a `$'…'` string at the cursor, which stands on the backslash. */
|
|
189
|
+
const readAnsiCEscape = (cursor: Cursor): string => {
|
|
190
|
+
const numeric = cursor.source.slice(cursor.index + 1).match(ANSI_C_NUMERIC)?.[0]
|
|
191
|
+
if (numeric) {
|
|
192
|
+
cursor.index += 1 + numeric.length
|
|
193
|
+
const radix = /^[0-7]/.test(numeric) ? 8 : 16
|
|
194
|
+
const codePoint = Number.parseInt(radix === 8 ? numeric : numeric.slice(1), radix)
|
|
195
|
+
if (codePoint > MAX_CODE_POINT) throw new ShellParseError('an escape beyond Unicode')
|
|
196
|
+
return String.fromCodePoint(codePoint)
|
|
197
|
+
}
|
|
198
|
+
const letter = peek(cursor, 1)
|
|
199
|
+
cursor.index += 2
|
|
200
|
+
return ANSI_C_ESCAPES[letter] ?? letter
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const ansiCQuoted: Handler = (cursor, state) => {
|
|
204
|
+
if (peek(cursor) !== '$' || peek(cursor, 1) !== "'") return false
|
|
205
|
+
cursor.index += 2
|
|
206
|
+
let text = ''
|
|
207
|
+
while (!atEnd(cursor) && peek(cursor) !== "'") {
|
|
208
|
+
if (peek(cursor) === '\\') text += readAnsiCEscape(cursor)
|
|
209
|
+
else {
|
|
210
|
+
text += peek(cursor)
|
|
211
|
+
cursor.index++
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (atEnd(cursor)) throw new ShellParseError('unterminated $-quote')
|
|
215
|
+
cursor.index++
|
|
216
|
+
appendShaped(state, text)
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const substitute = (cursor: Cursor, state: ListState, skip: number, terminator: Terminator): void => {
|
|
221
|
+
cursor.index += skip
|
|
222
|
+
state.commands.push(...parseList(cursor, terminator))
|
|
223
|
+
appendShaped(state, EXPANSION)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const startsCommandSubstitution = (cursor: Cursor): boolean =>
|
|
227
|
+
peek(cursor) === '$' && peek(cursor, 1) === '(' && peek(cursor, 2) !== '('
|
|
228
|
+
|
|
229
|
+
const readDoubleQuotedEscape = (cursor: Cursor, state: ListState): void => {
|
|
230
|
+
const next = peek(cursor, 1)
|
|
231
|
+
if (next !== '\n') appendShaped(state, isOneOf('$`"\\', next) ? next : `\\${next}`)
|
|
232
|
+
cursor.index += 2
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const backtick: Handler = (cursor, state) => {
|
|
236
|
+
if (peek(cursor) !== '`') return false
|
|
237
|
+
substitute(cursor, state, 1, '`')
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const arithmetic: Handler = (cursor, state) => {
|
|
242
|
+
if (peek(cursor) !== '$' || peek(cursor, 1) !== '(' || peek(cursor, 2) !== '(') return false
|
|
243
|
+
cursor.index++
|
|
244
|
+
skipBalanced(cursor, '(', ')')
|
|
245
|
+
appendShaped(state, EXPANSION)
|
|
246
|
+
return true
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const commandSubstitution: Handler = (cursor, state) => {
|
|
250
|
+
if (!startsCommandSubstitution(cursor)) return false
|
|
251
|
+
substitute(cursor, state, 2, ')')
|
|
252
|
+
return true
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Inside double quotes only these two still open a command. */
|
|
256
|
+
const QUOTED_HANDLERS: Handler[] = [backtick, commandSubstitution]
|
|
257
|
+
|
|
258
|
+
const doubleQuoted: Handler = (cursor, state) => {
|
|
259
|
+
if (peek(cursor) !== '"') return false
|
|
260
|
+
appendShaped(state, '')
|
|
261
|
+
cursor.index++
|
|
262
|
+
while (!atEnd(cursor)) {
|
|
263
|
+
const char = peek(cursor)
|
|
264
|
+
if (char === '"') {
|
|
265
|
+
cursor.index++
|
|
266
|
+
return true
|
|
267
|
+
}
|
|
268
|
+
if (char === '\\') readDoubleQuotedEscape(cursor, state)
|
|
269
|
+
else if (!QUOTED_HANDLERS.some((handle) => handle(cursor, state))) {
|
|
270
|
+
appendShaped(state, char)
|
|
271
|
+
cursor.index++
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
throw new ShellParseError('unterminated double quote')
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** `$"…"` is a double-quoted string translated by the locale; the text is the same. */
|
|
278
|
+
const localeQuoted: Handler = (cursor, state) => {
|
|
279
|
+
if (peek(cursor) !== '$' || peek(cursor, 1) !== '"') return false
|
|
280
|
+
cursor.index++
|
|
281
|
+
return doubleQuoted(cursor, state)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const parameterExpansion: Handler = (cursor, state) => {
|
|
285
|
+
if (peek(cursor) !== '$') return false
|
|
286
|
+
if (peek(cursor, 1) === '{') {
|
|
287
|
+
cursor.index++
|
|
288
|
+
skipBalanced(cursor, '{', '}')
|
|
289
|
+
} else cursor.index++
|
|
290
|
+
appendShaped(state, EXPANSION)
|
|
291
|
+
return true
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const processSubstitution: Handler = (cursor, state) => {
|
|
295
|
+
if (!isOneOf('<>', peek(cursor)) || peek(cursor, 1) !== '(') return false
|
|
296
|
+
endWord(state)
|
|
297
|
+
substitute(cursor, state, 2, ')')
|
|
298
|
+
return true
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const hereString: Handler = (cursor, state) => {
|
|
302
|
+
if (peek(cursor) !== '<' || peek(cursor, 1) !== '<' || peek(cursor, 2) !== '<') return false
|
|
303
|
+
endWord(state)
|
|
304
|
+
cursor.index += 3
|
|
305
|
+
state.dropNextWord = true
|
|
306
|
+
return true
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const hereDocument: Handler = (cursor, state) => {
|
|
310
|
+
if (peek(cursor) !== '<' || peek(cursor, 1) !== '<') return false
|
|
311
|
+
endWord(state)
|
|
312
|
+
cursor.index += 2
|
|
313
|
+
const stripTabs = peek(cursor) === '-'
|
|
314
|
+
if (stripTabs) cursor.index++
|
|
315
|
+
while (isOneOf(BLANKS, peek(cursor))) cursor.index++
|
|
316
|
+
state.heredocs.push(readHeredocHeader(cursor, stripTabs))
|
|
317
|
+
return true
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const isFileDescriptor = (word: Word | undefined): boolean => word !== undefined && word.literal && /^\d+$/.test(word.value)
|
|
321
|
+
|
|
322
|
+
const redirection: Handler = (cursor, state) => {
|
|
323
|
+
const char = peek(cursor)
|
|
324
|
+
if (char !== '<' && char !== '>' && !(char === '&' && peek(cursor, 1) === '>')) return false
|
|
325
|
+
if (isFileDescriptor(state.word)) state.word = undefined
|
|
326
|
+
else endWord(state)
|
|
327
|
+
while (isOneOf(REDIRECTION_CHARS, peek(cursor))) cursor.index++
|
|
328
|
+
state.dropNextWord = true
|
|
329
|
+
return true
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const operator: Handler = (cursor, state) => {
|
|
333
|
+
if (!isOneOf(OPERATORS, peek(cursor))) return false
|
|
334
|
+
endCommand(state)
|
|
335
|
+
while (isOneOf(OPERATORS, peek(cursor))) cursor.index++
|
|
336
|
+
return true
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const subshell: Handler = (cursor, state) => {
|
|
340
|
+
if (peek(cursor) !== '(') return false
|
|
341
|
+
endCommand(state)
|
|
342
|
+
cursor.index++
|
|
343
|
+
state.commands.push(...parseList(cursor, ')'))
|
|
344
|
+
return true
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const strayCloseParen: Handler = (cursor, state) => {
|
|
348
|
+
if (peek(cursor) !== ')') return false
|
|
349
|
+
endCommand(state)
|
|
350
|
+
cursor.index++
|
|
351
|
+
return true
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const plainChar = (cursor: Cursor, state: ListState): void => {
|
|
355
|
+
currentWord(state).value += peek(cursor)
|
|
356
|
+
cursor.index++
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* First match wins, so a handler for a longer prefix precedes the one for its own prefix:
|
|
361
|
+
* `\\⏎` before `\\`, `$'`, `$"`, `$((` and `$(` before `$`, `<(`, `<<<` and `<<` before `<`.
|
|
362
|
+
*/
|
|
363
|
+
const HANDLERS: Handler[] = [
|
|
364
|
+
lineContinuation,
|
|
365
|
+
escapedChar,
|
|
366
|
+
blank,
|
|
367
|
+
newline,
|
|
368
|
+
comment,
|
|
369
|
+
ansiCQuoted,
|
|
370
|
+
localeQuoted,
|
|
371
|
+
singleQuoted,
|
|
372
|
+
doubleQuoted,
|
|
373
|
+
backtick,
|
|
374
|
+
arithmetic,
|
|
375
|
+
commandSubstitution,
|
|
376
|
+
parameterExpansion,
|
|
377
|
+
processSubstitution,
|
|
378
|
+
hereString,
|
|
379
|
+
hereDocument,
|
|
380
|
+
redirection,
|
|
381
|
+
operator,
|
|
382
|
+
subshell,
|
|
383
|
+
strayCloseParen,
|
|
384
|
+
]
|
|
385
|
+
|
|
386
|
+
const parseList = (cursor: Cursor, terminator: Terminator): SimpleCommand[] => {
|
|
387
|
+
const state = newListState()
|
|
388
|
+
while (!atEnd(cursor)) {
|
|
389
|
+
if (terminator !== undefined && peek(cursor) === terminator) {
|
|
390
|
+
cursor.index++
|
|
391
|
+
endCommand(state)
|
|
392
|
+
return state.commands
|
|
393
|
+
}
|
|
394
|
+
if (!HANDLERS.some((handle) => handle(cursor, state))) plainChar(cursor, state)
|
|
395
|
+
}
|
|
396
|
+
if (terminator !== undefined) throw new ShellParseError(`missing ${terminator}`)
|
|
397
|
+
endCommand(state)
|
|
398
|
+
return state.commands
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Splits a shell script into its simple commands, the way a permission rule sees them: at `&&`,
|
|
403
|
+
* `||`, `;`, `|`, `&` and newlines, and into subshells, command substitutions and process
|
|
404
|
+
* substitutions. Here-document bodies, comments and redirection targets are not commands and are
|
|
405
|
+
* dropped. Throws `ShellParseError` on an unterminated quote or substitution.
|
|
406
|
+
*/
|
|
407
|
+
export const parseShell = (script: string): SimpleCommand[] => parseList({ source: script, index: 0 }, undefined)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const lineEnd = (script: string, from: number): number => {
|
|
2
|
+
const end = script.indexOf('\n', from)
|
|
3
|
+
return end === -1 ? script.length : end
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** The index after the quote that closes the one at `from`; a backslash escapes inside double quotes. */
|
|
7
|
+
const afterQuoted = (script: string, from: number): number => {
|
|
8
|
+
const quote = script[from]
|
|
9
|
+
let index = from + 1
|
|
10
|
+
while (index < script.length && script[index] !== quote) index += quote === '"' && script[index] === '\\' ? 2 : 1
|
|
11
|
+
return index + 1
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A lone `&`, not `&&`, `&>`, `>&`, `<&` or `|&`. */
|
|
15
|
+
const isLoneAmpersand = (script: string, index: number): boolean =>
|
|
16
|
+
script[index + 1] !== '&' && script[index + 1] !== '>' && !'&<>|'.includes(script[index - 1] ?? ' ')
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether an unquoted `&` outside a comment sends some command of the script into the background. It
|
|
20
|
+
* answers for the whole script, not for one command: `sleep 1 & jh watch` counts as well.
|
|
21
|
+
* Here-document bodies are input and belong out of `script` before it is asked.
|
|
22
|
+
*/
|
|
23
|
+
export const hasBackgroundJob = (script: string): boolean => {
|
|
24
|
+
let index = 0
|
|
25
|
+
let wordStart = true
|
|
26
|
+
while (index < script.length) {
|
|
27
|
+
const char = script[index] ?? ''
|
|
28
|
+
if (char === '&' && isLoneAmpersand(script, index)) return true
|
|
29
|
+
if (char === '\\') index += 2
|
|
30
|
+
else if (char === "'" || char === '"') index = afterQuoted(script, index)
|
|
31
|
+
else if (char === '#' && wordStart) index = lineEnd(script, index)
|
|
32
|
+
else index += char === '&' && script[index + 1] === '&' ? 2 : 1
|
|
33
|
+
wordStart = /[\s;|&()]/.test(char)
|
|
34
|
+
}
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { startsIn, type WatcherStart } from './starts'
|
|
2
|
+
import { startsInText } from './text-fallback'
|
|
3
|
+
|
|
4
|
+
export type BashToolInput = { command?: string; run_in_background?: boolean }
|
|
5
|
+
|
|
6
|
+
const LINE_CONTINUATION = /\\\n\s*/g
|
|
7
|
+
|
|
8
|
+
const STREAM_REFUSAL = [
|
|
9
|
+
'jh watch --stream never ends, so a Bash call never reports it, not even in the background.',
|
|
10
|
+
'Start the same command with the Monitor tool (timeout_ms 1800000), where every line it prints reaches you.',
|
|
11
|
+
].join('\n')
|
|
12
|
+
|
|
13
|
+
const BACKGROUND_REFUSAL = [
|
|
14
|
+
'jh watch only reports back as a background task or under the Monitor tool.',
|
|
15
|
+
'Start it with the Monitor tool and --stream, or as its own Bash call with run_in_background: true.',
|
|
16
|
+
].join('\n')
|
|
17
|
+
|
|
18
|
+
const BACKGROUND_JOB_REFUSAL = [
|
|
19
|
+
'jh watch in a call that sends a command off with & : a trailing & ends the call before the watcher reports.',
|
|
20
|
+
'Start it with the Monitor tool and --stream, or as its own Bash call with run_in_background: true and no &.',
|
|
21
|
+
].join('\n')
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The starts in a call. Any failure of the parse (an unreadable call, nesting past the limit, or an error
|
|
25
|
+
* nobody foresaw such as a stack overflow) falls back to the text match: a hook that throws lets the call
|
|
26
|
+
* through, so a doubtful call is refused instead.
|
|
27
|
+
*/
|
|
28
|
+
export const startsOf = (command: string, parse: typeof startsIn = startsIn): WatcherStart[] => {
|
|
29
|
+
const script = command.replace(LINE_CONTINUATION, ' ')
|
|
30
|
+
try {
|
|
31
|
+
return parse(script, false)
|
|
32
|
+
} catch {
|
|
33
|
+
return startsInText(script)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The reason to refuse a Bash call that starts `jh watch` where its report goes unseen, or null.
|
|
39
|
+
* A start is a simple command whose program runs the watcher (the `jh` bin or the older `agent-kit` one, a package runner with
|
|
40
|
+
* the package, or `bun` with the CLI's script) with the subcommand `watch`, also behind wrappers, inside
|
|
41
|
+
* `bash -c`, `eval`, a substitution, or a here-document or here-string fed to a shell. Text that only
|
|
42
|
+
* names the command (an argument, a quoted string, a data here-document) is no start. A `--stream` start
|
|
43
|
+
* never finishes, so no Bash call reports it; a single run reports only as its own background task, in a
|
|
44
|
+
* call that sends nothing off with `&`.
|
|
45
|
+
*/
|
|
46
|
+
export const watcherStartViolation = (input: BashToolInput): string | null => {
|
|
47
|
+
if (!input.command) return null
|
|
48
|
+
const starts = startsOf(input.command)
|
|
49
|
+
if (starts.length === 0) return null
|
|
50
|
+
if (starts.some((start) => start.stream)) return STREAM_REFUSAL
|
|
51
|
+
if (input.run_in_background !== true) return BACKGROUND_REFUSAL
|
|
52
|
+
return starts.some((start) => start.callHasBackgroundJob) ? BACKGROUND_JOB_REFUSAL : null
|
|
53
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// PreToolUse hook for Bash, shipped by jackherd: refuses a `jh watch` start whose
|
|
2
|
+
// reports would go unseen. Reads the hook input on stdin; exit 2 blocks the call and hands the reason on
|
|
3
|
+
// stderr back to the model, exit 0 leaves the call to the permission rules.
|
|
4
|
+
import { watcherStartViolation } from './decision'
|
|
5
|
+
|
|
6
|
+
const payload = await Bun.stdin.json()
|
|
7
|
+
const violation = watcherStartViolation(payload.tool_input ?? {})
|
|
8
|
+
if (violation) {
|
|
9
|
+
console.error(violation)
|
|
10
|
+
process.exit(2)
|
|
11
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { parseShell } from '../git-guard/shell'
|
|
2
|
+
|
|
3
|
+
/** Programs that run their `-c` argument as a command line; the names of shells among them also read stdin as a script. */
|
|
4
|
+
export const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish'])
|
|
5
|
+
|
|
6
|
+
const SHELL_NAME = `(?:${[...SHELLS].join('|')})`
|
|
7
|
+
|
|
8
|
+
const HEREDOC = /(<<-?\s*\\?(['"]?)([^\s;&|<>()'"]+)\2)([^\n]*)\n([\s\S]*?)\n\t*\3(?=\n|$)/g
|
|
9
|
+
const SHELL_INPUT = new RegExp(`(?:^|[\\s;&|(/])${SHELL_NAME}\\b`)
|
|
10
|
+
const PIPED_TO_SHELL = new RegExp(`\\|\\s*(?:\\S*/)?${SHELL_NAME}\\b`)
|
|
11
|
+
const SHELL_HERESTRING = new RegExp(`(?:^|[\\s;&|(/])${SHELL_NAME}\\b[^\\n<]*<<<\\s*([^\\n]*)`, 'g')
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The text of the line in front of a here-document operator. Found by looking back from a match instead of by
|
|
15
|
+
* a leading `[^\n]*` in the pattern, which would rescan the rest of the line from every position (quadratic).
|
|
16
|
+
*/
|
|
17
|
+
const lineBefore = (script: string, index: number): string => script.slice(script.lastIndexOf('\n', index - 1) + 1, index)
|
|
18
|
+
|
|
19
|
+
/** The script without here-document bodies, which are input, not commands of this script. */
|
|
20
|
+
export const withoutHeredocBodies = (script: string): string =>
|
|
21
|
+
script.replace(HEREDOC, (_whole, operator: string, _quote, _tag, rest: string) => `${operator}${rest}`)
|
|
22
|
+
|
|
23
|
+
/** The bodies of here-documents a shell reads as its script: `bash <<EOF`, `sh -s <<EOF`, `cat <<EOF | sh`. */
|
|
24
|
+
export const shellHeredocBodies = (script: string): string[] =>
|
|
25
|
+
[...script.matchAll(HEREDOC)].flatMap((match) => {
|
|
26
|
+
const [, , , , rest = '', body = ''] = match
|
|
27
|
+
return SHELL_INPUT.test(lineBefore(script, match.index)) || PIPED_TO_SHELL.test(rest) ? [body] : []
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
/** The strings a shell reads as its script from a here-string: `bash <<< "<script>"`. */
|
|
31
|
+
export const shellHereStrings = (script: string): string[] =>
|
|
32
|
+
[...script.matchAll(SHELL_HERESTRING)].flatMap(([, rest = '']) => parseShell(rest)[0]?.[0]?.value ?? [])
|