typeclaw 0.32.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/verify-procbind-sandbox.sh +61 -0
- package/src/agent/multimodal/look-at.ts +7 -5
- package/src/agent/plugin-tools.ts +47 -12
- package/src/agent/session-origin.ts +15 -9
- package/src/agent/system-prompt.ts +6 -0
- package/src/agent/tools/channel-fetch-attachment.ts +8 -7
- package/src/agent/tools/channel-history.ts +2 -0
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +267 -13
- package/src/bundled-plugins/reviewer/skills/code-review.ts +11 -9
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +1 -0
- package/src/channels/adapters/slack-bot-reference.ts +9 -10
- package/src/channels/adapters/slack-bot.ts +29 -7
- package/src/channels/router.ts +89 -21
- package/src/cli/index.ts +42 -2
- package/src/cli/init.ts +267 -82
- package/src/cli/inspect.ts +5 -2
- package/src/cli/model.ts +5 -1
- package/src/cli/provider.ts +41 -10
- package/src/config/config.ts +23 -11
- package/src/config/providers.ts +304 -7
- package/src/container/start.ts +12 -7
- package/src/init/find-agent-dir.ts +44 -0
- package/src/init/index.ts +3 -34
- package/src/init/models-dev.ts +2 -0
- package/src/init/validate-api-key.ts +13 -0
- package/src/inspect/transcript-view.ts +33 -7
- package/src/sandbox/availability.ts +354 -2
- package/src/sandbox/build.ts +17 -7
- package/src/sandbox/index.ts +10 -1
- package/src/sandbox/policy.ts +27 -9
- package/src/secrets/oauth-xai.ts +342 -0
- package/src/secrets/storage.ts +2 -0
- package/src/skills/typeclaw-markdown-pdf/SKILL.md +64 -5
- package/typeclaw.schema.json +20 -2
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { createServer, type Server } from 'node:http'
|
|
2
|
+
|
|
3
|
+
import { registerOAuthProvider } from '@mariozechner/pi-ai/oauth'
|
|
4
|
+
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from '@mariozechner/pi-ai/oauth'
|
|
5
|
+
|
|
6
|
+
// xAI (Grok) OAuth 2.0. xAI runs a standard OIDC authorization server at
|
|
7
|
+
// auth.x.ai that supports BOTH authorization-code + PKCE (loopback callback)
|
|
8
|
+
// and the device-authorization grant. We implement the auth-code path with a
|
|
9
|
+
// localhost callback server (same UX as pi-ai's anthropic provider) plus a
|
|
10
|
+
// manual-paste fallback for cross-device/SSH flows.
|
|
11
|
+
//
|
|
12
|
+
// There is no public developer console to register a third-party OAuth client,
|
|
13
|
+
// so — like every OSS Grok integration (Grok CLI, opencode, hermes-agent,
|
|
14
|
+
// pi-xai-oauth) — we reuse the Grok CLI's public client id. The `plan=generic`
|
|
15
|
+
// query param is load-bearing: loopback OAuth against this client id is
|
|
16
|
+
// rejected without it. `referrer` is attribution only.
|
|
17
|
+
//
|
|
18
|
+
// Endpoints below are the live values from
|
|
19
|
+
// https://auth.x.ai/.well-known/openid-configuration. The token endpoint speaks
|
|
20
|
+
// application/x-www-form-urlencoded (OAuth2 default) — NOT JSON like Anthropic.
|
|
21
|
+
export const XAI_OAUTH_PROVIDER_ID = 'xai'
|
|
22
|
+
|
|
23
|
+
const CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828'
|
|
24
|
+
const AUTHORIZE_URL = 'https://auth.x.ai/oauth2/authorize'
|
|
25
|
+
const TOKEN_URL = 'https://auth.x.ai/oauth2/token'
|
|
26
|
+
const CALLBACK_HOST = '127.0.0.1'
|
|
27
|
+
const CALLBACK_PORT = 56121
|
|
28
|
+
const CALLBACK_PATH = '/callback'
|
|
29
|
+
const REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`
|
|
30
|
+
const SCOPES = 'openid profile email offline_access grok-cli:access api:access'
|
|
31
|
+
const REFERRER = 'typeclaw'
|
|
32
|
+
// Refresh slightly early so an in-flight request never races expiry.
|
|
33
|
+
const EXPIRY_SKEW_MS = 5 * 60 * 1000
|
|
34
|
+
const REQUEST_TIMEOUT_MS = 30_000
|
|
35
|
+
|
|
36
|
+
type XaiTokenResponse = {
|
|
37
|
+
access_token?: string
|
|
38
|
+
refresh_token?: string
|
|
39
|
+
expires_in?: number
|
|
40
|
+
token_type?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function base64UrlEncode(bytes: Uint8Array): string {
|
|
44
|
+
let binary = ''
|
|
45
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
46
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// PKCE (RFC 7636, S256). pi-ai keeps its `generatePKCE` helper out of the
|
|
50
|
+
// public `/oauth` barrel, so we generate the verifier/challenge with Web Crypto
|
|
51
|
+
// directly — available in both Node and Bun.
|
|
52
|
+
async function generatePkce(): Promise<{ verifier: string; challenge: string }> {
|
|
53
|
+
const verifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)))
|
|
54
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
|
55
|
+
return { verifier, challenge: base64UrlEncode(new Uint8Array(digest)) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
|
|
59
|
+
const value = input.trim()
|
|
60
|
+
if (!value) return {}
|
|
61
|
+
try {
|
|
62
|
+
const url = new URL(value)
|
|
63
|
+
return {
|
|
64
|
+
code: url.searchParams.get('code') ?? undefined,
|
|
65
|
+
state: url.searchParams.get('state') ?? undefined,
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Not a full URL — fall through to query-string / bare-code handling.
|
|
69
|
+
}
|
|
70
|
+
if (value.includes('code=')) {
|
|
71
|
+
const params = new URLSearchParams(value)
|
|
72
|
+
return {
|
|
73
|
+
code: params.get('code') ?? undefined,
|
|
74
|
+
state: params.get('state') ?? undefined,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { code: value }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type CallbackServer = {
|
|
81
|
+
server: Server
|
|
82
|
+
cancelWait: () => void
|
|
83
|
+
waitForCode: () => Promise<{ code: string; state: string | null } | null>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function successHtml(): string {
|
|
87
|
+
return '<!doctype html><html><body style="font-family:sans-serif;padding:2rem"><h2>xAI authentication complete.</h2><p>You can close this window and return to the terminal.</p></body></html>'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The OAuth `error` query param is provider-supplied and reflected into the
|
|
91
|
+
// callback page, so escape it to keep a crafted callback URL
|
|
92
|
+
// (`?error=<script>…`) from injecting markup into the local page.
|
|
93
|
+
function escapeHtml(value: string): string {
|
|
94
|
+
return value
|
|
95
|
+
.replaceAll('&', '&')
|
|
96
|
+
.replaceAll('<', '<')
|
|
97
|
+
.replaceAll('>', '>')
|
|
98
|
+
.replaceAll('"', '"')
|
|
99
|
+
.replaceAll("'", ''')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function errorHtml(message: string): string {
|
|
103
|
+
return `<!doctype html><html><body style="font-family:sans-serif;padding:2rem"><h2>xAI authentication failed.</h2><p>${escapeHtml(message)}</p></body></html>`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Resolves to `null` when the fixed loopback port can't be bound (EADDRINUSE,
|
|
107
|
+
// sandbox bind restriction). The caller then falls back to manual-paste mode
|
|
108
|
+
// rather than failing the whole login — the browser callback is a convenience,
|
|
109
|
+
// not a hard requirement, since the user can always paste the redirect URL.
|
|
110
|
+
function startCallbackServer(expectedState: string): Promise<CallbackServer | null> {
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
let settle: ((value: { code: string; state: string | null } | null) => void) | undefined
|
|
113
|
+
const waitForCodePromise = new Promise<{ code: string; state: string | null } | null>((resolveWait) => {
|
|
114
|
+
let settled = false
|
|
115
|
+
settle = (value) => {
|
|
116
|
+
if (settled) return
|
|
117
|
+
settled = true
|
|
118
|
+
resolveWait(value)
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const server = createServer((req, res) => {
|
|
123
|
+
try {
|
|
124
|
+
const url = new URL(req.url || '', `http://${CALLBACK_HOST}`)
|
|
125
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
126
|
+
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
127
|
+
res.end(errorHtml('Callback route not found.'))
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
const code = url.searchParams.get('code')
|
|
131
|
+
const state = url.searchParams.get('state')
|
|
132
|
+
const error = url.searchParams.get('error')
|
|
133
|
+
if (error) {
|
|
134
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
135
|
+
res.end(errorHtml(`Error: ${error}`))
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
if (!code) {
|
|
139
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
140
|
+
res.end(errorHtml('Missing code parameter.'))
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
if (state !== expectedState) {
|
|
144
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
145
|
+
res.end(errorHtml('State mismatch.'))
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
149
|
+
res.end(successHtml())
|
|
150
|
+
settle?.({ code, state })
|
|
151
|
+
} catch {
|
|
152
|
+
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
153
|
+
res.end('Internal error')
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
server.on('error', () => {
|
|
158
|
+
server.close()
|
|
159
|
+
resolve(null)
|
|
160
|
+
})
|
|
161
|
+
server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
|
|
162
|
+
resolve({
|
|
163
|
+
server,
|
|
164
|
+
cancelWait: () => settle?.(null),
|
|
165
|
+
waitForCode: () => waitForCodePromise,
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type FetchFn = (input: string, init: RequestInit) => Promise<Response>
|
|
172
|
+
|
|
173
|
+
async function postForm(url: string, body: Record<string, string>, fetchImpl: FetchFn): Promise<XaiTokenResponse> {
|
|
174
|
+
const response = await fetchImpl(url, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: {
|
|
177
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
178
|
+
Accept: 'application/json',
|
|
179
|
+
},
|
|
180
|
+
body: new URLSearchParams(body).toString(),
|
|
181
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
182
|
+
})
|
|
183
|
+
const text = await response.text()
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
throw new Error(`xAI OAuth request failed. status=${response.status}; url=${url}; body=${text}`)
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
return JSON.parse(text) as XaiTokenResponse
|
|
189
|
+
} catch {
|
|
190
|
+
throw new Error(`xAI OAuth returned invalid JSON. url=${url}; body=${text}`)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function toCredentials(token: XaiTokenResponse): OAuthCredentials {
|
|
195
|
+
if (!token.access_token || !token.refresh_token || token.expires_in === undefined) {
|
|
196
|
+
throw new Error('xAI OAuth response missing access_token, refresh_token, or expires_in')
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
access: token.access_token,
|
|
200
|
+
refresh: token.refresh_token,
|
|
201
|
+
expires: Date.now() + token.expires_in * 1000 - EXPIRY_SKEW_MS,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function exchangeAuthorizationCode(
|
|
206
|
+
code: string,
|
|
207
|
+
verifier: string,
|
|
208
|
+
fetchImpl: FetchFn,
|
|
209
|
+
): Promise<OAuthCredentials> {
|
|
210
|
+
const token = await postForm(
|
|
211
|
+
TOKEN_URL,
|
|
212
|
+
{
|
|
213
|
+
grant_type: 'authorization_code',
|
|
214
|
+
client_id: CLIENT_ID,
|
|
215
|
+
code,
|
|
216
|
+
redirect_uri: REDIRECT_URI,
|
|
217
|
+
code_verifier: verifier,
|
|
218
|
+
},
|
|
219
|
+
fetchImpl,
|
|
220
|
+
)
|
|
221
|
+
return toCredentials(token)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function loginXai(callbacks: OAuthLoginCallbacks, fetchImpl: FetchFn = fetch): Promise<OAuthCredentials> {
|
|
225
|
+
const { verifier, challenge } = await generatePkce()
|
|
226
|
+
const server = await startCallbackServer(verifier)
|
|
227
|
+
let code: string | undefined
|
|
228
|
+
try {
|
|
229
|
+
const authParams = new URLSearchParams({
|
|
230
|
+
client_id: CLIENT_ID,
|
|
231
|
+
response_type: 'code',
|
|
232
|
+
redirect_uri: REDIRECT_URI,
|
|
233
|
+
scope: SCOPES,
|
|
234
|
+
code_challenge: challenge,
|
|
235
|
+
code_challenge_method: 'S256',
|
|
236
|
+
state: verifier,
|
|
237
|
+
plan: 'generic',
|
|
238
|
+
referrer: REFERRER,
|
|
239
|
+
})
|
|
240
|
+
callbacks.onAuth({
|
|
241
|
+
url: `${AUTHORIZE_URL}?${authParams.toString()}`,
|
|
242
|
+
instructions:
|
|
243
|
+
'Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.',
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
if (server && callbacks.onManualCodeInput) {
|
|
247
|
+
// Race the local callback server against a manual paste: whichever lands
|
|
248
|
+
// a code first wins (cross-device/SSH logins can't reach the loopback).
|
|
249
|
+
let manualInput: string | undefined
|
|
250
|
+
let manualError: Error | undefined
|
|
251
|
+
const manualPromise = callbacks
|
|
252
|
+
.onManualCodeInput()
|
|
253
|
+
.then((input) => {
|
|
254
|
+
manualInput = input
|
|
255
|
+
server.cancelWait()
|
|
256
|
+
})
|
|
257
|
+
.catch((err) => {
|
|
258
|
+
manualError = err instanceof Error ? err : new Error(String(err))
|
|
259
|
+
server.cancelWait()
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
const result = await server.waitForCode()
|
|
263
|
+
if (manualError) throw manualError
|
|
264
|
+
if (result?.code) {
|
|
265
|
+
code = result.code
|
|
266
|
+
} else if (manualInput) {
|
|
267
|
+
code = parseManualCode(manualInput, verifier)
|
|
268
|
+
}
|
|
269
|
+
if (!code) {
|
|
270
|
+
await manualPromise
|
|
271
|
+
if (manualError) throw manualError
|
|
272
|
+
if (manualInput) {
|
|
273
|
+
code = parseManualCode(manualInput, verifier)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
} else if (server) {
|
|
277
|
+
const result = await server.waitForCode()
|
|
278
|
+
if (result?.code) code = result.code
|
|
279
|
+
} else if (callbacks.onManualCodeInput) {
|
|
280
|
+
// No callback server bound — manual paste is the only path to a code.
|
|
281
|
+
code = parseManualCode(await callbacks.onManualCodeInput(), verifier)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!code) {
|
|
285
|
+
const input = await callbacks.onPrompt({
|
|
286
|
+
message: 'Paste the authorization code or full redirect URL:',
|
|
287
|
+
placeholder: REDIRECT_URI,
|
|
288
|
+
})
|
|
289
|
+
code = parseManualCode(input, verifier)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!code) throw new Error('Missing authorization code')
|
|
293
|
+
|
|
294
|
+
callbacks.onProgress?.('Exchanging authorization code for tokens...')
|
|
295
|
+
return await exchangeAuthorizationCode(code, verifier, fetchImpl)
|
|
296
|
+
} finally {
|
|
297
|
+
server?.server.close()
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function parseManualCode(input: string, verifier: string): string | undefined {
|
|
302
|
+
const parsed = parseAuthorizationInput(input)
|
|
303
|
+
if (parsed.state && parsed.state !== verifier) throw new Error('OAuth state mismatch')
|
|
304
|
+
return parsed.code
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function refreshXaiToken(refreshToken: string, fetchImpl: FetchFn = fetch): Promise<OAuthCredentials> {
|
|
308
|
+
const token = await postForm(
|
|
309
|
+
TOKEN_URL,
|
|
310
|
+
{
|
|
311
|
+
grant_type: 'refresh_token',
|
|
312
|
+
client_id: CLIENT_ID,
|
|
313
|
+
refresh_token: refreshToken,
|
|
314
|
+
},
|
|
315
|
+
fetchImpl,
|
|
316
|
+
)
|
|
317
|
+
// Some OAuth servers omit a rotated refresh token on refresh; keep the prior
|
|
318
|
+
// one so the credential stays usable across the next cycle.
|
|
319
|
+
return toCredentials({ ...token, refresh_token: token.refresh_token ?? refreshToken })
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export const xaiOAuthProvider: OAuthProviderInterface = {
|
|
323
|
+
id: XAI_OAUTH_PROVIDER_ID,
|
|
324
|
+
name: 'xAI (Grok)',
|
|
325
|
+
usesCallbackServer: true,
|
|
326
|
+
login: loginXai,
|
|
327
|
+
refreshToken: (credentials) => refreshXaiToken(credentials.refresh),
|
|
328
|
+
getApiKey: (credentials) => credentials.access,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let registered = false
|
|
332
|
+
|
|
333
|
+
// pi-ai ships no built-in xAI OAuth provider, so we register ours. Idempotent
|
|
334
|
+
// and called from `createSecretsStoreForAgent` — the single chokepoint both the
|
|
335
|
+
// init-time login path and the container-runtime auth/refresh path go through —
|
|
336
|
+
// so the provider is always present before `AuthStorage.login()` /
|
|
337
|
+
// `getApiKey()` look it up via `getOAuthProvider('xai')`.
|
|
338
|
+
export function registerXaiOAuthProvider(): void {
|
|
339
|
+
if (registered) return
|
|
340
|
+
registerOAuthProvider(xaiOAuthProvider)
|
|
341
|
+
registered = true
|
|
342
|
+
}
|
package/src/secrets/storage.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import lockfile from 'proper-lockfile'
|
|
10
10
|
|
|
11
11
|
import { providerKeyDefaultEnv } from './defaults'
|
|
12
|
+
import { registerXaiOAuthProvider } from './oauth-xai'
|
|
12
13
|
import { resolveSecret, type Secret } from './resolve'
|
|
13
14
|
import {
|
|
14
15
|
type Channels,
|
|
@@ -374,6 +375,7 @@ export class SecretsBackend implements AuthStorageBackend {
|
|
|
374
375
|
}
|
|
375
376
|
|
|
376
377
|
export function createSecretsStoreForAgent(secretsPath: string): AuthStorage {
|
|
378
|
+
registerXaiOAuthProvider()
|
|
377
379
|
return AuthStorageImpl.fromStorage(new SecretsBackend(secretsPath))
|
|
378
380
|
}
|
|
379
381
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: typeclaw-markdown-pdf
|
|
3
|
-
description: "
|
|
3
|
+
description: "The ONLY supported way to turn Markdown into a polished, professional PDF (and optionally attach it to a channel). Load this whenever you need to deliver a document as a PDF rather than raw markdown — reports, summaries, briefs, meeting notes, docs, render report, export document, anything a human would want to download, print, or forward, including a researcher's report file shipped as a Slack/Discord attachment. Triggers: 'make a PDF', 'export to PDF', 'markdown to PDF', 'PDF report', 'render report', 'export document', 'the report', 'attach the report', 'send me a PDF', 'as a PDF', 'turn this into a document', a researcher/subagent result you want to ship as a file, 'PDF로', 'PDF로 만들어', 'PDF로 변환', 'PDF 첨부', '리포트', '보고서'. Handles CJK/Korean/Japanese/Chinese: CJK fonts are opt-in, so before rendering it checks whether a CJK font is present and, if not, asks the user to enable `docker.file.cjkFonts` and regenerate rather than shipping a tofu PDF — it never auto-downloads a font. Also load before saying you cannot produce PDFs — you can: this skill installs a tiny Typst toolchain into workspace/ on first use, then renders. NEVER build a PDF with jsPDF, pdfkit, a canvas text dump, or a headless-browser raw-text print — those produce unrendered markdown and broken CJK; this skill is the only correct path. Covers the one-time setup, the styled wrapper, the render command, and how to attach the PDF to Slack/Discord/Telegram/KakaoTalk. For operating on EXISTING PDFs (merge, split, extract text, fill forms), this is not the skill — use pypdf/qpdf instead."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# typeclaw-markdown-pdf
|
|
@@ -22,6 +22,15 @@ You do **not** need to learn Typst markup. `cmarker` renders your CommonMark
|
|
|
22
22
|
wrapper only sets _styling_ — fonts, margins, headings, page numbers — so the
|
|
23
23
|
output looks deliberate, not like a default-template export.
|
|
24
24
|
|
|
25
|
+
> **This is the only supported way to make a PDF from Markdown in TypeClaw.**
|
|
26
|
+
> Do **not** reach for `jsPDF`, `pdfkit`, a `<canvas>` text dump, or a
|
|
27
|
+
> headless-browser "print raw text" path. Those skip Markdown rendering (you get
|
|
28
|
+
> literal `##` and `**` in the output) and ship no CJK font, so Korean/Japanese/
|
|
29
|
+
> Chinese come out as mojibake. The Typst path below renders the Markdown properly;
|
|
30
|
+
> for CJK it relies on the opt-in `cjkFonts` font and gates on its presence (see
|
|
31
|
+
> "## Handling CJK content") rather than shipping tofu. If you catch yourself about
|
|
32
|
+
> to `bun add` a PDF library, stop and use this skill instead.
|
|
33
|
+
|
|
25
34
|
## When to use this
|
|
26
35
|
|
|
27
36
|
- A research report, brief, or summary the user wants as a downloadable file.
|
|
@@ -141,10 +150,60 @@ Notes:
|
|
|
141
150
|
wherever the Latin fonts have no glyph, leaving Latin runs untouched. It comes
|
|
142
151
|
from `fonts-noto-cjk`, which Step 3's renderer loads from `/usr/share/fonts` via
|
|
143
152
|
`fontPaths`. **The package is only present when the container's `cjkFonts` toggle
|
|
144
|
-
resolves to `true
|
|
145
|
-
|
|
146
|
-
render
|
|
147
|
-
|
|
153
|
+
resolves to `true`** (default `"auto"` installs it only on a CJK host locale), so
|
|
154
|
+
on a non-CJK host CJK text renders as tofu — see "## Handling CJK content" below
|
|
155
|
+
for the pre-render check that catches this and asks the user before shipping a
|
|
156
|
+
broken PDF. If your CJK font lives elsewhere, add its dir to the `fontPaths` list.
|
|
157
|
+
|
|
158
|
+
## Handling CJK content
|
|
159
|
+
|
|
160
|
+
CJK fonts are **opt-in** (the `docker.file.cjkFonts` toggle). When they are off,
|
|
161
|
+
Typst still renders — it just substitutes `.notdef` tofu boxes for every
|
|
162
|
+
Korean/Japanese/Chinese glyph, so the render "succeeds" and you can ship a broken
|
|
163
|
+
PDF without noticing. **Do not** download, vendor, or `curl` a font into the
|
|
164
|
+
workspace to work around this, and **do not** silently deliver a tofu PDF. Instead,
|
|
165
|
+
run this gate **before** Step 3 whenever the markdown might contain CJK:
|
|
166
|
+
|
|
167
|
+
```sh
|
|
168
|
+
# Run from workspace/. MD is the markdown you are about to render.
|
|
169
|
+
MD="report.md"
|
|
170
|
+
|
|
171
|
+
# Hangul, Kana, CJK ideographs + the common extensions. grep -P on Debian; perl
|
|
172
|
+
# slurp as the fallback (BusyBox/macOS grep lack -P).
|
|
173
|
+
CJK_RE='[\x{1100}-\x{11FF}\x{3130}-\x{318F}\x{AC00}-\x{D7A3}\x{3040}-\x{30FF}\x{31F0}-\x{31FF}\x{3400}-\x{4DBF}\x{4E00}-\x{9FFF}\x{F900}-\x{FAFF}\x{20000}-\x{2A6DF}\x{2A700}-\x{2B73F}\x{2B740}-\x{2B81F}\x{2B820}-\x{2CEAF}\x{2CEB0}-\x{2EBEF}\x{30000}-\x{3134F}]'
|
|
174
|
+
if command -v grep >/dev/null && echo | grep -qP '' 2>/dev/null; then
|
|
175
|
+
LC_ALL=C.UTF-8 grep -qP "$CJK_RE" -- "$MD" && HAS_CJK=1 || HAS_CJK=0
|
|
176
|
+
else
|
|
177
|
+
perl -CSDA -0777 -ne "exit(/$CJK_RE/ ? 0 : 1)" "$MD" && HAS_CJK=1 || HAS_CJK=0
|
|
178
|
+
fi
|
|
179
|
+
|
|
180
|
+
# A CJK font Typst can load. dpkg is the authoritative signal for the opt-in
|
|
181
|
+
# fonts-noto-cjk package; the file scan covers a preinstalled or mounted font.
|
|
182
|
+
# fontconfig/fc-list is NOT consulted — Typst reads fontPaths directly, not fc.
|
|
183
|
+
has_cjk_font() {
|
|
184
|
+
dpkg-query -W -f='${Status}' fonts-noto-cjk 2>/dev/null | grep -q 'install ok installed' && return 0
|
|
185
|
+
find /usr/share/fonts /usr/local/share/fonts -type f \( -iname '*.otf' -o -iname '*.ttf' -o -iname '*.ttc' \) 2>/dev/null |
|
|
186
|
+
grep -Eiq '(Noto(Sans|Serif)CJK|Noto (Sans|Serif) CJK|SourceHan|Source Han|WenQuanYi|Nanum|Unifont|DroidSansFallback|AR PL)'
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if [ "$HAS_CJK" = 1 ] && ! has_cjk_font; then
|
|
190
|
+
echo "CJK_FONT_MISSING"
|
|
191
|
+
fi
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
If the gate prints `CJK_FONT_MISSING`, **stop — do not render or attach a PDF.**
|
|
195
|
+
Tell the user, honestly, that this is a restart-required boot setting, e.g.:
|
|
196
|
+
|
|
197
|
+
> This report has Korean/Japanese/Chinese text, but the container has no CJK font
|
|
198
|
+
> — they're opt-in, so the PDF would come out as tofu boxes. Want me to set
|
|
199
|
+
> `docker.file.cjkFonts: true` in `typeclaw.json`? It's a boot setting, so after I
|
|
200
|
+
> edit it you'll need to run `typeclaw restart` from the host project directory,
|
|
201
|
+
> and then I'll regenerate the PDF.
|
|
202
|
+
|
|
203
|
+
Only after the user agrees: edit `typeclaw.json` to set `docker.file.cjkFonts:
|
|
204
|
+
true` (use the `typeclaw-config` skill), ask them to `typeclaw restart`, and
|
|
205
|
+
regenerate the PDF **after** the restarted container reports `has_cjk_font` true.
|
|
206
|
+
If the markdown has no CJK, or a CJK font is present, skip straight to Step 3.
|
|
148
207
|
|
|
149
208
|
## Step 3 — render
|
|
150
209
|
|
package/typeclaw.schema.json
CHANGED
|
@@ -41,7 +41,16 @@
|
|
|
41
41
|
"zai-coding/glm-4.7",
|
|
42
42
|
"zai-coding/glm-5",
|
|
43
43
|
"zai-coding/glm-5-turbo",
|
|
44
|
-
"zai-coding/glm-5.1"
|
|
44
|
+
"zai-coding/glm-5.1",
|
|
45
|
+
"xai/grok-4.3",
|
|
46
|
+
"xai/grok-4.20-0309-reasoning",
|
|
47
|
+
"xai/grok-4.20-0309-non-reasoning",
|
|
48
|
+
"xai/grok-build-0.1",
|
|
49
|
+
"minimax/MiniMax-M3",
|
|
50
|
+
"minimax/MiniMax-M2.7",
|
|
51
|
+
"minimax/MiniMax-M2.5",
|
|
52
|
+
"minimax/MiniMax-M2.1",
|
|
53
|
+
"minimax/MiniMax-M2"
|
|
45
54
|
]
|
|
46
55
|
},
|
|
47
56
|
{
|
|
@@ -69,7 +78,16 @@
|
|
|
69
78
|
"zai-coding/glm-4.7",
|
|
70
79
|
"zai-coding/glm-5",
|
|
71
80
|
"zai-coding/glm-5-turbo",
|
|
72
|
-
"zai-coding/glm-5.1"
|
|
81
|
+
"zai-coding/glm-5.1",
|
|
82
|
+
"xai/grok-4.3",
|
|
83
|
+
"xai/grok-4.20-0309-reasoning",
|
|
84
|
+
"xai/grok-4.20-0309-non-reasoning",
|
|
85
|
+
"xai/grok-build-0.1",
|
|
86
|
+
"minimax/MiniMax-M3",
|
|
87
|
+
"minimax/MiniMax-M2.7",
|
|
88
|
+
"minimax/MiniMax-M2.5",
|
|
89
|
+
"minimax/MiniMax-M2.1",
|
|
90
|
+
"minimax/MiniMax-M2"
|
|
73
91
|
]
|
|
74
92
|
}
|
|
75
93
|
}
|