chalk-talk 0.12.0 → 0.14.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.
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: <the-skill>
3
+ description: <What it makes, in one sentence> — <what comes out: one file that opens offline, a workbook…>. Use when someone asks to <the requests, in the words people use, in English and Portuguese> ("…", "…"), or <the situation it fits>.
4
+ license: MIT
5
+ compatibility: Best with code execution and Node.js 18 or later (Chrome, Chromium or Edge, when installed, lets the script check the result first).
6
+ metadata:
7
+ version: "0.1.0"
8
+ ---
9
+
10
+ <!-- The shape the skills of this kit share (chalk-talk, stage-door, dress-rehearsal, front-of-house). A reference for
11
+ a new skill, not a file the skills import: copy it, then replace every <…>. Keep SKILL.md under 500 lines; what the
12
+ model only needs sometimes goes in references/. -->
13
+
14
+ # <the-skill>: <what it does, in a few words>
15
+
16
+ You write only the **source** (`<id>.<ext>`, a short text a person can read and change); `scripts/build.mjs` checks
17
+ it and makes everything else. Never write what the script makes. Paths are relative to this skill's folder.
18
+
19
+ ## 1. Make it first
20
+
21
+ For an ordinary request **do not ask and do not show a plan: make it.** Decide what is missing yourself — the
22
+ reasonable bet, never an invented fact — and say what you chose when you deliver.
23
+
24
+ Ask first — once, all together, only what is missing — when <the cases where a wrong guess is costly: for a class, a
25
+ real decision, data only the person has>, when the request could mean two things, or when the person asks to see the
26
+ plan.
27
+
28
+ ## 2. Write the source
29
+
30
+ <The cheat sheet: the lines of the usual case, each a line that builds (the kit's checks build every example). The
31
+ whole format in references/<format>.md, opened only when a line is not in the sheet or a message does not settle a
32
+ mistake.>
33
+
34
+ ## 3. Build and check
35
+
36
+ ```
37
+ node scripts/build.mjs <id>.<ext>
38
+ ```
39
+
40
+ - `✗ <file>:<line>: …` (exit 1, nothing written): fix that line the way the message says.
41
+ - `! …` (exit 3): made, but the check found something to fix — fix it and build again.
42
+ - `✓ …`: done. `✗ … not checked`: done, but tell the person it was not checked.
43
+
44
+ At most **two** rounds of fixes; then deliver and say in plain words what is left.
45
+
46
+ ## 4. Deliver
47
+
48
+ The path of what was made (or the file itself where the person downloads files), in one line **what you chose and an
49
+ offer to change it**, and one sentence on what to look at first.
50
+
51
+ ## Never
52
+
53
+ - Never edit `assets/` or the generated `scripts/`.
54
+ - Never put HTML, links or scripts in the source.
55
+ - Never invent a fact, a number or a quote to fill a gap: ask, or leave it visibly open.
@@ -0,0 +1,181 @@
1
+ // A Chrome, Chromium or Edge without a window: opens a built page in one of its modes (#review, #snapshots, #svg…) and
2
+ // reads back what the page wrote in <pre id="chalk-<mode>">. It talks to the browser over the DevTools protocol on a
3
+ // pipe (--remote-debugging-pipe) and waits for that <pre> itself: --dump-dom hangs on some machines, the pipe does not.
4
+ // The browsers are tried in order; one that does not start or does not answer in time gives way to the next.
5
+ // Part of the skills kit (chalk-talk/kit): copied into each skill by its tools/assets.mjs. Node only, no dependency.
6
+ import { execFileSync, spawn } from 'node:child_process'
7
+ import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'
8
+ import { createRequire } from 'node:module'
9
+ import { homedir, tmpdir } from 'node:os'
10
+ import { join } from 'node:path'
11
+ import { pathToFileURL } from 'node:url'
12
+
13
+ /** Playwright's own Chromium: the package's answer when it is installed here, else what it downloaded. */
14
+ function playwright() {
15
+ const found = []
16
+ const req = createRequire(join(process.cwd(), 'noop.js'))
17
+ for (const name of ['playwright', 'playwright-core']) {
18
+ try {
19
+ const p = req(name).chromium.executablePath()
20
+ if (p) found.push(p)
21
+ } catch {}
22
+ }
23
+ const caches = [process.env.PLAYWRIGHT_BROWSERS_PATH, join(homedir(), '.cache/ms-playwright'), join(homedir(), 'Library/Caches/ms-playwright'), '/opt/pw-browsers'].filter(Boolean)
24
+ const inside = ['chrome-linux64/chrome', 'chrome-linux/chrome', 'chrome-linux-arm64/chrome', 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing', 'chrome-mac/Chromium.app/Contents/MacOS/Chromium']
25
+ for (const cache of caches) {
26
+ let dirs = []
27
+ try {
28
+ dirs = readdirSync(cache).filter((d) => /^chromium-\d+$/.test(d)).sort().reverse()
29
+ } catch {}
30
+ for (const d of dirs) for (const p of inside) found.push(join(cache, d, p))
31
+ }
32
+ return found
33
+ }
34
+
35
+ /** Every browser worth trying, in order: CHROME_PATH, PUPPETEER_EXECUTABLE_PATH, Playwright's Chromium, the usual places
36
+ * on macOS and Windows, then `which` on Linux. Only paths that exist, each once. */
37
+ export function candidates() {
38
+ const paths = [process.env.CHROME_PATH, process.env.PUPPETEER_EXECUTABLE_PATH, ...playwright(),
39
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
40
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
41
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
42
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
43
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe']
44
+ if (process.platform !== 'win32') {
45
+ for (const name of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge']) {
46
+ try {
47
+ paths.push(execFileSync('which', [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim())
48
+ } catch {}
49
+ }
50
+ }
51
+ return [...new Set(paths.filter((p) => p && existsSync(p)))]
52
+ }
53
+
54
+ /** The first browser there is, or null. */
55
+ export function browser() {
56
+ return candidates()[0] ?? null
57
+ }
58
+
59
+ /** What the browser needs besides the usual: on Linux no sandbox as root (Chromium refuses to start otherwise) and no
60
+ * /dev/shm (small in containers). */
61
+ export function launchFlags(platform = process.platform, uid = process.getuid?.()) {
62
+ const flags = ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--allow-file-access-from-files',
63
+ '--disable-background-networking', '--disable-component-update', '--disable-sync', '--remote-debugging-pipe']
64
+ if (platform === 'linux') flags.push('--disable-dev-shm-usage')
65
+ if (platform === 'linux' && uid === 0) flags.push('--no-sandbox')
66
+ return flags
67
+ }
68
+
69
+ class BrowserFailed extends Error {}
70
+
71
+ // A wait that never keeps Node running by itself.
72
+ const pause = (ms) => new Promise((ok) => setTimeout(ok, ms).unref())
73
+
74
+ /** One browser, started with a profile of its own, spoken to over the pipe. */
75
+ async function start(exe) {
76
+ const profile = mkdtempSync(join(tmpdir(), 'chalk-talk-'))
77
+ const child = spawn(exe, [...launchFlags(), `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] })
78
+ const waiting = new Map()
79
+ let id = 0
80
+ let dead
81
+ // a failure of the whole browser rejects every call still waiting
82
+ const fail = (why) => {
83
+ dead ??= why
84
+ for (const { reject } of waiting.values()) reject(new BrowserFailed(dead))
85
+ waiting.clear()
86
+ }
87
+ child.on('error', (e) => fail(e.message))
88
+ const exited = new Promise((ok) => child.once('exit', (code) => (fail(`it stopped (exit ${code})`), ok())))
89
+ let buf = Buffer.alloc(0)
90
+ child.stdio[4].on('data', (d) => {
91
+ buf = Buffer.concat([buf, d])
92
+ let at
93
+ while ((at = buf.indexOf(0)) >= 0) {
94
+ const msg = JSON.parse(buf.subarray(0, at).toString('utf8'))
95
+ buf = buf.subarray(at + 1)
96
+ const call = msg.id && waiting.get(msg.id)
97
+ if (!call) continue
98
+ waiting.delete(msg.id)
99
+ if (msg.error) call.reject(new Error(msg.error.message))
100
+ else call.ok(msg.result)
101
+ }
102
+ })
103
+ child.stdio[3].on('error', (e) => fail(e.message))
104
+ const send = (method, params = {}, sessionId) => new Promise((ok, reject) => {
105
+ if (dead) return reject(new BrowserFailed(dead))
106
+ const n = ++id
107
+ waiting.set(n, { ok, reject })
108
+ child.stdio[3].write(JSON.stringify({ id: n, method, params, ...(sessionId ? { sessionId } : {}) }) + '\0')
109
+ })
110
+ const close = async () => {
111
+ if (!dead) {
112
+ send('Browser.close').catch(() => {})
113
+ await Promise.race([exited, pause(3000)])
114
+ }
115
+ child.kill()
116
+ await Promise.race([exited, pause(2000)])
117
+ rmSync(profile, { recursive: true, force: true })
118
+ }
119
+ return { send, close }
120
+ }
121
+
122
+ /** Opens `file` at `#mode` (with `query`) in a tab of its own and returns what the page wrote, parsed — { error } when
123
+ * the page could not be opened or wrote nothing in `ms`. */
124
+ async function open(b, file, mode, query, ms) {
125
+ const until = Date.now() + ms
126
+ let sessionId
127
+ // Every call waits at most until the page's time is up: a page busy forever does not hold the build.
128
+ const ask = (method, params, wait = until - Date.now()) => {
129
+ let timer
130
+ const late = new Promise((_, reject) => (timer = setTimeout(() => reject(new BrowserFailed(`no answer in ${Math.round(ms / 1000)} s`)), Math.max(0, wait))))
131
+ return Promise.race([b.send(method, params, sessionId), late]).finally(() => clearTimeout(timer))
132
+ }
133
+ const { targetId } = await ask('Target.createTarget', { url: 'about:blank' })
134
+ try {
135
+ ;({ sessionId } = await ask('Target.attachToTarget', { targetId, flatten: true }))
136
+ const nav = await ask('Page.navigate', { url: `${pathToFileURL(file).href}${query ? `?${query}` : ''}#${mode}` })
137
+ if (nav.errorText) return { error: `the page did not open (${nav.errorText})` }
138
+ while (Date.now() < until) {
139
+ const r = await ask('Runtime.evaluate', { expression: `document.getElementById('chalk-${mode}')?.textContent ?? null`, returnByValue: true })
140
+ const text = r.result?.value
141
+ if (typeof text === 'string' && text) return JSON.parse(decodeURIComponent(text))
142
+ await pause(50)
143
+ }
144
+ const at = await ask('Runtime.evaluate', { expression: 'location.href', returnByValue: true }, 1000).catch(() => undefined)
145
+ throw new BrowserFailed(`no answer in ${Math.round(ms / 1000)} s${at?.result?.value ? ` at ${at.result.value}` : ''}`)
146
+ } finally {
147
+ sessionId = undefined
148
+ b.send('Target.closeTarget', { targetId }).catch(() => {})
149
+ }
150
+ }
151
+
152
+ /** Runs `jobs` ({ file, mode, query }) in one browser and returns their results in order, with the browser used —
153
+ * `results` null when there is no browser at all. A browser that fails (does not start, stops, or a page gets no answer
154
+ * in `timeout` seconds) gives way to the next one, which runs every job again; when none works, each result is
155
+ * { error } naming what was tried. */
156
+ export async function runPages(jobs, { timeout = 20 } = {}) {
157
+ const list = candidates()
158
+ if (!list.length) return { results: null, tried: [] }
159
+ const tried = []
160
+ for (const exe of list) {
161
+ let b
162
+ try {
163
+ b = await start(exe)
164
+ const results = []
165
+ for (const { file, mode, query = '' } of jobs) results.push(await open(b, file, mode, query, timeout * 1000))
166
+ return { results, used: exe, tried }
167
+ } catch (e) {
168
+ tried.push(`${exe}: ${e instanceof BrowserFailed ? e.message : e.message.split('\n')[0]}`)
169
+ } finally {
170
+ await b?.close()
171
+ }
172
+ }
173
+ const error = `no browser answered — ${tried.join('; ')}. Point CHROME_PATH at another one, or give it more time with --review-timeout`
174
+ return { results: jobs.map(() => ({ error })), tried }
175
+ }
176
+
177
+ /** One page: what it wrote, parsed; null without a browser, { error } when it failed. */
178
+ export async function runPage(file, mode, query = '', opts) {
179
+ const { results } = await runPages([{ file, mode, query }], opts)
180
+ return results ? results[0] : null
181
+ }
@@ -0,0 +1,22 @@
1
+ // The kit's format reader: the line of a path, the nearest part of it in the file, a syntax error and a file of
2
+ // another format. Run by tools/check.mjs.
3
+ import assert from 'node:assert/strict'
4
+ import { readFormat } from './format'
5
+
6
+ const text = 'format: shop/v1\nname: Corner shop\nitems:\n - id: tea\n price: 3\n - id: cake\n'
7
+ const r = readFormat(text, 'shop/v1')
8
+ assert.ok(r.ok)
9
+ if (r.ok) {
10
+ assert.equal(r.format.raw.name, 'Corner shop')
11
+ assert.equal(r.format.at(['name']), 2)
12
+ assert.equal(r.format.at(['items', 1]), 6)
13
+ assert.equal(r.format.at(['items', 1, 'price']), 6, 'a path not in the file says where its nearest part is')
14
+ assert.equal(r.format.at(['nowhere']), 1)
15
+ }
16
+ const syntax = readFormat('format: shop/v1\nname: x\n bad: indent\n', 'shop/v1')
17
+ assert.ok(!syntax.ok && syntax.problem.key === 'syntax' && syntax.problem.line === 2)
18
+ const other = readFormat('format: other/v1\nname: x\n', 'shop/v1')
19
+ assert.ok(!other.ok && other.problem.key === 'format' && other.problem.line === 1)
20
+ const list = readFormat('- a\n- b\n', 'shop/v1')
21
+ assert.ok(!list.ok && list.problem.key === 'format')
22
+ console.log('kit format checks ok')
package/kit/format.ts ADDED
@@ -0,0 +1,37 @@
1
+ // Reading a skill's YAML file so every mistake can say its line: the document, where a path is in the file, and the
2
+ // two problems that stop reading at once (YAML that does not parse, a file that is not of this format). Part of the
3
+ // skills kit (chalk-talk/kit): a skill's esbuild puts it in its engine.mjs. Needs the `yaml` package.
4
+ import { isMap, LineCounter, parseDocument, type Document } from 'yaml'
5
+
6
+ export type Path = (string | number)[]
7
+
8
+ /** A problem the reader found before the skill's own checks: `syntax` (with YAML's code) or `format`. */
9
+ export interface FormatProblem { line: number; key: 'syntax' | 'format'; detail?: string }
10
+
11
+ export interface Format {
12
+ raw: Record<string, unknown>
13
+ doc: Document
14
+ /** The line where `path` (or the nearest part of it that is in the file) starts; 1 when none is. */
15
+ at: (path: Path) => number
16
+ }
17
+
18
+ export type Read = { ok: true; format: Format } | { ok: false; problem: FormatProblem }
19
+
20
+ /** Parses `text`, which must be a YAML map whose `format:` is `format`. */
21
+ export function readFormat(text: string, format: string): Read {
22
+ const lines = new LineCounter()
23
+ const doc = parseDocument(text, { lineCounter: lines, prettyErrors: false })
24
+ const at = (path: Path) => {
25
+ for (let p = path; p.length; p = p.slice(0, -1)) {
26
+ const node = doc.getIn(p, true) as { range?: [number, number, number] } | undefined
27
+ if (node?.range) return lines.linePos(node.range[0]).line
28
+ }
29
+ return 1
30
+ }
31
+ if (doc.errors.length) return { ok: false, problem: { line: lines.linePos(doc.errors[0].pos[0]).line, key: 'syntax', detail: doc.errors[0].code } }
32
+ const raw = doc.toJS() as unknown
33
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || (raw as Record<string, unknown>).format !== format || !isMap(doc.contents)) {
34
+ return { ok: false, problem: { line: at(['format']), key: 'format' } }
35
+ }
36
+ return { ok: true, format: { raw: raw as Record<string, unknown>, doc, at } }
37
+ }
package/kit/page.mjs ADDED
@@ -0,0 +1,15 @@
1
+ // Making the one HTML file a skill delivers: a value as JSON that can sit inside a <script> tag, and a page template
2
+ // with its {{KEYS}} filled. Part of the skills kit (chalk-talk/kit): copied into each skill by its tools/assets.mjs.
3
+
4
+ /** `value` as JSON, with "<" escaped so no text in it can end the <script> tag it goes in. */
5
+ export const inline = (value) => JSON.stringify(value).replace(/</g, '\\u003c')
6
+
7
+ /** `template` with each {{KEY}} replaced by its value (a function replacer, so `$` in a value is kept as it is). */
8
+ export function fill(template, values) {
9
+ let page = template
10
+ for (const [key, value] of Object.entries(values)) {
11
+ if (!page.includes(`{{${key}}}`)) throw new Error(`The page has no {{${key}}} to fill.`)
12
+ page = page.replace(`{{${key}}}`, () => value)
13
+ }
14
+ return page
15
+ }
@@ -0,0 +1,42 @@
1
+ // Before a push: no name that must not be in a public repository is in the files git tracks. The names themselves live
2
+ // outside every repository — ~/.config/skill-kit/audit.txt, one pattern per line (a regular expression, matched
3
+ // without case; # starts a comment) — since writing them in a public repository would publish them. Without that file
4
+ // it says so and passes. Part of the skills kit (chalk-talk/kit).
5
+ //
6
+ // node audit.mjs [<repository>] [--skip <glob> …] exit 1 when a name is found
7
+ import { execFileSync } from 'node:child_process'
8
+ import { existsSync, readFileSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { join, resolve } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ export const AUDIT_LIST = join(homedir(), '.config/skill-kit/audit.txt')
14
+
15
+ /** The tracked text files that match a pattern of the list, as "file:line" — skipping the `skip` pathspecs (generated
16
+ * and minified files). null when there is no list. */
17
+ export function audit(repo, skip = []) {
18
+ if (!existsSync(AUDIT_LIST)) return null
19
+ const patterns = readFileSync(AUDIT_LIST, 'utf8').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
20
+ if (!patterns.length) return []
21
+ try {
22
+ const out = execFileSync('git', ['-C', repo, 'grep', '-I', '-n', '-i', '-E', patterns.join('|'), '--', '.', ...skip.map((s) => `:!${s}`)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 })
23
+ // only where, never what: the matched text is not printed
24
+ return out.trim().split('\n').filter(Boolean).map((l) => l.split(':').slice(0, 2).join(':'))
25
+ } catch (e) {
26
+ if (e.status === 1) return []
27
+ throw e
28
+ }
29
+ }
30
+
31
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
32
+ const args = process.argv.slice(2)
33
+ const skip = args.flatMap((a, i) => (args[i - 1] === '--skip' ? [a] : []))
34
+ const repo = resolve(args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--skip') ?? '.')
35
+ const found = audit(repo, skip)
36
+ if (found === null) console.log(`• audit no list at ${AUDIT_LIST}: nothing checked`)
37
+ else if (!found.length) console.log('✓ audit no listed name in the tracked files')
38
+ else {
39
+ for (const f of found) console.log(`✗ audit ${f}`)
40
+ process.exit(1)
41
+ }
42
+ }
@@ -0,0 +1,67 @@
1
+ // The skeleton every skill's tools/check.mjs shares: what passed and what did not, running the skill's own build,
2
+ // SKILL.md's frontmatter, one version everywhere, and a page probed in a headless browser. Part of the skills kit
3
+ // (chalk-talk/kit).
4
+ import { spawnSync } from 'node:child_process'
5
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
6
+ import { tmpdir } from 'node:os'
7
+ import { join } from 'node:path'
8
+ import { runPage } from '../browser.mjs'
9
+
10
+ /** `expect(ok, what, detail)` prints ✓/✗; `done(name)` ends the process with 1 when anything failed. */
11
+ export function checker() {
12
+ let failed = 0
13
+ return {
14
+ expect(ok, what, detail = '') {
15
+ console.log(`${ok ? '✓' : '✗'} ${what}${!ok && detail ? `\n${detail}` : ''}`)
16
+ if (!ok) failed++
17
+ return ok
18
+ },
19
+ done(name) {
20
+ if (failed) {
21
+ console.error(`✗ ${failed} check${failed === 1 ? '' : 's'} failed`)
22
+ process.exit(1)
23
+ }
24
+ console.log(`✓ ${name} checks ok`)
25
+ },
26
+ }
27
+ }
28
+
29
+ /** Runs a Node script (the skill's build.mjs) with `args`; the result of spawnSync, as text. */
30
+ export const runNode = (script, args = [], opts = {}) => spawnSync(process.execPath, [script, ...args], { encoding: 'utf8', ...opts })
31
+
32
+ /** SKILL.md's frontmatter as { name, description, version, … } (the version from metadata.version). */
33
+ export function skillFrontmatter(skillMd) {
34
+ const head = readFileSync(skillMd, 'utf8').match(/^---\n([\s\S]*?)\n---/)?.[1] ?? ''
35
+ const field = (k) => head.match(new RegExp(`^${k}: (.*)$`, 'm'))?.[1] ?? ''
36
+ return { name: field('name'), description: field('description'), version: head.match(/version: "([^"]+)"/)?.[1] ?? '', lines: readFileSync(skillMd, 'utf8').split('\n').length }
37
+ }
38
+
39
+ /** The usual checks of SKILL.md: its name is the folder's, a description of at most 1024 characters, under 500 lines. */
40
+ export function checkSkillMd(expect, skillMd, name) {
41
+ const f = skillFrontmatter(skillMd)
42
+ expect(f.name === name, 'SKILL.md name is the folder name')
43
+ expect(f.description.length > 0 && f.description.length <= 1024, `SKILL.md description has ${f.description.length} of 1024 characters`)
44
+ expect(f.lines < 500, 'SKILL.md under 500 lines')
45
+ return f
46
+ }
47
+
48
+ /** One version everywhere: { where: version } all equal. */
49
+ export function checkOneVersion(expect, versions) {
50
+ const all = Object.values(versions)
51
+ expect(all.every((v) => v && v === all[0]), `one version everywhere: ${Object.entries(versions).map(([k, v]) => `${k} ${v}`).join(', ')}`)
52
+ }
53
+
54
+ /** Opens `html` (with a script that writes <pre id="chalk-probe">) in a headless browser; what it wrote, parsed — null
55
+ * without a browser. */
56
+ export async function pageProbe(html, { timeout = 60 } = {}) {
57
+ const file = join(mkdtempSync(join(tmpdir(), 'skill-probe-')), 'probe.html')
58
+ writeFileSync(file, html)
59
+ return runPage(file, 'probe', '', { timeout })
60
+ }
61
+
62
+ /** The script a probed page ends with: `body` is async code that fills `out`; it is written for pageProbe to read. */
63
+ export const probeScript = (body) => `<script type="module">
64
+ const out = {}
65
+ try { ${body} } catch (e) { out.failed = String(e && e.message || e) }
66
+ const pre = document.createElement('pre'); pre.id = 'chalk-probe'; pre.textContent = encodeURIComponent(JSON.stringify(out)); document.body.append(pre)
67
+ </script>`
@@ -0,0 +1,70 @@
1
+ // How fast a skill delivers, from an eval run kept with --keep-temp and --json: for each run, the seconds until the
2
+ // first page (the first build that wrote an .html), the files read before the source was written, the rounds of fixes
3
+ // (builds after the first page) and the quality grader. Per case, the mean of its runs. Part of the skills kit
4
+ // (chalk-talk/kit).
5
+ //
6
+ // node eval-metrics.mjs <run.json> [<another run.json> …]
7
+ import { readFileSync } from 'node:fs'
8
+
9
+ const isBuild = (u) => u.name === 'Bash' && /build\.mjs/.test(u.input?.command ?? '')
10
+ const isRecipe = (u) => (u.name === 'Write' || u.name === 'Edit') && /\.(chalk|mmd|json|md|ya?ml)$/.test(u.input?.file_path ?? '')
11
+ const text = (c) => (typeof c === 'string' ? c : (c ?? []).map((x) => x.text ?? '').join(''))
12
+
13
+ function metrics(tracePath, startedAt) {
14
+ let lines
15
+ try {
16
+ lines = readFileSync(tracePath, 'utf8').trim().split('\n').map((l) => JSON.parse(l))
17
+ } catch {
18
+ return undefined
19
+ }
20
+ const uses = new Map()
21
+ let reads = 0
22
+ let wrote = false
23
+ let first
24
+ let builds = 0
25
+ let drawnAt
26
+ for (const e of lines) {
27
+ for (const c of e.message?.content ?? []) {
28
+ if (c.type === 'tool_use') {
29
+ uses.set(c.id, c)
30
+ // what the skill had to read before writing: files opened with Read, or with cat/sed/head/grep in a shell
31
+ if (!wrote && c.name === 'Read') reads++
32
+ if (!wrote && c.name === 'Bash') reads += (c.input?.command?.match(/(?:references|SKILL)[\w./-]*\.(?:md|json|chalk|mmd)/g) ?? []).length
33
+ if (isRecipe(c)) wrote = true
34
+ if (isBuild(c)) builds++
35
+ }
36
+ if (c.type === 'tool_result' && !drawnAt) {
37
+ const u = uses.get(c.tool_use_id)
38
+ if (u && isBuild(u) && /\.html\b/.test(text(c.content)) && !/^✗ (?!review)/m.test(text(c.content))) {
39
+ drawnAt = Date.parse(e.timestamp)
40
+ first = builds
41
+ }
42
+ }
43
+ }
44
+ }
45
+ return {
46
+ firstDrawing: drawnAt ? (drawnAt - Date.parse(startedAt)) / 1000 : undefined,
47
+ reads,
48
+ fixes: first ? builds - first : undefined,
49
+ }
50
+ }
51
+
52
+ const mean = (xs) => {
53
+ const v = xs.filter((x) => typeof x === 'number')
54
+ return v.length ? v.reduce((a, b) => a + b, 0) / v.length : undefined
55
+ }
56
+ const show = (x, digits = 0) => (x === undefined ? '—' : x.toFixed(digits))
57
+
58
+ for (const file of process.argv.slice(2)) {
59
+ const run = JSON.parse(readFileSync(file, 'utf8'))
60
+ console.log(`\n${file} — ${run.claudeVersion ?? ''}, ${run.durationSeconds ?? '?'} s, US$ ${(run.costUsd ?? 0).toFixed(2)}`)
61
+ console.log('case | 1st drawing s | reads before writing | fixes | quality | runs drawn')
62
+ for (const c of run.cases) {
63
+ const runs = (c.arms?.with ?? []).map((r) => ({ ...r, m: metrics(r.tracePath, r.startedAt) }))
64
+ const ms = runs.map((r) => r.m).filter(Boolean)
65
+ const drawn = ms.filter((m) => m.firstDrawing !== undefined).length
66
+ // the score is the quality grader (or, for a case that should ask, every grader)
67
+ const quality = runs.map((r) => { const q = r.graders?.find((g) => g.name === 'quality'); return q ? Number(q.passed) : r.score })
68
+ console.log(`${c.name.padEnd(24)} | ${show(mean(ms.map((m) => m.firstDrawing))).padStart(13)} | ${show(mean(ms.map((m) => m.reads)), 1).padStart(20)} | ${show(mean(ms.map((m) => m.fixes)), 1).padStart(5)} | ${show(mean(quality), 2).padStart(5)} | ${drawn}/${runs.length}`)
69
+ }
70
+ }
@@ -0,0 +1,30 @@
1
+ // The pages and pictures of a skill's GitHub Pages gallery: each example's page in <docs>/<pages>/ and a PNG of it in
2
+ // <docs>/thumbs/, drawn by the page itself in a headless browser (the kit's browser.mjs, over the DevTools pipe). The
3
+ // page says what its picture is: `mode` is the #mode that answers with it and `pick` takes the PNG data: URL from the
4
+ // answer. The gallery's index stays the skill's own. Part of the skills kit (chalk-talk/kit).
5
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { browser, runPages } from '../browser.mjs'
8
+
9
+ /** `items`: [{ name, html }]. Writes every page, then every picture in one opening of the browser. */
10
+ export async function gallery(docs, items, { pages = 'lessons', mode, pick, timeout = 60 }) {
11
+ if (!browser()) throw new Error('No Chrome, Chromium or Edge: the pictures need one (set CHROME_PATH).')
12
+ for (const dir of [pages, 'thumbs']) {
13
+ rmSync(join(docs, dir), { recursive: true, force: true })
14
+ mkdirSync(join(docs, dir), { recursive: true })
15
+ }
16
+ const files = items.map(({ name, html }) => {
17
+ const file = join(docs, pages, `${name}.html`)
18
+ writeFileSync(file, html)
19
+ return file
20
+ })
21
+ const { results } = await runPages(files.map((file) => ({ file, mode })), { timeout })
22
+ items.forEach(({ name }, k) => {
23
+ const r = results[k]
24
+ const png = r && !r.error ? pick(r) : undefined
25
+ if (!png) throw new Error(`${name}: the picture failed (${r?.error ?? 'no picture in the answer'})`)
26
+ writeFileSync(join(docs, 'thumbs', `${name}.png`), Buffer.from(png.replace(/^data:image\/png;base64,/, ''), 'base64'))
27
+ console.log(`✓ ${name}`)
28
+ })
29
+ return files
30
+ }
@@ -0,0 +1,31 @@
1
+ // A skill's build as in a cloud sandbox: Linux, as root, with Playwright's Chromium and no other browser. Each case is
2
+ // a file to build and the exit code it must give — never «not checked» from a browser that did not answer. Needs
3
+ // Docker; downloads node:22-slim and Playwright's Chromium (~150 MB). Part of the skills kit (chalk-talk/kit).
4
+ import { execFileSync } from 'node:child_process'
5
+ import { basename } from 'node:path'
6
+
7
+ /** `skill`: the skill's folder; `cases`: [{ file, args?, exit }]. Returns true when every case gave its exit code. */
8
+ export function linuxCheck(skill, cases) {
9
+ const name = `skill-linux-check-${process.pid}`
10
+ const sh = (cmd) => execFileSync('docker', ['exec', name, 'bash', '-c', cmd], { encoding: 'utf8' })
11
+ try {
12
+ execFileSync('docker', ['run', '-d', '--name', name, 'node:22-slim', 'sleep', '1800'], { stdio: 'ignore' })
13
+ sh('mkdir -p /work/skill /work/files')
14
+ for (const dir of ['scripts', 'assets']) execFileSync('docker', ['cp', `${skill}/${dir}`, `${name}:/work/skill/`])
15
+ for (const c of cases) execFileSync('docker', ['cp', c.file, `${name}:/work/files/`])
16
+ sh('cd /work && npm init -y >/dev/null && npm i playwright >/dev/null 2>&1 && npx playwright install --with-deps chromium >/dev/null 2>&1')
17
+ let ok = true
18
+ for (const c of cases) {
19
+ const args = (c.args ?? []).map((a) => `'${a}'`).join(' ')
20
+ const out = sh(`cd /work && node skill/scripts/build.mjs files/${basename(c.file)} ${args}; echo "exit=$?"`)
21
+ const exit = Number(out.match(/exit=(\d+)\s*$/)?.[1])
22
+ const unchecked = /not checked|could not run|no Chrome/.test(out)
23
+ const good = exit === c.exit && !unchecked
24
+ ok &&= good
25
+ console.log(`${good ? '✓' : '✗'} linux as root: ${basename(c.file)} exit ${exit} (expected ${c.exit})${unchecked ? ', the review did not run' : ''}`)
26
+ }
27
+ return ok
28
+ } finally {
29
+ execFileSync('docker', ['rm', '-f', name], { stdio: 'ignore' })
30
+ }
31
+ }
@@ -0,0 +1,24 @@
1
+ // A skill carries the kit's runtime files itself (claude.ai installs no packages): `vendor` copies them into the skill
2
+ // and says which kit they came from. Part of the skills kit (chalk-talk/kit), used by a skill's tools/assets.mjs.
3
+ import { copyFileSync, readFileSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const kit = join(dirname(fileURLToPath(import.meta.url)), '..')
8
+
9
+ /** The kit's version: the chalk-talk package it ships in (or the repository's plugin when used from source). */
10
+ export function kitVersion() {
11
+ for (const p of [join(kit, '../package.json'), join(kit, '../.claude-plugin/plugin.json')]) {
12
+ try {
13
+ const { name, version } = JSON.parse(readFileSync(p, 'utf8'))
14
+ if (version && (name === 'chalk-talk' || p.endsWith('plugin.json'))) return version
15
+ } catch {}
16
+ }
17
+ return 'unknown'
18
+ }
19
+
20
+ /** Copies the kit's `files` (e.g. ['browser.mjs', 'page.mjs']) into `into`; returns the line for assets/VERSION. */
21
+ export function vendor(into, files) {
22
+ for (const f of files) copyFileSync(join(kit, f), join(into, f))
23
+ return `kit chalk-talk ${kitVersion()} (${files.join(', ')})`
24
+ }
@@ -0,0 +1,14 @@
1
+ // Packs a skill for claude.ai (Settings › Skills) and the API: <root>/dist/<name>.zip with the skills/<name>/ folder
2
+ // inside. Part of the skills kit (chalk-talk/kit).
3
+ import { execFileSync } from 'node:child_process'
4
+ import { mkdirSync, rmSync, statSync } from 'node:fs'
5
+ import { dirname, join } from 'node:path'
6
+
7
+ export function zip(root, name) {
8
+ const out = join(root, 'dist', `${name}.zip`)
9
+ mkdirSync(dirname(out), { recursive: true })
10
+ rmSync(out, { force: true })
11
+ execFileSync('zip', ['-qr', out, name, '-x', '*.DS_Store'], { cwd: join(root, 'skills') })
12
+ console.log(`✓ ${out} (${Math.round(statSync(out).size / 1024)} KB)`)
13
+ return out
14
+ }
package/markdown.js CHANGED
@@ -1,4 +1,4 @@
1
- // chalk-talk/markdown (engine v0.252.0) — MIT. GENERATED by tools/assets.mjs.
1
+ // chalk-talk/markdown (engine v0.254.0) — MIT. GENERATED by tools/assets.mjs.
2
2
  // player/markdown.ts
3
3
  var FENCES = { "chalk-talk": "text", "chalk-mermaid": "mermaid" };
4
4
  function fenceKind(info) {