typeclaw 0.37.5 → 0.37.6

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.
@@ -0,0 +1,339 @@
1
+ // typeclaw doc-render — themed report library
2
+ // =============================================
3
+ // Turns Markdown (rendered via `cmarker`) into a deliberately designed PDF. The
4
+ // `report` template styles every element and adds a cover / masthead, running
5
+ // header, and footer. Pick a theme; that is the main choice.
6
+ //
7
+ // FONT CONSTRAINT. The container ships only Typst's bundled text faces
8
+ // ("Libertinus Serif", "New Computer Modern") plus the mono "DejaVu Sans Mono".
9
+ // Every theme is built on those so output is identical in-container and on a dev
10
+ // box. Beauty here comes from composition — type scale, a real cover, tracking,
11
+ // rules, restrained color, generous rhythm — not from swapping typefaces. CJK
12
+ // families are appended as fallbacks (resolve only when `docker.file.cjkFonts`
13
+ // is on; harmless otherwise).
14
+ //
15
+ // USAGE (wrapper written next to the markdown):
16
+ // #import "lib.typ": report, callout
17
+ // #show: report.with(theme: "editorial", title: "…", subtitle: "…",
18
+ // date: "…", author: "…")
19
+ // #import "@preview/cmarker:0.1.8"
20
+ // #cmarker.render(read("report.md"), h1-level: 1,
21
+ // blockquote: quote.with(block: true), scope: (callout: callout))
22
+
23
+ // --- fonts -----------------------------------------------------------------
24
+
25
+ #let _serif = ("Libertinus Serif", "New Computer Modern", "Noto Serif CJK KR", "Noto Serif CJK JP", "Noto Serif CJK SC")
26
+ #let _mono = ("DejaVu Sans Mono", "Noto Sans Mono CJK KR", "Noto Sans Mono CJK JP", "Noto Sans Mono CJK SC")
27
+
28
+ // --- color derivation (one accent does the work) ---------------------------
29
+
30
+ #let _muted(spec) = color.mix((spec.ink, 58%), (white, 42%), space: oklab)
31
+ #let _hair(spec) = color.mix((spec.ink, 16%), (white, 84%), space: oklab)
32
+ #let _tint(spec) = color.mix((spec.accent, 8%), (white, 92%), space: oklab)
33
+ #let _link(spec) = color.mix((spec.accent, 78%), (spec.ink, 22%), space: oklab)
34
+
35
+ // --- theme registry --------------------------------------------------------
36
+ // `t*` fields are em-multipliers off `base`, giving each theme an explicit,
37
+ // designed type scale. `cover` selects the masthead treatment; `id` drives the
38
+ // per-theme heading/table/cover branches below.
39
+
40
+ #let _themes = (
41
+ editorial: (
42
+ id: "editorial",
43
+ body: _serif,
44
+ base: 11pt,
45
+ tTitle: 3.1,
46
+ tH1: 1.7,
47
+ tH2: 1.22,
48
+ tH3: 1.05,
49
+ accent: rgb("#7b2d3b"),
50
+ ink: rgb("#22202a"),
51
+ justify: true,
52
+ leading: 0.74em,
53
+ spacing: 1.2em,
54
+ margin: (x: 2.7cm, top: 2.7cm, bottom: 2.6cm),
55
+ cover: "page",
56
+ header: true,
57
+ ),
58
+ modern: (
59
+ id: "modern",
60
+ body: _serif,
61
+ base: 11pt,
62
+ tTitle: 2.7,
63
+ tH1: 1.75,
64
+ tH2: 1.25,
65
+ tH3: 1.05,
66
+ accent: rgb("#4f46e5"),
67
+ ink: rgb("#0f172a"),
68
+ justify: false,
69
+ leading: 0.8em,
70
+ spacing: 1.3em,
71
+ margin: (x: 2.4cm, top: 2.4cm, bottom: 2.5cm),
72
+ cover: "masthead",
73
+ header: true,
74
+ ),
75
+ report: (
76
+ id: "report",
77
+ body: _serif,
78
+ base: 10.5pt,
79
+ tTitle: 2.7,
80
+ tH1: 1.45,
81
+ tH2: 1.18,
82
+ tH3: 1.02,
83
+ accent: rgb("#1d4ed8"),
84
+ ink: rgb("#111827"),
85
+ justify: true,
86
+ leading: 0.7em,
87
+ spacing: 1.05em,
88
+ margin: (x: 2.3cm, top: 2.4cm, bottom: 2.3cm),
89
+ cover: "page",
90
+ header: true,
91
+ ),
92
+ minimal: (
93
+ id: "minimal",
94
+ body: _serif,
95
+ base: 11.5pt,
96
+ tTitle: 2.2,
97
+ tH1: 1.5,
98
+ tH2: 1.18,
99
+ tH3: 1.02,
100
+ accent: rgb("#171717"),
101
+ ink: rgb("#1c1c1c"),
102
+ justify: false,
103
+ leading: 0.85em,
104
+ spacing: 1.45em,
105
+ margin: (x: 3.4cm, top: 3.2cm, bottom: 3.2cm),
106
+ cover: "title",
107
+ header: false,
108
+ ),
109
+ )
110
+
111
+ #let _resolve(theme) = {
112
+ if theme not in _themes {
113
+ panic("unknown theme '" + theme + "' — use one of: " + _themes.keys().join(", "))
114
+ }
115
+ _themes.at(theme)
116
+ }
117
+
118
+ #let _eyebrow(spec, body, fill: auto) = text(
119
+ font: _mono,
120
+ size: spec.base * 0.7,
121
+ weight: "medium",
122
+ tracking: 0.16em,
123
+ fill: if fill == auto { _muted(spec) } else { fill },
124
+ )[#upper(body)]
125
+
126
+ #let _meta-line(author, date) = {
127
+ if author != none and date != none { author + " · " + date } else if author != none { author } else if date != none { date } else { "" }
128
+ }
129
+
130
+ // --- public helpers (drop into markdown via <!--raw-typst … -->) ------------
131
+
132
+ #let callout(kind: "note", title: none, body) = {
133
+ let p = (
134
+ note: (bar: rgb("#3b82f6"), bg: rgb("#eff6ff"), label: "Note"),
135
+ tip: (bar: rgb("#22c55e"), bg: rgb("#f0fdf4"), label: "Tip"),
136
+ success: (bar: rgb("#16a34a"), bg: rgb("#f0fdf4"), label: "Success"),
137
+ warning: (bar: rgb("#f59e0b"), bg: rgb("#fffbeb"), label: "Warning"),
138
+ danger: (bar: rgb("#ef4444"), bg: rgb("#fef2f2"), label: "Caution"),
139
+ ).at(kind, default: (bar: rgb("#3b82f6"), bg: rgb("#eff6ff"), label: "Note"))
140
+ block(width: 100%, fill: p.bg, stroke: (left: 3pt + p.bar), inset: (left: 13pt, rest: 11pt), radius: (right: 4pt), breakable: false)[
141
+ #text(weight: "bold", fill: p.bar.darken(18%))[#if title != none { title } else { p.label }.]
142
+ #h(0.4em)
143
+ #body
144
+ ]
145
+ v(0.6em)
146
+ }
147
+
148
+ #let kpi(value, label, sub: none, accent: rgb("#1d4ed8")) = block(width: 100%, inset: 13pt, stroke: (bottom: 2.5pt + accent, rest: 0.5pt + luma(225)), fill: white)[
149
+ #align(center)[
150
+ #text(size: 1.9em, weight: "bold", fill: accent.darken(15%))[#value]
151
+ #v(0.25em)
152
+ #text(font: _mono, size: 0.62em, weight: "medium", tracking: 0.12em, fill: luma(110))[#upper(label)]
153
+ #if sub != none { v(0.15em); text(size: 0.72em, fill: luma(150))[#sub] }
154
+ ]
155
+ ]
156
+
157
+ #let kpi-row(..cards) = {
158
+ let items = cards.pos()
159
+ block(width: 100%, breakable: false, above: 0.9em, below: 0.9em, grid(columns: items.len(), column-gutter: 12pt, ..items))
160
+ }
161
+
162
+ #let pullquote(body, by: none) = {
163
+ v(0.4em)
164
+ block(width: 100%, inset: (x: 8%, y: 0.6em))[
165
+ #align(center)[
166
+ #text(size: 1.35em, style: "italic", fill: luma(60))[#body]
167
+ #if by != none { v(0.5em); text(font: _mono, size: 0.72em, tracking: 0.1em, fill: luma(130))[#upper("— " + by)] }
168
+ ]
169
+ ]
170
+ v(0.4em)
171
+ }
172
+
173
+ // --- cover treatments ------------------------------------------------------
174
+
175
+ #let _cover-page-editorial(spec, title, subtitle, date, author) = {
176
+ page(margin: (x: spec.margin.x, top: 3cm, bottom: 2.8cm), header: none, footer: none)[
177
+ #set text(font: spec.body, fill: spec.ink)
178
+ #set par(justify: false)
179
+ #_eyebrow(spec, _meta-line(author, date))
180
+ #v(1fr)
181
+ #text(size: spec.base * spec.tTitle, weight: "bold")[#title]
182
+ #if subtitle != none {
183
+ v(0.5em)
184
+ text(size: spec.base * 1.3, style: "italic", fill: _muted(spec))[#subtitle]
185
+ }
186
+ #v(0.8em)
187
+ #line(length: 38%, stroke: 1.4pt + spec.accent)
188
+ #v(1.4fr)
189
+ ]
190
+ pagebreak(weak: true)
191
+ }
192
+
193
+ #let _cover-page-report(spec, title, subtitle, date, author) = {
194
+ page(margin: 0pt, header: none, footer: none)[
195
+ #set text(font: spec.body, fill: spec.ink)
196
+ #set par(justify: false)
197
+ #block(width: 100%, height: 4.5cm, fill: spec.accent, inset: (x: spec.margin.x, top: 2.4cm))[
198
+ #_eyebrow(spec, "Report", fill: white.transparentize(25%))
199
+ ]
200
+ #pad(x: spec.margin.x, top: 1.6cm)[
201
+ #text(size: spec.base * spec.tTitle, weight: "bold")[#title]
202
+ #if subtitle != none {
203
+ v(0.5em)
204
+ text(size: spec.base * 1.25, fill: _muted(spec))[#subtitle]
205
+ }
206
+ ]
207
+ #place(bottom + left, dx: spec.margin.x, dy: -2.4cm, _eyebrow(spec, _meta-line(author, date)))
208
+ ]
209
+ pagebreak(weak: true)
210
+ }
211
+
212
+ #let _masthead-modern(spec, title, subtitle, date, author) = {
213
+ place(top + left, dx: -spec.margin.x, dy: -spec.margin.top, rect(width: 100% + spec.margin.x * 2, height: 6pt, fill: spec.accent))
214
+ v(0.4em)
215
+ set par(justify: false)
216
+ _eyebrow(spec, _meta-line(author, date), fill: spec.accent)
217
+ v(0.5em)
218
+ text(size: spec.base * spec.tTitle, weight: "bold", fill: spec.ink)[#title]
219
+ if subtitle != none {
220
+ v(0.35em)
221
+ text(size: spec.base * 1.2, fill: _muted(spec))[#subtitle]
222
+ }
223
+ v(0.7em)
224
+ line(length: 100%, stroke: 1.5pt + spec.accent)
225
+ v(1.4em)
226
+ }
227
+
228
+ #let _title-minimal(spec, title, subtitle, date, author) = {
229
+ set par(justify: false)
230
+ v(0.5em)
231
+ text(size: spec.base * spec.tTitle, weight: "medium", fill: spec.ink)[#title]
232
+ if subtitle != none {
233
+ v(0.5em)
234
+ text(size: spec.base * 1.15, fill: _muted(spec))[#subtitle]
235
+ }
236
+ let m = _meta-line(author, date)
237
+ if m != "" {
238
+ v(0.9em)
239
+ _eyebrow(spec, m)
240
+ }
241
+ v(2.4em)
242
+ }
243
+
244
+ // --- the report template ---------------------------------------------------
245
+
246
+ #let report(title: none, subtitle: none, date: none, author: none, theme: "editorial", accent: auto, cover: auto, body) = {
247
+ let spec = _resolve(theme)
248
+ if accent != auto { spec.accent = accent }
249
+ let cover-kind = if cover == auto { spec.cover } else { cover }
250
+ set document(title: if title != none { title } else { "Document" })
251
+
252
+ set page(
253
+ paper: "a4",
254
+ margin: spec.margin,
255
+ header: context {
256
+ if spec.header and counter(page).get().first() > 1 {
257
+ block(width: 100%)[
258
+ #_eyebrow(spec, if title != none { title } else { "" }) #h(1fr) #_eyebrow(spec, if author != none { author } else { "" })
259
+ #v(-0.5em)
260
+ #line(length: 100%, stroke: 0.5pt + _hair(spec))
261
+ ]
262
+ }
263
+ },
264
+ footer: context {
265
+ align(center, _eyebrow(spec, [#counter(page).get().first() / #counter(page).final().first()]))
266
+ },
267
+ )
268
+
269
+ set text(font: spec.body, size: spec.base, fill: spec.ink, lang: "en")
270
+ set par(justify: spec.justify, leading: spec.leading, spacing: spec.spacing, linebreaks: "optimized")
271
+ show link: it => text(fill: _link(spec), underline(it))
272
+
273
+ set heading(numbering: none)
274
+ show heading.where(level: 1): it => {
275
+ if spec.id == "editorial" {
276
+ block(above: 2em, below: 1.55em, width: 100%)[
277
+ #text(size: spec.base * spec.tH1, weight: "bold", tracking: 0.02em)[#smallcaps(it.body)]
278
+ #v(-0.3em)
279
+ #line(length: 100%, stroke: 0.6pt + _hair(spec))
280
+ ]
281
+ } else if spec.id == "modern" {
282
+ block(above: 1.9em, below: 1.5em, width: 100%)[
283
+ #grid(columns: (3.5pt, 1fr), column-gutter: 11pt, rect(width: 3.5pt, height: 0.95em, fill: spec.accent, radius: 1pt), text(size: spec.base * spec.tH1, weight: "bold")[#it.body])
284
+ ]
285
+ } else if spec.id == "report" {
286
+ block(above: 1.7em, below: 1.4em, width: 100%)[
287
+ #text(size: spec.base * spec.tH1, weight: "bold", fill: spec.ink)[#it.body]
288
+ #v(-0.28em)
289
+ #line(length: 100%, stroke: 1.4pt + spec.accent)
290
+ ]
291
+ } else {
292
+ block(above: 2.2em, below: 1.6em)[
293
+ #text(size: spec.base * spec.tH1, weight: "medium", fill: spec.ink)[#it.body]
294
+ ]
295
+ }
296
+ }
297
+ show heading.where(level: 2): it => block(above: 1.85em, below: 1.45em)[
298
+ #if spec.id == "editorial" {
299
+ text(size: spec.base * spec.tH2, weight: "semibold", fill: spec.accent)[#smallcaps(it.body)]
300
+ } else {
301
+ text(size: spec.base * spec.tH2, weight: "semibold", fill: if spec.id == "minimal" { _muted(spec) } else { spec.accent })[#it.body]
302
+ }
303
+ ]
304
+ show heading.where(level: 3): it => block(above: 1.5em, below: 1.15em)[
305
+ #text(size: spec.base * spec.tH3, weight: "semibold", fill: spec.ink)[#it.body]
306
+ ]
307
+
308
+ show quote.where(block: true): it => block(width: 100%, inset: (left: 1.1em, y: 0.25em), stroke: (left: 2.5pt + spec.accent.lighten(10%)), text(style: "italic", fill: _muted(spec), it.body))
309
+
310
+ show raw.where(block: true): it => block(width: 100%, fill: rgb("#f8fafc"), stroke: 0.5pt + _hair(spec), inset: 9pt, radius: 4pt, breakable: false, text(font: _mono, size: 0.85em, it))
311
+ show raw.where(block: false): it => box(fill: rgb("#f1f5f9"), inset: (x: 3pt), outset: (y: 3pt), radius: 2pt, text(font: _mono, size: 0.88em, it))
312
+
313
+ // set/show must stay at the function's top level (a `set` nested in an `if`
314
+ // block only scopes to that block and never reaches `body`).
315
+ let _zebra = spec.id == "modern" or spec.id == "report"
316
+ set table(
317
+ stroke: if _zebra { none } else { (_, y) => (top: if y == 0 { 0.9pt + spec.ink } else { 0pt }, bottom: 0.5pt + _hair(spec)) },
318
+ fill: if _zebra { (_, y) => if y == 0 { spec.accent } else if calc.odd(y) { _tint(spec) } else { white } } else { none },
319
+ inset: if _zebra { (x: 10pt, y: 7pt) } else { (x: 4pt, y: 6pt) },
320
+ )
321
+ show table.cell.where(y: 0): set text(fill: if _zebra { white } else { spec.ink }, weight: "bold")
322
+
323
+ show figure.caption: it => text(size: 0.82em, fill: _muted(spec))[#it.supplement #context it.counter.display(it.numbering). #it.body]
324
+ set image(width: 82%)
325
+
326
+ if title != none and cover-kind != none {
327
+ if cover-kind == "page" and spec.id == "report" {
328
+ _cover-page-report(spec, title, subtitle, date, author)
329
+ } else if cover-kind == "page" {
330
+ _cover-page-editorial(spec, title, subtitle, date, author)
331
+ } else if cover-kind == "masthead" {
332
+ _masthead-modern(spec, title, subtitle, date, author)
333
+ } else if cover-kind == "title" {
334
+ _title-minimal(spec, title, subtitle, date, author)
335
+ }
336
+ }
337
+
338
+ body
339
+ }
@@ -1,6 +1,12 @@
1
+ // `code` classifies a block so the caller can react structurally instead of
2
+ // string-matching the reason: only `missing-repo` is eligible for a trusted
3
+ // repo-fallback (origin/cwd) that turns the block into a mint; `composition`,
4
+ // `multi-owner`, `api-repo-conflict`, and `non-literal-repo` must stay blocks.
5
+ export type GhBlockCode = 'missing-repo' | 'non-literal-repo' | 'composition' | 'multi-owner' | 'api-repo-conflict'
6
+
1
7
  export type GhCommandDecision =
2
8
  | { kind: 'pass-through' }
3
- | { kind: 'block'; reason: string }
9
+ | { kind: 'block'; code: GhBlockCode; reason: string }
4
10
  // `rewrittenCommand`, when present, MUST replace the executed command: `gh api`
5
11
  // rejects `-R/--repo` ("unknown shorthand flag"), so for a graphql endpoint the
6
12
  // flag is consumed as our repo hint and stripped before exec. Other inject paths
@@ -9,8 +15,16 @@ export type GhCommandDecision =
9
15
 
10
16
  const MISSING_REPO_REASON =
11
17
  'This GitHub App spans multiple owners, so `gh` has no single correct token. ' +
12
- 'Re-run with an explicit repo: `gh <cmd> -R owner/repo` (or `gh api /repos/owner/repo/...`) ' +
13
- 'so the right installation token can be injected.'
18
+ 'Re-run as a single bare command with a LITERAL repo: `gh <cmd> -R owner/repo` ' +
19
+ '(or `gh api /repos/owner/repo/...`) so the right installation token can be injected. ' +
20
+ 'The repo must be a concrete `owner/repo` slug, not a shell variable.'
21
+
22
+ const NON_LITERAL_REPO_REASON =
23
+ 'The `-R/--repo` value is not a literal `owner/repo` slug TypeClaw can verify. ' +
24
+ 'Shell variables like `-R "$repo"` are not readable by the static GitHub App token ' +
25
+ 'guard (it never expands the shell). Re-run as a single bare command with a literal ' +
26
+ 'repo: `gh <cmd> -R owner/repo`, or `gh api /repos/owner/repo/...`; only a trailing ' +
27
+ 'stdin-only reader pipeline such as `| jq ...` may follow.'
14
28
 
15
29
  const MULTI_OWNER_REASON =
16
30
  'This command targets repos under more than one owner; a single GH_TOKEN cannot ' +
@@ -30,12 +44,15 @@ const API_REPO_CONFLICT_REASON =
30
44
  // `gh api /repos/x/y` path slip past an `-R`-derived check.
31
45
  type GhSegmentDecision =
32
46
  | { kind: 'pass-through' }
33
- | { kind: 'block'; reason: string }
47
+ | { kind: 'block'; code: GhBlockCode; reason: string }
34
48
  // `stripRepoFlag` marks a graphql inject whose `-R/--repo` is a TypeClaw-only
35
49
  // hint that `gh api` would reject, so it must be removed from the command.
36
50
  | { kind: 'inject'; repoSlugs: readonly string[]; stripRepoFlag?: boolean }
37
51
 
38
52
  const COMPOSITION_REASON =
53
+ 'Allowed shape: a single bare `gh <cmd> -R owner/repo` (or `gh api /repos/owner/repo/...`) ' +
54
+ 'with a LITERAL repo slug, optionally followed by a trailing stdin-only reader pipeline ' +
55
+ 'such as `| jq ...`. ' +
39
56
  'A repo-targeting `gh` command receives a minted GitHub App token in its process ' +
40
57
  'environment, so it must run as a single bare `gh` command — no `;`, `&&`, `||`, `&`, ' +
41
58
  'newlines, redirections, command/process substitution, subshells, heredocs, or unquoted ' +
@@ -119,7 +136,16 @@ const REPO_LESS_SUBCOMMANDS = new Set([
119
136
  // installation). We therefore inspect EVERY `gh` invocation, not just the
120
137
  // first: a repo-targeting `gh` with no resolvable repo blocks (missing-repo),
121
138
  // and invocations spanning more than one owner block (multi-owner).
122
- export function analyzeGhCommand(command: string): GhCommandDecision {
139
+ // `fallbackRepo`, when supplied, is a TRUSTED literal `owner/repo` the CALLER
140
+ // resolved from a non-command source (GitHub session origin or the cwd git
141
+ // remote) and already allowlist-checked. It fills in for a repo-less non-`api`
142
+ // segment that would otherwise block `missing-repo`, so a bare `gh label list`
143
+ // can mint. It deliberately flows through the SAME multi-owner + single-bare
144
+ // composition gates below, so a compound command still blocks even with a
145
+ // fallback (the token would leak to siblings). It NEVER overrides an explicit
146
+ // `-R`/path repo, and is NOT applied to `non-literal-repo` (a `$var` the user
147
+ // wrote) or `gh api` (path is authoritative).
148
+ export function analyzeGhCommand(command: string, fallbackRepo?: string): GhCommandDecision {
123
149
  const tokens = tokenize(command)
124
150
  const ghStarts = findGhInvocations(tokens)
125
151
  if (ghStarts.length === 0) return { kind: 'pass-through' }
@@ -130,7 +156,7 @@ export function analyzeGhCommand(command: string): GhCommandDecision {
130
156
  const start = ghStarts[i] as number
131
157
  const end = ghStarts[i + 1] ?? tokens.length
132
158
  const args = tokens.slice(start + 1, end)
133
- const segment = classifyGhSegment(args)
159
+ const segment = classifyGhSegment(args, fallbackRepo)
134
160
  if (segment.kind === 'block') return segment
135
161
  if (segment.kind === 'inject') {
136
162
  repoSlugs.push(...segment.repoSlugs)
@@ -140,7 +166,7 @@ export function analyzeGhCommand(command: string): GhCommandDecision {
140
166
 
141
167
  if (repoSlugs.length === 0) return { kind: 'pass-through' }
142
168
  const owners = new Set(repoSlugs.map((slug) => slug.split('/')[0]))
143
- if (owners.size > 1) return { kind: 'block', reason: MULTI_OWNER_REASON }
169
+ if (owners.size > 1) return { kind: 'block', code: 'multi-owner', reason: MULTI_OWNER_REASON }
144
170
 
145
171
  const repoSlug = repoSlugs[0] as string
146
172
 
@@ -156,7 +182,7 @@ export function analyzeGhCommand(command: string): GhCommandDecision {
156
182
  const piped = analyzeReaderPipeline(command, stripRepoFlag)
157
183
  if (piped !== null) return { kind: 'inject', repoSlug, rewrittenCommand: piped }
158
184
 
159
- return { kind: 'block', reason: COMPOSITION_REASON }
185
+ return { kind: 'block', code: 'composition', reason: COMPOSITION_REASON }
160
186
  }
161
187
 
162
188
  // stdin-only readers whose only sink is stdout (back to the agent, who already
@@ -487,7 +513,7 @@ function matchRepoFlagAt(command: string, start: number): number | null {
487
513
  return null
488
514
  }
489
515
 
490
- function classifyGhSegment(args: readonly string[]): GhSegmentDecision {
516
+ function classifyGhSegment(args: readonly string[], fallbackRepo?: string): GhSegmentDecision {
491
517
  const subcommand = args.find((t) => !t.startsWith('-'))
492
518
  if (subcommand === undefined) return { kind: 'pass-through' }
493
519
 
@@ -500,9 +526,21 @@ function classifyGhSegment(args: readonly string[]): GhSegmentDecision {
500
526
  const explicit = extractRepoFlag(args)
501
527
  if (explicit !== null) return { kind: 'inject', repoSlugs: [explicit] }
502
528
 
529
+ // A `-R`/`--repo` IS present but its value isn't a literal slug (e.g. `-R "$repo"`):
530
+ // tell the user that, not the misleading "add -R" message — they already did.
531
+ // A trusted fallback never papers over a value the user explicitly mistyped.
532
+ if (repoFlagHasNonLiteralValue(args))
533
+ return { kind: 'block', code: 'non-literal-repo', reason: NON_LITERAL_REPO_REASON }
534
+
503
535
  if (REPO_LESS_SUBCOMMANDS.has(subcommand)) return { kind: 'pass-through' }
504
536
 
505
- return { kind: 'block', reason: MISSING_REPO_REASON }
537
+ // Repo-less repo-scoped subcommand. A caller-supplied trusted fallback repo
538
+ // (origin/cwd, already allowlisted) fills it so the command can mint; absent
539
+ // one, block missing-repo. The fallback still passes through the composition
540
+ // gate in analyzeGhCommand, so a compound command blocks regardless.
541
+ if (fallbackRepo !== undefined && isRepoSlug(fallbackRepo)) return { kind: 'inject', repoSlugs: [fallbackRepo] }
542
+
543
+ return { kind: 'block', code: 'missing-repo', reason: MISSING_REPO_REASON }
506
544
  }
507
545
 
508
546
  // Repo authority for `gh api`: the literal endpoint path wins. A `-R/--repo`
@@ -515,6 +553,18 @@ function classifyGhApiSegment(args: readonly string[]): GhSegmentDecision {
515
553
  const pathRepos = extractReposFromApiPath(args)
516
554
  const flagRepo = extractRepoFlag(args)
517
555
 
556
+ // A non-literal `-R/--repo` (e.g. `-R '$repo'`) blocks BEFORE any inject path,
557
+ // including the literal `/repos/owner/repo` path branch below. Without this, a
558
+ // single-quoted `gh api /repos/acme/widgets/... -R '$repo'` slips the composition
559
+ // gate (single quotes neutralize `$`) AND is dropped by extractAllRepoFlags
560
+ // (which keeps literal slugs only), so the path branch would mint for the PATH
561
+ // repo while the unverifiable flag named something else — the exact mint-for-X-
562
+ // hit-Y the conflict guard exists to stop. We never inject when an unreadable
563
+ // repo flag is present.
564
+ if (repoFlagHasNonLiteralValue(args)) {
565
+ return { kind: 'block', code: 'non-literal-repo', reason: NON_LITERAL_REPO_REASON }
566
+ }
567
+
518
568
  if (pathRepos.length > 0) {
519
569
  // Check EVERY repo flag, not just the first: the strip removes all of them,
520
570
  // so a single non-redundant flag anywhere is a mint-for-X-hit-Y attempt and
@@ -522,7 +572,7 @@ function classifyGhApiSegment(args: readonly string[]): GhSegmentDecision {
522
572
  // mask it.
523
573
  const flagRepos = extractAllRepoFlags(args)
524
574
  if (flagRepos.some((slug) => !pathRepos.includes(slug))) {
525
- return { kind: 'block', reason: API_REPO_CONFLICT_REASON }
575
+ return { kind: 'block', code: 'api-repo-conflict', reason: API_REPO_CONFLICT_REASON }
526
576
  }
527
577
  // Every `-R` here is redundant: it matches the repo already named in the
528
578
  // literal path, which is authoritative. `gh api` rejects `-R` outright, so
@@ -546,6 +596,13 @@ function classifyGhApiSegment(args: readonly string[]): GhSegmentDecision {
546
596
  return { kind: 'inject', repoSlugs: [flagRepo], stripRepoFlag: true }
547
597
  }
548
598
 
599
+ // No literal path repo and no usable literal `-R`: if a `-R`/`--repo` was given
600
+ // with a non-literal value (e.g. `gh api graphql -R "$repo"`), say so rather
601
+ // than silently passing through to an unauthenticated `gh api`.
602
+ if (flagRepo === null && repoFlagHasNonLiteralValue(args)) {
603
+ return { kind: 'block', code: 'non-literal-repo', reason: NON_LITERAL_REPO_REASON }
604
+ }
605
+
549
606
  return { kind: 'pass-through' }
550
607
  }
551
608
 
@@ -660,6 +717,27 @@ function extractRepoFlag(args: readonly string[]): string | null {
660
717
  return null
661
718
  }
662
719
 
720
+ // True when a `-R`/`--repo` flag is present but its value is not a literal slug
721
+ // `extractRepoFlag` would accept (missing, a shell variable, a placeholder, or a
722
+ // malformed slug). Lets the classifier emit NON_LITERAL_REPO_REASON instead of
723
+ // the misleading "add -R" message when the user DID pass `-R` but with `$repo`.
724
+ function repoFlagHasNonLiteralValue(args: readonly string[]): boolean {
725
+ for (let i = 0; i < args.length; i++) {
726
+ const arg = args[i]
727
+ if (arg === undefined) continue
728
+ if (arg === '-R' || arg === '--repo') {
729
+ const value = args[i + 1]
730
+ if (value === undefined || value.startsWith('-')) return true
731
+ if (!isRepoSlug(value)) return true
732
+ } else if (arg.startsWith('--repo=')) {
733
+ if (!isRepoSlug(arg.slice('--repo='.length))) return true
734
+ } else if (arg.startsWith('-R=')) {
735
+ if (!isRepoSlug(arg.slice('-R='.length))) return true
736
+ }
737
+ }
738
+ return false
739
+ }
740
+
663
741
  // Every valid `-R`/`--repo` slug in `args`, not just the first. The strip removes
664
742
  // ALL unquoted repo flags, so the conflict check must see ALL of them: a command
665
743
  // like `... -R path/repo -R victim/private` is a mint-for-X-hit-Y attempt where
@@ -772,7 +850,13 @@ function apiEndpointHasOwnerRepoPlaceholder(args: readonly string[]): boolean {
772
850
  return endpoint.includes('{owner}') || endpoint.includes('{repo}')
773
851
  }
774
852
 
853
+ // Security invariant: `extractRepoFlag` mints a token from this value, so only a
854
+ // CONCRETE static `owner/name` may pass. A value carrying `$`/`${}` expansion or
855
+ // `{owner}`/`{repo}` placeholders is rejected here so a `-R '$owner/$repo'`
856
+ // (single-quoted, so it slips the composition gate) can never be injected and
857
+ // mint for an unverifiable target; it surfaces as NON_LITERAL_REPO_REASON instead.
775
858
  function isRepoSlug(value: string): boolean {
859
+ if (value.includes('$') || value.includes('{') || value.includes('}')) return false
776
860
  const [owner, name, ...rest] = value.split('/')
777
861
  return owner !== undefined && owner !== '' && name !== undefined && name !== '' && rest.length === 0
778
862
  }
@@ -271,6 +271,17 @@ async function resolveDefaultPushRemote(cwd: string, resolvers: GitResolvers): P
271
271
  return 'origin'
272
272
  }
273
273
 
274
+ // The `gh`-side cwd default repo: the working tree's `origin` FETCH url only.
275
+ // Unlike `git push`, `gh <cmd>` with no `-R` has no push semantics, so we do NOT
276
+ // consult the branch.pushRemote/remote.pushDefault chain — origin is what `gh`
277
+ // itself would infer. Returns a literal `owner/repo` slug or null; the caller
278
+ // still gates it through the repos[] allowlist before minting.
279
+ export async function resolveGhDefaultRepoFromCwd(cwd: string, resolvers: GitResolvers): Promise<string | null> {
280
+ const url = await resolvers.resolveRemoteUrl(cwd, 'origin', false)
281
+ if (url === null) return null
282
+ return parseGithubRepoFromGitUrl(url)
283
+ }
284
+
274
285
  const HTTPS_GITHUB_RE = /^https:\/\/github\.com\/([^/\s:@]+)\/([^/\s?#]+?)(?:\.git)?\/?(?:[?#].*)?$/i
275
286
  const SCP_GITHUB_RE = /^git@github\.com:([^/\s:?#]+)\/([^/\s?#]+?)(?:\.git)?$/i
276
287
  const SSH_GITHUB_RE = /^ssh:\/\/git@github\.com(?::\d+)?\/([^/\s]+)\/([^/\s?#]+?)(?:\.git)?\/?(?:[?#].*)?$/i