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.
- package/README.md +1 -1
- package/assets/player.html +3 -3
- package/embed-mermaid.js +419 -419
- package/embed.js +5 -5
- package/kit/SKILL.template.md +55 -0
- package/kit/browser.mjs +181 -0
- package/kit/format.check.ts +22 -0
- package/kit/format.ts +37 -0
- package/kit/page.mjs +15 -0
- package/kit/tools/audit.mjs +42 -0
- package/kit/tools/checks.mjs +67 -0
- package/kit/tools/eval-metrics.mjs +70 -0
- package/kit/tools/gallery.mjs +30 -0
- package/kit/tools/linux-check.mjs +31 -0
- package/kit/tools/vendor.mjs +24 -0
- package/kit/tools/zip.mjs +14 -0
- package/markdown.js +1 -1
- package/mount.global.js +449 -449
- package/mount.js +1 -1
- package/package.json +4 -2
- package/player.js +4 -4
- package/scripts/browser.mjs +1 -1
- package/scripts/build.mjs +17 -5
- package/scripts/contract.mjs +276 -17
- package/scripts/mermaid.mjs +1 -1
- package/scripts/page.mjs +15 -0
- package/scripts/syntax.mjs +3 -1
|
@@ -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.
|
package/kit/browser.mjs
ADDED
|
@@ -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.
|
|
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) {
|