typeclaw 0.20.0 → 0.22.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.
Files changed (55) hide show
  1. package/package.json +2 -1
  2. package/src/agent/index.ts +55 -1
  3. package/src/agent/loop-guard.ts +180 -53
  4. package/src/agent/restart/index.ts +101 -0
  5. package/src/agent/tools/restart.ts +23 -52
  6. package/src/bundled-plugins/bun-hygiene/README.md +82 -0
  7. package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
  8. package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
  9. package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
  10. package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
  11. package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
  12. package/src/bundled-plugins/memory/memory-logger.ts +6 -2
  13. package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
  14. package/src/channels/adapters/discord-bot-classify.ts +8 -2
  15. package/src/channels/adapters/discord-bot.ts +29 -2
  16. package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
  17. package/src/channels/adapters/github/inbound.ts +92 -1
  18. package/src/channels/adapters/github/index.ts +12 -1
  19. package/src/channels/adapters/github/reactions.ts +138 -4
  20. package/src/channels/adapters/slack-bot-classify.ts +2 -2
  21. package/src/channels/adapters/slack-bot.ts +129 -7
  22. package/src/channels/engagement.ts +71 -31
  23. package/src/channels/manager.ts +8 -0
  24. package/src/channels/router.ts +180 -25
  25. package/src/channels/schema.ts +18 -0
  26. package/src/channels/types.ts +16 -1
  27. package/src/cli/builtins.ts +1 -0
  28. package/src/cli/dreams.ts +148 -0
  29. package/src/cli/index.ts +1 -0
  30. package/src/cli/inspect.ts +2 -1
  31. package/src/cli/ui.ts +34 -0
  32. package/src/commands/index.ts +5 -2
  33. package/src/config/config.ts +89 -0
  34. package/src/dreams/git.ts +85 -0
  35. package/src/dreams/index.ts +134 -0
  36. package/src/dreams/parse.ts +224 -0
  37. package/src/dreams/render.ts +155 -0
  38. package/src/dreams/types.ts +50 -0
  39. package/src/mcp/catalog.ts +29 -0
  40. package/src/mcp/client.ts +236 -0
  41. package/src/mcp/index.ts +25 -0
  42. package/src/mcp/manager.ts +156 -0
  43. package/src/mcp/tools.ts +190 -0
  44. package/src/permissions/builtins.ts +9 -0
  45. package/src/reload/format.ts +14 -0
  46. package/src/reload/index.ts +1 -0
  47. package/src/run/bundled-plugins.ts +7 -0
  48. package/src/run/channel-session-factory.ts +3 -0
  49. package/src/run/index.ts +38 -1
  50. package/src/server/command-runner.ts +5 -0
  51. package/src/server/index.ts +53 -0
  52. package/src/shared/protocol.ts +2 -0
  53. package/src/skills/typeclaw-channel-github/SKILL.md +86 -22
  54. package/src/tui/index.ts +70 -18
  55. package/typeclaw.schema.json +82 -0
@@ -0,0 +1,82 @@
1
+ # typeclaw-plugin-bun-hygiene
2
+
3
+ The bundled bun-hygiene plugin. Registers a `tool.before` hook that blocks two classes of `bash` command:
4
+
5
+ 1. **Global package installs** — `npm install -g`, `pnpm add -g`, `yarn global add`, `bun add -g`, and their `--global` / bundled-flag variants.
6
+ 2. **Non-bun package managers** — any `npm`, `npx`, `pnpm`, `pnpx`, or `yarn` invocation.
7
+
8
+ This plugin is **auto-loaded** by every TypeClaw agent. There is no `plugins[]` entry to add. Both guards carry an `acknowledgeGuards` escape hatch (below) for the cases where the agent genuinely needs the blocked command.
9
+
10
+ ## Why it exists
11
+
12
+ **Global installs don't persist.** The agent folder is bind-mounted at `/agent`; everything else in the container — including `~/.bun`, `~/.npm`, and the global `node_modules` a global install writes to — is ephemeral and wiped on every `typeclaw restart`. An agent that runs `npm install -g some-cli` gets a tool that works for the rest of the session and silently vanishes on the next boot, leading to confusing "command not found" failures that look like regressions. The fix is to either add the dependency to `package.json` (`bun add <pkg>`, which lives in the bind-mounted folder and survives) or run it once without installing (`bunx <pkg>`).
13
+
14
+ **The container standardizes on bun.** TypeClaw is Bun-native end to end (see the root README). Mixing in `npm`/`pnpm`/`yarn` produces competing lockfiles and install trees, and `npx` pulls a second package-execution path when `bunx` already covers it. Steering every package-manager call to bun keeps the dependency state coherent.
15
+
16
+ Both guards **block with guidance** rather than silently rewriting the command — the agent sees exactly why the command was rejected and what to run instead, the same UX as the bundled `security` and `guard` policies.
17
+
18
+ ## Guards
19
+
20
+ | Guard | Triggers on | Guidance in the block reason |
21
+ | ---------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
22
+ | `globalInstall` | `npm`/`pnpm` install/add with `-g`/`--global`, `yarn global add`, `bun add -g` / `bun install -g` | Use `bun add <pkg>` (persists) or `bunx <pkg>` (ephemeral run). |
23
+ | `nonBunPackageManager` | `npm`, `npx`, `pnpm`, `pnpx`, `yarn` at a command boundary | Use `bun install` / `bun add <pkg>`, and `bunx <pkg>` instead of npx/pnpx. |
24
+
25
+ A global install (e.g. `npm install -g x`) trips **only** `globalInstall`, not both — the global install is the more specific violation, so acknowledging `globalInstall` lets the command through without a second acknowledgement for `nonBunPackageManager`.
26
+
27
+ ## Bypass
28
+
29
+ Both guards follow the repo-wide `acknowledgeGuards` convention (shared with the `security` and `guard` plugins). To run a blocked command intentionally, pass the matching flag in the `bash` tool arguments:
30
+
31
+ ```jsonc
32
+ // bash tool args
33
+ { "command": "npm install", "acknowledgeGuards": { "nonBunPackageManager": true } }
34
+ { "command": "npm install -g some-cli", "acknowledgeGuards": { "globalInstall": true } }
35
+ ```
36
+
37
+ ## How it works
38
+
39
+ `checkBunHygieneGuard` in `policy.ts` does not regex the raw command. It runs a small single-pass tokenizer (`splitSegments`) that turns the command into a list of **segments**, each a list of **words**:
40
+
41
+ - Segments break on real command separators — `;`, `&&`, `||`, `|`, `&`, newline, `\r` — and on subshell / command-substitution openers (`(`, `$(`, backtick), **including `$(`/backtick inside double quotes** (Bash executes those, e.g. `echo "$(npm install -g x)"`; the outer double-quote mode resumes when the substitution closes so a trailing command isn't swallowed). Single-quoted bodies stay literal, matching Bash.
42
+ - The tokenizer is quote-aware (a separator inside `"..."`/`'...'` is literal) and escape-aware (`\x` is a literal `x`, so `\npm` resolves to `npm` and `\;` is not a separator). A `\<newline>` is a POSIX line continuation — it is removed and the surrounding text joined, so `npm install \⏎-g x` is one command (a global install), while a bare newline separates commands.
43
+
44
+ For each segment, the guard strips leading **preamble wrappers** (`sudo`, `env`, `command`, `exec`, `nice`, `nohup`, `stdbuf`, `setsid`, `time`, `xargs`, and any `VAR=val` assignment) — including their options, and the argument a flag consumes (`sudo -u nobody`, `nice -n 10`, `env -i`) — to find the real command word, then classifies:
45
+
46
+ 1. command word is `npm`/`npx`/`pnpm`/`pnpx`/`yarn` (or `bun`) **and** the segment has an install subcommand **and** a global flag → `globalInstall` (for `yarn`, the `global add` sequence must appear adjacent and in command position, so `yarn add global foo` — a local install of a package named `global` — is not misflagged);
47
+ 2. command word is a non-bun manager (not via global) → `nonBunPackageManager`;
48
+ 3. otherwise → allowed.
49
+
50
+ A `globalInstall` verdict on any segment wins over a plain non-bun verdict. This is a command-position detector, not a full shell parser — it doesn't interpret redirections or expansions beyond boundary marking — but it is linear-time and closes the structural gaps a single regex left open.
51
+
52
+ ## Scope: not a security boundary
53
+
54
+ This guard is a **hygiene nudge**, not an isolation mechanism. It deliberately does not chase manager invocations hidden inside a wrapper's code payload — `sh -c 'npm install'`, `bash -lc "pnpm add foo"`, `python -c '...os.system("npx tsc")'`, `node -e`, `eval`, `base64 | sh`, etc. That set is unbounded (any interpreter can reach any binary), and inspecting arbitrary `-c`/`-e` payloads is an arms race with diminishing returns and rising false-positive risk. An agent that genuinely wants a package manager can always reach one; the guard's job is to steer the common, direct invocations toward bun and to stop accidental global installs. The real isolation boundary is the per-tool **bwrap sandbox** (see `/docs/internals/sandbox`), not this policy. Optioned preamble _wrappers_ (`env -i`, `sudo -u`, `nice -n`) are handled because they prefix a real command word that the tokenizer can still see; code-payload wrappers are not, by design.
55
+
56
+ ## Why a tokenizer, not a regex
57
+
58
+ The earlier implementation matched boundary-anchored regexes against an escape/quote-normalized copy of the command. Review surfaced three structural gaps that are awkward to close with one regex but fall out naturally from the segment model:
59
+
60
+ - **Escaped / quoted command words.** `\npm install`, `"npm" install`, `'npm' install`, `n\px …` all run the real binary; the tokenizer collapses escapes and quotes at the word level, so each resolves to its bare command word.
61
+ - **Leading assignments.** `FOO=bar npm install` runs npm with `FOO` set. Stripping `VAR=val` (and `sudo`/`env`/`command`/`exec`/`nice`) preamble words finds the manager behind them.
62
+ - **Newline = separate command.** `npm install\n-g typescript` is two commands; the `-g` does not make the install global. Per-segment scoping means a flag in one segment never combines with an install in another, so this classifies as `nonBunPackageManager` (the `npm install` line), not `globalInstall`.
63
+
64
+ It also recognizes an explicit falsy global flag (`--global=false|0|no|off`) as **not** a global install, and detects managers inside subshells / command substitutions.
65
+
66
+ ## Option placement in global installs
67
+
68
+ Because classification scans a segment's words as a set (after preamble stripping), options may sit anywhere relative to the subcommand and the global flag, in either order: `npm --prefix /tmp install -g x`, `npm install --foo bar -g x`, `npm -g install x`, `pnpm add --reporter silent -g foo`, and `bun --cwd /x add -g foo` all attribute to `globalInstall`.
69
+
70
+ ## What is NOT blocked
71
+
72
+ - `bun`, `bunx`, `bun run`, `bun add`, `bun install` (local) — the intended package commands. (`bun add -g` / `bun install -g` are still blocked as global installs: bun globals live in `~/.bun`, outside `/agent`, and are wiped on restart.)
73
+ - A non-bun manager name appearing as a substring or argument: `my-npm-wrapper`, `./npm`, `cat npm-debug.log`, `git commit -m "drop npm"`, `grep -rn npx src/`, `echo "npm install -g foo"`. Only the **command word** of a segment is classified, so a manager name inside an argument, path, quoted string, or longer token never trips the guard.
74
+
75
+ ## Ordering against other bundled plugins
76
+
77
+ Registered after `guard` in `src/run/bundled-plugins.ts`. It guards a disjoint surface (package-manager bash commands), so its position only matters for precedence: keeping it after `security` and `guard` means any of their blocks wins first.
78
+
79
+ ## Tests
80
+
81
+ - `policy.test.ts` — pure-function unit tests for the detection logic: every global-install form, every non-bun manager, the allowed-command set (bun/bunx, substrings, paths, quoted text), both bypasses, the global-install-takes-precedence rule, escaped/quoted evasions, leading-assignment preambles, newline-as-separator scoping, falsy `--global=`, option placement, and subshell/substitution detection.
82
+ - `index.test.ts` — composition tests: the plugin registers the `tool.before` hook and wires it to the policy (block on global install, block on npx, allow bunx, honor the bypass).
@@ -0,0 +1,11 @@
1
+ import { definePlugin } from '@/plugin'
2
+
3
+ import { checkBunHygieneGuard } from './policy'
4
+
5
+ export default definePlugin({
6
+ plugin: async () => ({
7
+ hooks: {
8
+ 'tool.before': (event) => checkBunHygieneGuard({ tool: event.tool, args: event.args }),
9
+ },
10
+ }),
11
+ })
@@ -0,0 +1,318 @@
1
+ import { ACKNOWLEDGE_GUARDS, type GuardBlock, isGuardAcknowledged } from '../guard/policy'
2
+
3
+ export const GUARD_GLOBAL_INSTALL = 'globalInstall'
4
+ export const GUARD_NON_BUN_PACKAGE_MANAGER = 'nonBunPackageManager'
5
+
6
+ const NON_BUN_MANAGERS = new Set(['npm', 'npx', 'pnpm', 'pnpx', 'yarn'])
7
+ const INSTALL_SUBCOMMANDS = new Set(['install', 'i', 'add'])
8
+
9
+ export function checkBunHygieneGuard(options: { tool: string; args: Record<string, unknown> }): GuardBlock | undefined {
10
+ const { tool, args } = options
11
+ if (tool !== 'bash') return undefined
12
+
13
+ const command = args.command
14
+ if (typeof command !== 'string') return undefined
15
+
16
+ const verdict = classify(command)
17
+ if (verdict === undefined) return undefined
18
+ if (verdict.kind === 'global-install') return blockGlobalInstall(verdict.label, args)
19
+ return blockNonBunManager(verdict.manager, args)
20
+ }
21
+
22
+ type Verdict = { kind: 'global-install'; label: string } | { kind: 'non-bun'; manager: string }
23
+
24
+ // Why a segment model instead of one big regex: every gap in a raw-string match
25
+ // is a shell-structure gap. Splitting into segments first means a global flag on
26
+ // a *different* command (`npm install\n-g x` — two commands) can never combine
27
+ // with an install on another, leading assignment words (`FOO=bar npm ...`) are
28
+ // stripped uniformly, and `--global=false` is inspected as a real token. The
29
+ // global-install verdict wins over the plain non-bun verdict (it's the more
30
+ // specific violation, and acknowledging it is meant to let the whole thing run).
31
+ function classify(command: string): Verdict | undefined {
32
+ let fallback: Verdict | undefined
33
+ for (const segment of splitSegments(command)) {
34
+ const words = segment.map(normalizeWord)
35
+ const manager = leadingCommandWord(words)
36
+ if (manager === undefined) continue
37
+
38
+ // `bun` is the allowed manager, but a `bun add -g` still installs to ~/.bun
39
+ // (outside /agent) and is wiped on restart, so it is a global install too —
40
+ // just never a plain non-bun violation.
41
+ const isBun = manager === 'bun'
42
+ if (!isBun && !NON_BUN_MANAGERS.has(manager)) continue
43
+
44
+ const label = globalInstallLabel(manager, words)
45
+ if (label !== undefined) return { kind: 'global-install', label }
46
+ if (!isBun) fallback ??= { kind: 'non-bun', manager }
47
+ }
48
+ return fallback
49
+ }
50
+
51
+ // Split on real command separators (`;`, `&&`, `||`, `|`, `&`, newline, `\r`)
52
+ // and on subshell / command-substitution openers (`(`, `$(`, backtick), then
53
+ // tokenize each segment into whitespace-separated words. Quote-aware so a
54
+ // separator inside quotes stays literal; backslash escapes the next character
55
+ // (so `\;` and `\ ` are literal, not separators/breaks). Word-level only — it
56
+ // does not interpret redirections or expansions beyond boundary marking.
57
+ function splitSegments(command: string): string[][] {
58
+ const segments: string[][] = []
59
+ let words: string[] = []
60
+ let current = ''
61
+ let hasWord = false
62
+ let quote: '"' | "'" | null = null
63
+ // Tracks a command substitution entered from inside a double quote, so the
64
+ // outer double-quote mode is restored when the substitution closes. Bash runs
65
+ // `$(...)` and backtick substitutions inside double quotes (but not single),
66
+ // so `echo "$(npm install)"` must be scanned for a manager.
67
+ let commandSub: { kind: '`' | '$('; depth: number; resumeQuote: '"' } | null = null
68
+
69
+ const flushWord = (): void => {
70
+ if (hasWord) {
71
+ words.push(current)
72
+ current = ''
73
+ hasWord = false
74
+ }
75
+ }
76
+ const flushSegment = (): void => {
77
+ flushWord()
78
+ if (words.length > 0) {
79
+ segments.push(words)
80
+ words = []
81
+ }
82
+ }
83
+
84
+ for (let i = 0; i < command.length; i++) {
85
+ const ch = command[i]
86
+ if (quote !== null) {
87
+ // A `$(` or backtick inside a double quote opens a command substitution
88
+ // that Bash executes; scan its body as a fresh segment, remembering to
89
+ // resume double-quote mode when it closes.
90
+ if (quote === '"' && ch === '`') {
91
+ flushSegment()
92
+ commandSub = { kind: '`', depth: 0, resumeQuote: '"' }
93
+ quote = null
94
+ continue
95
+ }
96
+ if (quote === '"' && ch === '$' && command[i + 1] === '(') {
97
+ flushSegment()
98
+ commandSub = { kind: '$(', depth: 1, resumeQuote: '"' }
99
+ quote = null
100
+ i++
101
+ continue
102
+ }
103
+ if (ch === quote) quote = null
104
+ else {
105
+ current += ch
106
+ hasWord = true
107
+ }
108
+ continue
109
+ }
110
+ // Close of a command substitution opened from inside a double quote:
111
+ // restore the outer double-quote mode so the rest of the string (and any
112
+ // trailing `&&`/`;`) is parsed correctly rather than re-quoted.
113
+ if (commandSub?.kind === '`' && ch === '`') {
114
+ flushSegment()
115
+ quote = commandSub.resumeQuote
116
+ commandSub = null
117
+ continue
118
+ }
119
+ if (commandSub?.kind === '$(' && ch === '$' && command[i + 1] === '(') {
120
+ flushSegment()
121
+ commandSub.depth++
122
+ i++
123
+ continue
124
+ }
125
+ if (commandSub?.kind === '$(' && ch === '(') {
126
+ flushSegment()
127
+ commandSub.depth++
128
+ continue
129
+ }
130
+ if (commandSub?.kind === '$(' && ch === ')') {
131
+ flushSegment()
132
+ commandSub.depth--
133
+ if (commandSub.depth === 0) {
134
+ quote = commandSub.resumeQuote
135
+ commandSub = null
136
+ }
137
+ continue
138
+ }
139
+ if (ch === '\\') {
140
+ const next = command[i + 1]
141
+ if (next === undefined) break
142
+ // `\<newline>` (and `\<CR><newline>`) is a shell line continuation: the
143
+ // shell removes it entirely, joining the surrounding text. Dropping it
144
+ // here keeps `npm install \<nl>-g x` tokenized as `install`,`-g` (a global
145
+ // install) instead of producing a malformed `install\n-g` token.
146
+ if (next === '\n') {
147
+ i++
148
+ continue
149
+ }
150
+ if (next === '\r' && command[i + 2] === '\n') {
151
+ i += 2
152
+ continue
153
+ }
154
+ current += next
155
+ hasWord = true
156
+ i++
157
+ continue
158
+ }
159
+ if (ch === '"' || ch === "'") {
160
+ quote = ch
161
+ hasWord = true
162
+ continue
163
+ }
164
+ if (ch === ' ' || ch === '\t') {
165
+ flushWord()
166
+ continue
167
+ }
168
+ if (ch === '\n' || ch === '\r' || ch === ';' || ch === '|' || ch === '&' || ch === '(' || ch === '`') {
169
+ flushSegment()
170
+ continue
171
+ }
172
+ if (ch === '$' && command[i + 1] === '(') {
173
+ flushSegment()
174
+ i++
175
+ continue
176
+ }
177
+ current += ch
178
+ hasWord = true
179
+ }
180
+ flushSegment()
181
+ return segments
182
+ }
183
+
184
+ // A word is already quote/escape-collapsed by splitSegments (quotes consumed,
185
+ // backslash-escapes literalized), so the only residue to strip is leftover
186
+ // quote characters that appeared mid-token via concatenation. Keeping this
187
+ // explicit makes `"npm"`, `n\px`, `'npm'` all resolve to their bare binary.
188
+ function normalizeWord(word: string): string {
189
+ return word.replaceAll('"', '').replaceAll("'", '')
190
+ }
191
+
192
+ // Preamble wrappers that prefix a real command (`sudo npm …`, `env FOO=bar npm
193
+ // …`, `nice -n 10 npm …`). The value is the set of SHORT option letters that
194
+ // consume the FOLLOWING word as their argument, so `sudo -u nobody npm` skips
195
+ // `nobody` too. Long `--opt=value` forms are self-contained (one token); bare
196
+ // long `--opt` and unknown short flags are skipped as a single token (the safe
197
+ // default — at worst we skip one extra token and still find the manager later).
198
+ const PREAMBLE_WRAPPERS: Record<string, ReadonlySet<string>> = {
199
+ sudo: new Set(['u', 'g', 'h', 'p', 'C', 'r', 't', 'T', 'U']),
200
+ env: new Set(['u', 'C', 'S']),
201
+ nice: new Set(['n']),
202
+ command: new Set([]),
203
+ exec: new Set(['a']),
204
+ nohup: new Set([]),
205
+ stdbuf: new Set(['i', 'o', 'e']),
206
+ setsid: new Set([]),
207
+ time: new Set(['o', 'f']),
208
+ xargs: new Set([]),
209
+ }
210
+
211
+ // The command word is the first token that is not a shell preamble: a known
212
+ // wrapper (with its options consumed), or a `VAR=val` assignment. This is what
213
+ // makes `FOO=bar npm install` and `env -i npm install` / `sudo -u nobody pnpm
214
+ // add` resolve to the real manager instead of evading the guard.
215
+ function leadingCommandWord(words: string[]): string | undefined {
216
+ let i = 0
217
+ while (i < words.length) {
218
+ const word = words[i]
219
+ if (word === undefined) break
220
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
221
+ i++
222
+ continue
223
+ }
224
+ const argTakingShortOpts = PREAMBLE_WRAPPERS[word]
225
+ if (argTakingShortOpts !== undefined) {
226
+ i = skipWrapperOptions(words, i + 1, argTakingShortOpts)
227
+ continue
228
+ }
229
+ return word
230
+ }
231
+ return undefined
232
+ }
233
+
234
+ // From the token after a wrapper, skip its option tokens. A long `--opt` is one
235
+ // token. A short `-x` (or bundled `-xy`) consumes the next word only when its
236
+ // LAST letter is in `argTaking` (e.g. `-n 10`, `-u nobody`). `VAR=val` between a
237
+ // wrapper and its command (as `env` allows) is also skipped. Stops at the first
238
+ // non-option, non-assignment token — that is the wrapped command word.
239
+ function skipWrapperOptions(words: string[], start: number, argTaking: ReadonlySet<string>): number {
240
+ let i = start
241
+ while (i < words.length) {
242
+ const word = words[i]
243
+ if (word === undefined) break
244
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
245
+ i++
246
+ continue
247
+ }
248
+ if (word.startsWith('--')) {
249
+ i++
250
+ continue
251
+ }
252
+ if (word.startsWith('-') && word.length > 1) {
253
+ const lastLetter = word[word.length - 1]
254
+ i++
255
+ if (lastLetter !== undefined && argTaking.has(lastLetter) && i < words.length) i++
256
+ continue
257
+ }
258
+ break
259
+ }
260
+ return i
261
+ }
262
+
263
+ function globalInstallLabel(manager: string, words: string[]): string | undefined {
264
+ if (manager === 'yarn') {
265
+ // Real syntax is `yarn global add <pkg>` — `global` immediately followed by
266
+ // `add` as a consecutive sequence. Checking for both tokens anywhere would
267
+ // false-positive on `yarn add global foo` (a local install of a package
268
+ // literally named `global`), which is not a global install at all.
269
+ return hasAdjacentSequence(words, 'global', 'add') ? 'yarn global add' : undefined
270
+ }
271
+ const hasInstall = words.some((w) => INSTALL_SUBCOMMANDS.has(w))
272
+ const hasGlobal = words.some(isGlobalFlag)
273
+ if (!hasInstall || !hasGlobal) return undefined
274
+ return manager === 'bun' ? 'bun global install (-g / --global)' : 'npm/pnpm global install (-g / --global)'
275
+ }
276
+
277
+ function hasAdjacentSequence(words: string[], first: string, second: string): boolean {
278
+ for (let i = 0; i + 1 < words.length; i++) {
279
+ if (words[i] === first && words[i + 1] === second) return true
280
+ }
281
+ return false
282
+ }
283
+
284
+ // `-g` / `--global`, including bundled short flags like `-gD` / `-Dg`. An
285
+ // explicit falsy value (`--global=false|0|no|off`) is NOT a global install —
286
+ // it disables the flag — so it must not match.
287
+ function isGlobalFlag(word: string): boolean {
288
+ if (/^--global=(?:false|0|no|off)$/i.test(word)) return false
289
+ if (/^--global(?:=|$)/.test(word)) return true
290
+ return /^-[A-Za-z]*g[A-Za-z]*$/.test(word)
291
+ }
292
+
293
+ function blockGlobalInstall(label: string, args: Record<string, unknown>): GuardBlock | undefined {
294
+ if (isGuardAcknowledged(args, GUARD_GLOBAL_INSTALL)) return undefined
295
+
296
+ return {
297
+ block: true,
298
+ reason: [
299
+ `Guard \`${GUARD_GLOBAL_INSTALL}\` blocked a global install: ${label}.`,
300
+ 'Global installs live outside the bind-mounted /agent folder and are wiped on every container restart, so they never persist.',
301
+ 'Use `bun add <pkg>` to add a dependency that survives restarts (it writes package.json), or `bunx <pkg>` to run a tool once without installing.',
302
+ `Retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_GLOBAL_INSTALL}: true\` only if a throwaway global install is genuinely what you want.`,
303
+ ].join(' '),
304
+ }
305
+ }
306
+
307
+ function blockNonBunManager(manager: string, args: Record<string, unknown>): GuardBlock | undefined {
308
+ if (isGuardAcknowledged(args, GUARD_NON_BUN_PACKAGE_MANAGER)) return undefined
309
+
310
+ return {
311
+ block: true,
312
+ reason: [
313
+ `Guard \`${GUARD_NON_BUN_PACKAGE_MANAGER}\` blocked \`${manager}\`. This container standardizes on bun.`,
314
+ 'Use `bun install` / `bun add <pkg>` instead of npm/pnpm/yarn, and `bunx <pkg>` instead of npx/pnpx.',
315
+ `Retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_NON_BUN_PACKAGE_MANAGER}: true\` if this package manager is genuinely required (e.g. a project pinned to a different lockfile).`,
316
+ ].join(' '),
317
+ }
318
+ }
@@ -1,7 +1,11 @@
1
1
  export type GhCommandDecision =
2
2
  | { kind: 'pass-through' }
3
3
  | { kind: 'block'; reason: string }
4
- | { kind: 'inject'; repoSlug: string }
4
+ // `rewrittenCommand`, when present, MUST replace the executed command: `gh api`
5
+ // rejects `-R/--repo` ("unknown shorthand flag"), so for a graphql endpoint the
6
+ // flag is consumed as our repo hint and stripped before exec. Other inject paths
7
+ // (REST, non-`api` subcommands) leave the command unchanged and omit it.
8
+ | { kind: 'inject'; repoSlug: string; rewrittenCommand?: string }
5
9
 
6
10
  const MISSING_REPO_REASON =
7
11
  'This GitHub App spans multiple owners, so `gh` has no single correct token. ' +
@@ -27,7 +31,9 @@ const API_REPO_CONFLICT_REASON =
27
31
  type GhSegmentDecision =
28
32
  | { kind: 'pass-through' }
29
33
  | { kind: 'block'; reason: string }
30
- | { kind: 'inject'; repoSlugs: readonly string[] }
34
+ // `stripRepoFlag` marks a graphql inject whose `-R/--repo` is a TypeClaw-only
35
+ // hint that `gh api` would reject, so it must be removed from the command.
36
+ | { kind: 'inject'; repoSlugs: readonly string[]; stripRepoFlag?: boolean }
31
37
 
32
38
  const COMPOSITION_REASON =
33
39
  'A repo-targeting `gh` command receives a minted GitHub App token in its process ' +
@@ -117,13 +123,17 @@ export function analyzeGhCommand(command: string): GhCommandDecision {
117
123
  if (ghStarts.length === 0) return { kind: 'pass-through' }
118
124
 
119
125
  const repoSlugs: string[] = []
126
+ let stripRepoFlag = false
120
127
  for (let i = 0; i < ghStarts.length; i++) {
121
128
  const start = ghStarts[i] as number
122
129
  const end = ghStarts[i + 1] ?? tokens.length
123
130
  const args = tokens.slice(start + 1, end)
124
131
  const segment = classifyGhSegment(args)
125
132
  if (segment.kind === 'block') return segment
126
- if (segment.kind === 'inject') repoSlugs.push(...segment.repoSlugs)
133
+ if (segment.kind === 'inject') {
134
+ repoSlugs.push(...segment.repoSlugs)
135
+ if (segment.stripRepoFlag === true) stripRepoFlag = true
136
+ }
127
137
  }
128
138
 
129
139
  if (repoSlugs.length === 0) return { kind: 'pass-through' }
@@ -135,9 +145,71 @@ export function analyzeGhCommand(command: string): GhCommandDecision {
135
145
  // shell expansion would inherit it.
136
146
  if (!isSingleBareGhCommand(command)) return { kind: 'block', reason: COMPOSITION_REASON }
137
147
 
148
+ if (stripRepoFlag) {
149
+ return { kind: 'inject', repoSlug: repoSlugs[0] as string, rewrittenCommand: stripRepoFlagFromCommand(command) }
150
+ }
138
151
  return { kind: 'inject', repoSlug: repoSlugs[0] as string }
139
152
  }
140
153
 
154
+ // Removes an unquoted `-R`/`--repo` flag (and its repo-slug value) from a single
155
+ // bare command, preserving everything else byte-for-byte. Quote-aware so a `-R`
156
+ // inside a quoted `-f query='...'` value is never touched; a repo slug is
157
+ // owner/name (no whitespace), so the value is always a single unquoted token.
158
+ // Used only for graphql, where `gh api` rejects the flag we consumed as a hint.
159
+ function stripRepoFlagFromCommand(command: string): string {
160
+ let out = ''
161
+ let i = 0
162
+ while (i < command.length) {
163
+ const ch = command[i] as string
164
+ if (ch === '"' || ch === "'") {
165
+ const close = command.indexOf(ch, i + 1)
166
+ const endQuote = close === -1 ? command.length : close
167
+ out += command.slice(i, endQuote + 1)
168
+ i = endQuote + 1
169
+ continue
170
+ }
171
+ const removed = matchRepoFlagAt(command, i)
172
+ if (removed !== null) {
173
+ out = out.replace(/[ \t]+$/, '')
174
+ i = removed
175
+ while (command[i] === ' ' || command[i] === '\t') i += 1
176
+ if (out !== '' && i < command.length) out += ' '
177
+ continue
178
+ }
179
+ out += ch
180
+ i += 1
181
+ }
182
+ return out
183
+ }
184
+
185
+ // If `command` has an unquoted `-R`/`--repo` repo-flag token starting at `start`
186
+ // (at a word boundary), returns the index just past the flag and its value;
187
+ // otherwise null. Handles `-R o/r`, `--repo o/r`, `-R=o/r`, `--repo=o/r`.
188
+ function matchRepoFlagAt(command: string, start: number): number | null {
189
+ const before = start === 0 ? '' : (command[start - 1] as string)
190
+ if (before !== '' && before !== ' ' && before !== '\t') return null
191
+
192
+ for (const flag of ['--repo', '-R']) {
193
+ if (!command.startsWith(flag, start)) continue
194
+ let i = start + flag.length
195
+ const sep = command[i]
196
+ if (sep === '=') {
197
+ i += 1
198
+ while (i < command.length && command[i] !== ' ' && command[i] !== '\t') i += 1
199
+ return i
200
+ }
201
+ if (sep === ' ' || sep === '\t') {
202
+ let j = i
203
+ while (command[j] === ' ' || command[j] === '\t') j += 1
204
+ const valueStart = j
205
+ while (j < command.length && command[j] !== ' ' && command[j] !== '\t') j += 1
206
+ if (!isRepoSlug(command.slice(valueStart, j))) return null
207
+ return j
208
+ }
209
+ }
210
+ return null
211
+ }
212
+
141
213
  function classifyGhSegment(args: readonly string[]): GhSegmentDecision {
142
214
  const subcommand = args.find((t) => !t.startsWith('-'))
143
215
  if (subcommand === undefined) return { kind: 'pass-through' }
@@ -159,8 +231,9 @@ function classifyGhSegment(args: readonly string[]): GhSegmentDecision {
159
231
  // Repo authority for `gh api`: the literal endpoint path wins. A `-R/--repo`
160
232
  // that names a DIFFERENT repo than the path is a mint-for-X-but-hit-Y attempt
161
233
  // and blocks. A placeholder endpoint (`repos/{owner}/{repo}`) has no literal
162
- // target, so -R fills it and is authoritative. A non-repo endpoint (`graphql`,
163
- // `/user`) passes through — -R does not make it repo-scoped, so no mint.
234
+ // target, so -R fills it and is authoritative. A non-repo endpoint without a
235
+ // `-R` (`graphql`, `/user`) passes through — the flag is what makes it
236
+ // repo-scoped, so absent one there is nothing to mint for.
164
237
  function classifyGhApiSegment(args: readonly string[]): GhSegmentDecision {
165
238
  const pathRepos = extractReposFromApiPath(args)
166
239
  const flagRepo = extractRepoFlag(args)
@@ -176,9 +249,22 @@ function classifyGhApiSegment(args: readonly string[]): GhSegmentDecision {
176
249
  return { kind: 'inject', repoSlugs: [flagRepo] }
177
250
  }
178
251
 
252
+ // graphql encodes its repo in the query body / opaque node IDs, never an
253
+ // inspectable path, so `-R` is taken as the mint hint. Safe because there is
254
+ // no literal path to conflict with (cf. the API_REPO_CONFLICT_REASON guard
255
+ // above): the minted token's installation scope, not the flag, bounds reach.
256
+ // `gh api` rejects `-R`, so the flag must be stripped from the command.
257
+ if (flagRepo !== null && isGraphqlEndpoint(args)) {
258
+ return { kind: 'inject', repoSlugs: [flagRepo], stripRepoFlag: true }
259
+ }
260
+
179
261
  return { kind: 'pass-through' }
180
262
  }
181
263
 
264
+ function isGraphqlEndpoint(args: readonly string[]): boolean {
265
+ return findApiEndpoint(args) === 'graphql'
266
+ }
267
+
182
268
  function findGhInvocations(tokens: readonly string[]): number[] {
183
269
  const starts: number[] = []
184
270
  for (let i = 0; i < tokens.length; i++) {
@@ -249,6 +335,12 @@ const GH_API_VALUE_FLAGS = new Set([
249
335
  '--hostname',
250
336
  ])
251
337
 
338
+ // `-R`/`--repo` are not real `gh api` flags, but TypeClaw accepts them as a repo
339
+ // hint that can appear BEFORE the endpoint (`gh api -R o/r graphql`). They consume
340
+ // the following token, so findApiEndpoint must skip both — otherwise the slug is
341
+ // misread as the endpoint and the graphql path never runs.
342
+ const REPO_HINT_VALUE_FLAGS = new Set(['-R', '--repo'])
343
+
252
344
  // The `gh api` endpoint is the first positional arg after `api` (skipping flags
253
345
  // and the tokens that bare value-flags consume). Returns null if there is none.
254
346
  function findApiEndpoint(args: readonly string[]): string | null {
@@ -257,7 +349,7 @@ function findApiEndpoint(args: readonly string[]): string | null {
257
349
  for (let i = apiIndex + 1; i < args.length; i++) {
258
350
  const arg = args[i] as string
259
351
  if (arg.startsWith('-')) {
260
- if (!arg.includes('=') && GH_API_VALUE_FLAGS.has(arg)) i += 1
352
+ if (!arg.includes('=') && (GH_API_VALUE_FLAGS.has(arg) || REPO_HINT_VALUE_FLAGS.has(arg))) i += 1
261
353
  continue
262
354
  }
263
355
  return arg
@@ -0,0 +1,80 @@
1
+ import type { ContentPart, ToolResult } from '@/plugin'
2
+
3
+ import { classifyGhToken } from './token-class'
4
+
5
+ export const GRAPHQL_AUTH_NUDGE_TAG = 'github-cli-auth:graphqlRepoHint'
6
+
7
+ const NUDGE_TEXT =
8
+ `\n\n[${GRAPHQL_AUTH_NUDGE_TAG}] That looked like a GitHub auth failure on a ` +
9
+ '`gh api graphql` call. Under a multi-owner GitHub App there is no single ' +
10
+ '`GH_TOKEN`, and graphql carries its repo inside the query — not an inspectable ' +
11
+ 'path — so TypeClaw cannot tell which installation token to mint. Re-run with an ' +
12
+ 'explicit repo, e.g. `gh api graphql -R owner/repo -f query=...`. `gh api` does ' +
13
+ 'not accept `-R/--repo`; TypeClaw consumes it as the mint hint and strips it ' +
14
+ 'before running the command with the right token injected.'
15
+
16
+ // The shell strips quotes/escapes, so we match the raw `gh ... graphql` substring
17
+ // rather than parse — the nudge is advisory, so a loose match is acceptable and a
18
+ // false positive only appends a hint to an unrelated failure. Captured through to
19
+ // end of line so we can inspect the SAME invocation's flags (see REPO_FLAG).
20
+ const GRAPHQL_INVOCATION = /\bgh\b[^\n]*\bapi\b[^\n]*\bgraphql\b[^\n]*/
21
+
22
+ // `-R foo`, `-R=foo`, `--repo foo`, `--repo=foo`. Word-boundary anchored so a
23
+ // path or token merely containing "repo" does not count as a repo hint.
24
+ const REPO_FLAG = /(?:^|\s)(?:-R|--repo)(?:[=\s]|$)/
25
+
26
+ // Concrete auth-rejection strings only — gh / the API emit these when the
27
+ // request itself was rejected for auth. Deliberately excludes resolution
28
+ // errors like "Could not resolve to ...", which graphql also returns for an
29
+ // ordinary bad node ID or missing repo (not auth) and would misroute the nudge.
30
+ const AUTH_FAILURE_SIGNATURES = [
31
+ 'HTTP 401',
32
+ 'Bad credentials',
33
+ 'Resource not accessible by integration',
34
+ 'Resource not accessible by personal access token',
35
+ 'must be authenticated',
36
+ 'authentication required',
37
+ 'gh auth login',
38
+ ]
39
+
40
+ export function checkGraphqlAuthNudge(options: { tool: string; result: ToolResult }): void {
41
+ if (options.tool !== 'bash') return
42
+
43
+ // Only meaningful when no usable token is seeded — the multi-owner App case.
44
+ // A seeded global token (single-owner App, classic/fine-grained PAT) means
45
+ // graphql already authenticates, so the advice would be wrong.
46
+ const tokenClass = classifyGhToken(process.env.GH_TOKEN)
47
+ if (tokenClass !== 'none') return
48
+
49
+ const text = collectText(options.result.content)
50
+ const invocation = GRAPHQL_INVOCATION.exec(text)
51
+ if (invocation === null) return
52
+ if (!AUTH_FAILURE_SIGNATURES.some((sig) => text.includes(sig))) return
53
+ if (text.includes(GRAPHQL_AUTH_NUDGE_TAG)) return
54
+
55
+ // The nudge's only message is "add the repo hint". If the failing invocation
56
+ // already carries -R/--repo, the hint is present and the failure is an
57
+ // authorization/permission denial (e.g. the App token lacks a scope), not a
58
+ // missing-repo one — so "re-run with -R" would be misleading, repeated advice.
59
+ if (REPO_FLAG.test(invocation[0])) return
60
+
61
+ appendAdviceToContent(options.result.content, NUDGE_TEXT)
62
+ }
63
+
64
+ function collectText(content: readonly ContentPart[]): string {
65
+ return content
66
+ .filter((p): p is ContentPart & { type: 'text' } => p.type === 'text')
67
+ .map((p) => p.text)
68
+ .join('\n')
69
+ }
70
+
71
+ function appendAdviceToContent(content: ContentPart[], advice: string): void {
72
+ for (let i = content.length - 1; i >= 0; i--) {
73
+ const part = content[i]
74
+ if (part && part.type === 'text') {
75
+ content[i] = { ...part, text: `${part.text}${advice}` }
76
+ return
77
+ }
78
+ }
79
+ content.push({ type: 'text', text: advice.trimStart() })
80
+ }