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.
- package/package.json +1 -1
- package/src/agent/plugin-tools.ts +23 -1
- package/src/bundled-plugins/doc-render/index.ts +10 -0
- package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +171 -165
- package/src/bundled-plugins/doc-render/templates/lib.typ +339 -0
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +95 -11
- package/src/bundled-plugins/github-cli-auth/git-command.ts +11 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +68 -7
- package/src/channels/manager.ts +77 -1
- package/src/channels/router.ts +63 -2
- package/src/cli/compose.ts +11 -2
- package/src/cli/mount.ts +5 -5
- package/src/cli/restart.ts +2 -1
- package/src/cli/start.ts +2 -1
- package/src/cli/ui.ts +13 -0
- package/src/compose/restart.ts +1 -1
- package/src/compose/start.ts +4 -2
- package/src/config/config.ts +200 -9
- package/src/cron/consumer.ts +3 -3
- package/src/init/dockerfile.ts +11 -8
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|