typeclaw 0.21.0 → 0.23.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 (47) 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/session-origin.ts +41 -2
  5. package/src/bundled-plugins/bun-hygiene/README.md +82 -0
  6. package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
  7. package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
  8. package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
  9. package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
  10. package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
  11. package/src/bundled-plugins/memory/memory-logger.ts +34 -12
  12. package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
  13. package/src/channels/adapters/discord-bot.ts +8 -0
  14. package/src/channels/adapters/github/inbound.ts +23 -1
  15. package/src/channels/adapters/github/index.ts +9 -0
  16. package/src/channels/adapters/slack-bot.ts +112 -5
  17. package/src/channels/adapters/telegram-bot.ts +11 -0
  18. package/src/channels/manager.ts +8 -0
  19. package/src/channels/router.ts +100 -15
  20. package/src/channels/schema.ts +18 -0
  21. package/src/channels/types.ts +27 -0
  22. package/src/cli/dreams.ts +2 -1
  23. package/src/cli/inspect-controller.ts +92 -0
  24. package/src/cli/inspect.ts +21 -123
  25. package/src/cli/ui.ts +34 -0
  26. package/src/commands/index.ts +5 -2
  27. package/src/config/config.ts +89 -0
  28. package/src/inspect/index.ts +8 -26
  29. package/src/inspect/live.ts +17 -3
  30. package/src/inspect/loop.ts +23 -17
  31. package/src/mcp/catalog.ts +29 -0
  32. package/src/mcp/client.ts +236 -0
  33. package/src/mcp/index.ts +25 -0
  34. package/src/mcp/manager.ts +156 -0
  35. package/src/mcp/tools.ts +190 -0
  36. package/src/permissions/builtins.ts +9 -0
  37. package/src/reload/format.ts +14 -0
  38. package/src/reload/index.ts +1 -0
  39. package/src/run/bundled-plugins.ts +7 -0
  40. package/src/run/channel-session-factory.ts +3 -0
  41. package/src/run/index.ts +38 -1
  42. package/src/server/command-runner.ts +5 -0
  43. package/src/server/index.ts +4 -0
  44. package/src/skills/typeclaw-channel-github/SKILL.md +83 -13
  45. package/src/skills/typeclaw-config/SKILL.md +1 -1
  46. package/src/skills/typeclaw-git/SKILL.md +1 -1
  47. package/typeclaw.schema.json +82 -0
@@ -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
+ }
@@ -2,6 +2,7 @@ import { TYPECLAW_INTERNAL_BASH_ENV } from '@/agent/plugin-tools'
2
2
  import { definePlugin } from '@/plugin'
3
3
 
4
4
  import { analyzeGhCommand } from './gh-command'
5
+ import { checkGraphqlAuthNudge } from './graphql-auth-nudge'
5
6
  import { classifyGhToken } from './token-class'
6
7
 
7
8
  export default definePlugin({
@@ -34,8 +35,14 @@ export default definePlugin({
34
35
  // --setenv by the bash wrapper) so the token never enters the command
35
36
  // string, where it could leak through logs or later hooks.
36
37
  event.args[TYPECLAW_INTERNAL_BASH_ENV] = { GH_TOKEN: result.token }
38
+ // graphql consumed `-R/--repo` as a mint hint; `gh api` rejects it, so
39
+ // run the command with the flag stripped (token still rides in env).
40
+ if (decision.rewrittenCommand !== undefined) event.args.command = decision.rewrittenCommand
37
41
  return
38
42
  },
43
+ 'tool.after': async (event) => {
44
+ checkGraphqlAuthNudge({ tool: event.tool, result: event.result })
45
+ },
39
46
  },
40
47
  }
41
48
  },