@itcase/config 1.6.71 → 1.6.73

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 CHANGED
@@ -17,6 +17,8 @@ npm install --save-dev @itcase/config
17
17
  - `createCommitlintReleaseConfig(SCOPES)` — для релизных веток
18
18
  - `commitlint/commitizenAdapter.js` — адаптер Commitizen (type → scope → subScope → subject)
19
19
 
20
+ - **Smart commit** — CLI `smart-commit` (Cursor Agent → preview → commit)
21
+
20
22
  - **Husky** — общие git-хуки в `husky-hooks/`
21
23
  - В git только `.husky/config`; обёртки и `_run.sh` создаёт `itcase-config-husky-install` при `npm i`
22
24
 
@@ -83,6 +85,71 @@ npm install --save-dev commitizen @commitlint/cz-commitlint
83
85
  { "path": "@itcase/config/commitlint/commitizenAdapter.js" }
84
86
  ```
85
87
 
88
+ ## Smart commit
89
+
90
+ `smart-commit` — Cursor Agent (`ask`) планирует коммиты по `SCOPES`, печатает сообщения, спрашивает `[y/N]`.
91
+
92
+ ```text
93
+ [yellow pixel banana animation]
94
+ Drafting commit messages… (12s)
95
+
96
+ Commits:
97
+ • feat(other): add smart-commit cli
98
+ • docs(docs): document smart-commit usage
99
+
100
+ Create 2 commit(s)? [y/N]
101
+ ```
102
+
103
+ Нужны Cursor Agent + логин (`agent login` / `CURSOR_API_KEY`) и `commitlint.config.*` с SCOPES.
104
+
105
+ В проекте с `@itcase/config` добавьте в `package.json`:
106
+
107
+ ```json
108
+ {
109
+ "scripts": {
110
+ "smart-commit": "smart-commit",
111
+ "smart-commit:dry": "smart-commit --dry-run"
112
+ }
113
+ }
114
+ ```
115
+
116
+ Запуск:
117
+
118
+ ```bash
119
+ npm run smart-commit:dry
120
+ npm run smart-commit
121
+ make smart-commit-dry # если есть Makefile
122
+ make smart-commit
123
+ ```
124
+
125
+ В самом `@itcase/config` (разработка пакета):
126
+
127
+ ```bash
128
+ npm run smart-commit:dry
129
+ npm run smart-commit:yes
130
+ make smart-commit-dry
131
+ ```
132
+
133
+ Если `agent` нет в PATH (`command not found`):
134
+
135
+ ```bash
136
+ curl https://cursor.com/install -fsS | bash
137
+ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
138
+ source ~/.bashrc
139
+ agent --version
140
+ agent login
141
+ ```
142
+
143
+ CLI ставится в `~/.local/bin`. Логин в Cursor IDE этот CLI не авторизует.
144
+
145
+ Без браузера (CI): `export CURSOR_API_KEY=…` вместо `agent login`.
146
+
147
+ Обновление CLI: `agent update`.
148
+
149
+ Пока Agent думает, в терминале крутится pixel-анимация dancing banana + статус `(Ns)` на одной области экрана (`log-update`). Для полного лога: `npm run smart-commit -- --verbose --dry-run`.
150
+
151
+ > **Важно:** в npm есть одноимённый пакет `smart-commit`. Используйте `npm run smart-commit` из проекта с `@itcase/config` — bin берётся из `node_modules`. `npx smart-commit` без локальной установки может подтянуть чужой пакет; CLI перед запуском проверяет, что выполняется именно `@itcase/config`.
152
+
86
153
  ## Подключение Husky
87
154
 
88
155
  В git кладите только `.husky/config`. Обёртки хуков и `_run.sh` создаёт `itcase-config-husky-install` при `npm i`. Логика хуков живёт в `@itcase/config/husky-hooks`.
@@ -16,7 +16,44 @@ export const commonRules = {
16
16
  }
17
17
 
18
18
  export const promptSubjectDescription =
19
- 'Write a short, imperative tense description of the change'
19
+ 'Short imperative description in lower-case (no camelCase: use "public dir" not "publicDir")'
20
+
21
+ export const promptSubjectRules = [
22
+ 'lower-case only (commitlint subject-case)',
23
+ 'no camelCase: write "public dir", not "publicDir"',
24
+ 'imperative mood, no trailing period',
25
+ 'single line — no body or footer',
26
+ ]
27
+
28
+ /**
29
+ * Splits camelCase/PascalCase words so subject-case lower-case can pass.
30
+ * @param {string} subject
31
+ * @returns {string}
32
+ */
33
+ export function normalizeSubjectCase(subject) {
34
+ return String(subject || '')
35
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
36
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
37
+ .toLowerCase()
38
+ .replace(/\s+/g, ' ')
39
+ .trim()
40
+ }
41
+
42
+ /**
43
+ * Normalizes full conventional commit header for commitlint subject-case.
44
+ * @param {string} message
45
+ * @returns {string}
46
+ */
47
+ export function normalizeCommitSubject(message) {
48
+ const header = String(message || '')
49
+ .split('\n')[0]
50
+ .trim()
51
+ const match = header.match(/^(\w+)\(([^)]+)\):\s*(.+)$/)
52
+ if (!match) return header
53
+
54
+ const [, type, scope, subject] = match
55
+ return `${type}(${scope}): ${normalizeSubjectCase(subject)}`
56
+ }
20
57
 
21
58
  /**
22
59
  * Builds flat scope-enum list from SCOPES.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itcase/config",
3
- "version": "1.6.71",
3
+ "version": "1.6.73",
4
4
  "author": "ITCase",
5
5
  "description": "ITCase Config",
6
6
  "engines": {
@@ -8,12 +8,14 @@
8
8
  },
9
9
  "bin": {
10
10
  "itcase-config-husky-hooks": "./husky-hooks/run.js",
11
- "itcase-config-husky-install": "./husky-hooks/install-launcher.js"
11
+ "itcase-config-husky-install": "./husky-hooks/install-launcher.js",
12
+ "smart-commit": "./smart-commit/cli.js"
12
13
  },
13
14
  "scripts": {
14
15
  "prepare": "husky && node ./husky-hooks/install-launcher.js",
15
16
  "postinstall": "node ./husky-hooks/install-launcher.js",
16
- "semantic-release": "semantic-release"
17
+ "semantic-release": "semantic-release",
18
+ "smart-commit": "node smart-commit/cli.js"
17
19
  },
18
20
  "type": "module",
19
21
  "license": "MIT",
@@ -29,6 +31,7 @@
29
31
  "lint-staged-ts",
30
32
  "semantic-release",
31
33
  "postcss",
34
+ "smart-commit",
32
35
  "svgr",
33
36
  "README.md"
34
37
  ],
@@ -65,6 +68,7 @@
65
68
  "cssnano": "^8.0.6",
66
69
  "cssnano-preset-default": "^8.0.6",
67
70
  "inquirer": "^14.1.0",
71
+ "log-update": "^8.0.0",
68
72
  "postcss": "^8.5.26",
69
73
  "postcss-aspect-ratio-polyfill": "^2.0.0",
70
74
  "postcss-clamp": "^4.1.0",
@@ -0,0 +1,600 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, spawnSync } from 'node:child_process'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { stdin as input, stdout as output } from 'node:process'
6
+ import readline from 'node:readline/promises'
7
+ import { fileURLToPath, pathToFileURL } from 'node:url'
8
+
9
+ import lint from '@commitlint/lint'
10
+ import load from '@commitlint/load'
11
+ import { logUpdateStderr } from 'log-update'
12
+
13
+ import {
14
+ buildScopeEnum,
15
+ normalizeCommitSubject,
16
+ promptSubjectRules,
17
+ } from '../commitlint/commitlintCommon.js'
18
+ import { getBananaFrame } from './dancing-banana.js'
19
+
20
+ const PACKAGE_NAME = '@itcase/config'
21
+ const CLI_RELATIVE = 'smart-commit/cli.js'
22
+
23
+ const MAX_DIFF = 80_000
24
+ const AGENT_TIMEOUT_MS = 120_000
25
+ const AGENT_STEP_MS = 2_500
26
+ const BANANA_FRAME_MS = 100
27
+ const AGENT_WAIT_STEPS = [
28
+ 'Asking Cursor Agent',
29
+ 'Still thinking',
30
+ 'Waiting for response',
31
+ ]
32
+ const HELP = `smart-commit [--dry-run] [--yes|-y] [--type t] [--model id] [--verbose]
33
+
34
+ Cursor Agent → commit messages by SCOPES → [y/N] → git commit
35
+ Run via: npm exec smart-commit (from project with @itcase/config)
36
+ Needs: agent login (or CURSOR_API_KEY) + commitlint.config.* with SCOPES
37
+ `
38
+ const AGENT_MISSING = `Cursor Agent CLI not found — smart-commit cannot run.
39
+
40
+ curl https://cursor.com/install -fsS | bash
41
+ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
42
+ source ~/.bashrc
43
+ agent --version
44
+ agent login
45
+
46
+ If agent is still missing, see README.md → Smart commit.
47
+ Or set CURSOR_API_KEY instead of login.
48
+ `
49
+
50
+ const argv = process.argv.slice(2)
51
+ if (argv.includes('-h') || argv.includes('--help')) {
52
+ process.stdout.write(HELP)
53
+ process.exit(0)
54
+ }
55
+
56
+ main(argv).catch((error) => {
57
+ process.stderr.write(`${error.message || error}\n`)
58
+ process.exit(1)
59
+ })
60
+
61
+ async function main(argv) {
62
+ const option = parseArgs(argv)
63
+ const cwd = process.cwd()
64
+ assertItcaseConfigPackage(cwd)
65
+ const { scopes, types } = await loadCommitlintConfig(cwd)
66
+ const files = listFiles(cwd, option)
67
+ if (!files.length) {
68
+ process.stdout.write('No changes to commit.\n')
69
+ return
70
+ }
71
+
72
+ const prompt = buildPrompt(scopes, types, files, cwd, option.type)
73
+ const raw = await withAgentProgress(
74
+ { fileCount: files.length, verbose: option.verbose },
75
+ (progress) =>
76
+ askAgent(
77
+ cwd,
78
+ option.model,
79
+ prompt,
80
+ option.verbose,
81
+ progress.onAgentActivity,
82
+ ),
83
+ )
84
+ const plans = parsePlan(raw, files)
85
+ await validateAndNormalizePlans(cwd, plans)
86
+
87
+ process.stdout.write('\nCommits:\n')
88
+ for (const plan of plans) process.stdout.write(` • ${plan.message}\n`)
89
+ process.stdout.write('\n')
90
+
91
+ if (option.dryRun) {
92
+ process.stdout.write('Dry run — nothing committed.\n')
93
+ return
94
+ }
95
+ if (!option.yes && !(await confirm(plans.length))) {
96
+ process.stdout.write('Aborted.\n')
97
+ return
98
+ }
99
+
100
+ const activePlans = plans
101
+ .map((plan) => ({
102
+ ...plan,
103
+ files: plan.files.filter((file) => fileHasChanges(cwd, file)),
104
+ }))
105
+ .filter((plan) => plan.files.length)
106
+
107
+ if (!activePlans.length) {
108
+ process.stdout.write('No changes left to commit (already committed?).\n')
109
+ return
110
+ }
111
+
112
+ if (activePlans.length !== plans.length) {
113
+ process.stdout.write(
114
+ `Skipping ${plans.length - activePlans.length} empty commit(s).\n`,
115
+ )
116
+ }
117
+
118
+ let created = 0
119
+ for (const plan of activePlans) {
120
+ git(cwd, ['add', '--', ...plan.files])
121
+ const staged = lines(cwd, ['diff', '--cached', '--name-only'])
122
+ const stagedForPlan = plan.files.filter((file) => staged.includes(file))
123
+ if (!stagedForPlan.length) {
124
+ throw new Error(
125
+ `Nothing staged for commit: ${plan.message}\n` +
126
+ `Files: ${plan.files.join(', ')}`,
127
+ )
128
+ }
129
+
130
+ const commit = spawnSync('git', ['commit', '-m', plan.message], {
131
+ cwd: cwd,
132
+ encoding: 'utf8',
133
+ stdio: ['ignore', 'inherit', 'inherit'],
134
+ })
135
+ if (commit.status) throw new Error(`git commit failed: ${plan.message}`)
136
+ created++
137
+ }
138
+ process.stdout.write(`Created ${created} commit(s).\n`)
139
+ }
140
+
141
+ function parseArgs(argv) {
142
+ const option = {
143
+ type: '',
144
+ dryRun: false,
145
+ model: '',
146
+ staged: false,
147
+ verbose: false,
148
+ yes: false,
149
+ }
150
+ for (let i = 0; i < argv.length; i++) {
151
+ const arg = argv[i]
152
+ if (arg === '--dry-run') option.dryRun = true
153
+ else if (arg === '--yes' || arg === '-y') option.yes = true
154
+ else if (arg === '--verbose') option.verbose = true
155
+ else if (arg === '--staged') option.staged = true
156
+ else if (arg === '--type') option.type = argv[++i] || ''
157
+ else if (arg === '--model') option.model = argv[++i] || ''
158
+ else if (arg.startsWith('--type=')) option.type = arg.slice(7)
159
+ else if (arg.startsWith('--model=')) option.model = arg.slice(8)
160
+ else throw new Error(`Unknown argument: ${arg}`)
161
+ }
162
+ return option
163
+ }
164
+
165
+ function assertItcaseConfigPackage(cwd) {
166
+ const scriptPath = fileURLToPath(import.meta.url)
167
+
168
+ if (
169
+ scriptPath.includes(
170
+ `${path.sep}itcase-config${path.sep}smart-commit${path.sep}`,
171
+ )
172
+ ) {
173
+ return
174
+ }
175
+
176
+ const configSegment = `${path.sep}@itcase${path.sep}config${path.sep}`
177
+ if (!scriptPath.includes(configSegment)) {
178
+ throw wrongPackageError(cwd)
179
+ }
180
+
181
+ const localCli = path.join(
182
+ cwd,
183
+ 'node_modules',
184
+ '@itcase',
185
+ 'config',
186
+ CLI_RELATIVE,
187
+ )
188
+ if (!fs.existsSync(localCli)) {
189
+ throw new Error(
190
+ `@itcase/config not installed in ${cwd}.\n` +
191
+ ' npm install --save-dev @itcase/config\n' +
192
+ 'Then run: npm exec smart-commit',
193
+ )
194
+ }
195
+
196
+ const resolvedSelf = fs.realpathSync(scriptPath)
197
+ const resolvedLocal = fs.realpathSync(localCli)
198
+ if (resolvedSelf !== resolvedLocal) {
199
+ throw wrongPackageError(cwd)
200
+ }
201
+
202
+ const packageJsonPath = path.join(
203
+ cwd,
204
+ 'node_modules',
205
+ '@itcase',
206
+ 'config',
207
+ 'package.json',
208
+ )
209
+ if (fs.existsSync(packageJsonPath)) {
210
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
211
+ if (packageJson.name !== PACKAGE_NAME) {
212
+ throw wrongPackageError(cwd)
213
+ }
214
+ }
215
+ }
216
+
217
+ function wrongPackageError(cwd) {
218
+ return new Error(
219
+ `Wrong smart-commit — use @itcase/config from ${cwd}, not npm registry package.\n` +
220
+ ' npm install --save-dev @itcase/config\n' +
221
+ ' npm exec smart-commit --dry-run\n' +
222
+ 'Avoid: npx smart-commit (may install unrelated package from npm).',
223
+ )
224
+ }
225
+
226
+ async function loadCommitlintConfig(cwd) {
227
+ exportReleaseBranchEnv(cwd)
228
+
229
+ const file = [
230
+ 'commitlint.config.mjs',
231
+ 'commitlint.config.js',
232
+ 'commitlint.config.cjs',
233
+ ]
234
+ .map((name) => path.join(cwd, name))
235
+ .find((candidate) => fs.existsSync(candidate))
236
+ if (!file) throw new Error(`commitlint.config.* not found in ${cwd}`)
237
+
238
+ const config = (await import(pathToFileURL(file).href)).default
239
+ const scopes = config?.SCOPES
240
+ if (!scopes) {
241
+ throw new Error(
242
+ 'SCOPES missing — use createCommitlintProjectConfig(SCOPES)',
243
+ )
244
+ }
245
+
246
+ const types = config.rules?.['type-enum']?.[2]
247
+ if (!Array.isArray(types) || !types.length) {
248
+ throw new Error('type-enum missing in commitlint.config.*')
249
+ }
250
+
251
+ return { scopes, types }
252
+ }
253
+
254
+ function exportReleaseBranchEnv(cwd) {
255
+ if (process.env.RELEASE_BRANCH === 'true') return
256
+
257
+ const branch = out(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
258
+ if (!branch) return
259
+
260
+ const releaseBranches = readReleaseBranches(cwd)
261
+ if (releaseBranches.includes(branch)) {
262
+ process.env.RELEASE_BRANCH = 'true'
263
+ }
264
+ }
265
+
266
+ function readReleaseBranches(cwd) {
267
+ const configPath = path.join(cwd, '.husky', 'config')
268
+ if (!fs.existsSync(configPath)) return ['master']
269
+
270
+ const text = fs.readFileSync(configPath, 'utf8')
271
+ const match = text.match(/RELEASE_BRANCHES="([^"]+)"/)
272
+ if (!match) return ['master']
273
+
274
+ return match[1].split(/\s+/).filter(Boolean)
275
+ }
276
+
277
+ function listFiles(cwd, option) {
278
+ const staged = lines(cwd, [
279
+ 'diff',
280
+ '--cached',
281
+ '--name-only',
282
+ '--diff-filter=ACDMR',
283
+ ])
284
+ if (option.staged || staged.length) return staged
285
+ return [
286
+ ...new Set([
287
+ ...lines(cwd, ['diff', '--name-only', '--diff-filter=ACDMR']),
288
+ ...lines(cwd, ['ls-files', '--others', '--exclude-standard']),
289
+ ]),
290
+ ]
291
+ }
292
+
293
+ function buildPrompt(scopes, types, files, cwd, type) {
294
+ let diff = [
295
+ out(cwd, ['diff', '--cached', '--', ...files]),
296
+ out(cwd, ['diff', '--', ...files]),
297
+ ]
298
+ .filter(Boolean)
299
+ .join('\n\n')
300
+ if (diff.length > MAX_DIFF) diff = `${diff.slice(0, MAX_DIFF)}\n…truncated`
301
+
302
+ return `Plan git commits. Do not edit files or run git commit.
303
+ Return ONLY JSON: {"commits":[{"message":"type(scope): subject","files":["a.ts"]}]}
304
+ Types: ${types.join(', ')}
305
+ Scopes (exact): ${buildScopeEnum(scopes).join(', ')}
306
+ Subject rules:
307
+ ${promptSubjectRules.map((rule) => `- ${rule}`).join('\n')}
308
+ Prefer one commit when changes belong to the same feature.
309
+ Split by scope. Each listed file in exactly one commit.
310
+ Do not add Co-authored-by or Made-with trailers.
311
+ ${type ? `Prefer type "${type}".\n` : ''}SCOPES: ${JSON.stringify(scopes)}
312
+ Files:
313
+ ${files.map((file) => `- ${file}`).join('\n')}
314
+ status:
315
+ ${out(cwd, ['status', '--short', '--', ...files]) || '(empty)'}
316
+ diff:
317
+ ${diff || '(none)'}
318
+ `
319
+ }
320
+
321
+ function buildAgentProgressSteps(fileCount) {
322
+ const fileLabel =
323
+ fileCount === 1 ? '1 changed file' : `${fileCount} changed files`
324
+
325
+ return [
326
+ `Reading ${fileLabel}`,
327
+ 'Loading commitlint scopes and types',
328
+ 'Analyzing diff',
329
+ 'Grouping changes by scope',
330
+ 'Drafting commit messages',
331
+ ]
332
+ }
333
+
334
+ async function withAgentProgress(context, callback) {
335
+ const earlySteps = buildAgentProgressSteps(context.fileCount)
336
+ const startedAt = Date.now()
337
+ let earlyStepIndex = 0
338
+ let waitStepIndex = 0
339
+ let phase = 'early'
340
+ let receivedChars = 0
341
+ let frameIndex = 0
342
+
343
+ const onAgentActivity = (charCount) => {
344
+ if (charCount > 0) {
345
+ receivedChars = charCount
346
+ phase = 'receiving'
347
+ }
348
+ }
349
+
350
+ const currentLabel = () => {
351
+ if (phase === 'receiving') {
352
+ return `Receiving response (${receivedChars} chars)`
353
+ }
354
+ if (phase === 'waiting') {
355
+ return AGENT_WAIT_STEPS[waitStepIndex % AGENT_WAIT_STEPS.length]
356
+ }
357
+ return earlySteps[Math.min(earlyStepIndex, earlySteps.length - 1)]
358
+ }
359
+
360
+ const formatText = () => {
361
+ const elapsed = Math.floor((Date.now() - startedAt) / 1000)
362
+ return `${currentLabel()}… (${elapsed}s)`
363
+ }
364
+
365
+ if (!process.stderr.isTTY || context.verbose) {
366
+ process.stderr.write(`${formatText()}\n`)
367
+ return callback({ onAgentActivity })
368
+ }
369
+
370
+ const render = () => {
371
+ logUpdateStderr(`${getBananaFrame(frameIndex)}\n${formatText()}`)
372
+ frameIndex++
373
+ }
374
+
375
+ render()
376
+ const frameTimer = setInterval(render, BANANA_FRAME_MS)
377
+ const stepTimer = setInterval(() => {
378
+ if (phase === 'early') {
379
+ if (earlyStepIndex < earlySteps.length - 1) {
380
+ earlyStepIndex++
381
+ } else {
382
+ phase = 'waiting'
383
+ }
384
+ } else if (phase === 'waiting') {
385
+ waitStepIndex++
386
+ }
387
+ }, AGENT_STEP_MS)
388
+
389
+ try {
390
+ return await callback({ onAgentActivity })
391
+ } finally {
392
+ clearInterval(frameTimer)
393
+ clearInterval(stepTimer)
394
+ logUpdateStderr.clear()
395
+ }
396
+ }
397
+
398
+ function askAgent(cwd, model, prompt, verbose, onAgentActivity) {
399
+ const cmd = resolveAgent()
400
+ const args = [
401
+ '-p',
402
+ '--mode',
403
+ 'ask',
404
+ '--output-format',
405
+ 'text',
406
+ '--trust',
407
+ '--workspace',
408
+ cwd,
409
+ ]
410
+ if (model) args.push('--model', model)
411
+ if (process.env.CURSOR_API_KEY)
412
+ args.push('--api-key', process.env.CURSOR_API_KEY)
413
+ args.push(prompt)
414
+
415
+ if (verbose) process.stderr.write(`Using: ${cmd}\n`)
416
+
417
+ return new Promise((resolve, reject) => {
418
+ const child = spawn(cmd, args, {
419
+ cwd: cwd,
420
+ stdio: ['ignore', 'pipe', 'pipe'],
421
+ })
422
+
423
+ let stdout = ''
424
+ let stderr = ''
425
+
426
+ child.stdout.on('data', (chunk) => {
427
+ stdout += chunk
428
+ if (onAgentActivity) onAgentActivity(stdout.length)
429
+ if (verbose) process.stderr.write(chunk)
430
+ })
431
+ child.stderr.on('data', (chunk) => {
432
+ stderr += chunk
433
+ if (verbose) process.stderr.write(chunk)
434
+ })
435
+
436
+ const timeout = setTimeout(() => {
437
+ child.kill('SIGTERM')
438
+ reject(
439
+ new Error(
440
+ `Cursor Agent timed out after ${AGENT_TIMEOUT_MS / 1000}s. Check agent login / network.`,
441
+ ),
442
+ )
443
+ }, AGENT_TIMEOUT_MS)
444
+
445
+ child.on('error', (error) => {
446
+ clearTimeout(timeout)
447
+ reject(error)
448
+ })
449
+
450
+ child.on('close', (code) => {
451
+ clearTimeout(timeout)
452
+ if (code) {
453
+ reject(new Error(formatAgentError(stderr, stdout)))
454
+ return
455
+ }
456
+ resolve(stdout.trim())
457
+ })
458
+ })
459
+ }
460
+
461
+ function formatAgentError(stderr, stdout) {
462
+ const text = (stderr || stdout || AGENT_MISSING).trim()
463
+ if (/authentication required/i.test(text)) {
464
+ return `Cursor Agent failed.\n${text}\n\nRun: agent login\nOr set CURSOR_API_KEY.`
465
+ }
466
+ return `Cursor Agent failed.\n${text}`
467
+ }
468
+
469
+ function resolveAgent() {
470
+ const home = process.env.HOME || ''
471
+ const candidates = [
472
+ which('agent'),
473
+ which('cursor-agent'),
474
+ `${home}/.local/bin/agent`,
475
+ `${home}/.local/bin/cursor-agent`,
476
+ `${home}/Library/Application Support/Cursor/User/globalStorage/anysphere.cursor-agent-worker/agent-cli/.local/bin/cursor-agent`,
477
+ ]
478
+ const found = candidates.find(
479
+ (candidate) => candidate && fs.existsSync(candidate),
480
+ )
481
+ if (!found) throw new Error(AGENT_MISSING)
482
+ return found
483
+ }
484
+
485
+ function which(name) {
486
+ const result = spawnSync('which', [name], { encoding: 'utf8' })
487
+ return result.status === 0 ? result.stdout.trim() : ''
488
+ }
489
+
490
+ function parsePlan(raw, allowedFiles) {
491
+ const allowed = new Set(allowedFiles)
492
+ const body = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1] || raw
493
+ const start = body.indexOf('{')
494
+ const end = body.lastIndexOf('}')
495
+ if (start < 0 || end <= start)
496
+ throw new Error(`No JSON in Cursor output:\n${raw}`)
497
+
498
+ let data
499
+ try {
500
+ data = JSON.parse(body.slice(start, end + 1))
501
+ } catch (error) {
502
+ throw new Error(`Bad JSON from Cursor:\n${raw}`, { cause: error })
503
+ }
504
+
505
+ const seen = new Set()
506
+ const plans = []
507
+ for (const item of data.commits || []) {
508
+ const message = normalizeCommitMessage(item.message)
509
+ const files = (item.files || [])
510
+ .map(String)
511
+ .filter((file) => allowed.has(file) && !seen.has(file))
512
+ files.forEach((file) => seen.add(file))
513
+ if (message && files.length) plans.push({ message, files })
514
+ }
515
+
516
+ if (!plans.length) throw new Error(`Empty commit plan:\n${raw}`)
517
+ const missing = allowedFiles.filter((file) => !seen.has(file))
518
+ if (missing.length)
519
+ throw new Error(`Cursor omitted files:\n${missing.join('\n')}`)
520
+ return plans
521
+ }
522
+
523
+ function normalizeCommitMessage(message) {
524
+ const lines = String(message || '')
525
+ .split('\n')
526
+ .map((line) => line.trim())
527
+ .filter(Boolean)
528
+ .filter((line) => !/^Co-authored-by:/i.test(line))
529
+ .filter((line) => !/^Made-with:/i.test(line))
530
+
531
+ const subject = (lines[0] || '').replace(/\s+Co-authored-by:.*$/i, '').trim()
532
+ return normalizeCommitSubject(subject)
533
+ }
534
+
535
+ async function validateAndNormalizePlans(cwd, plans) {
536
+ const loaded = await load({}, cwd)
537
+ const lintOptions = {
538
+ defaultIgnores: loaded.defaultIgnores,
539
+ ignores: loaded.ignores,
540
+ parserPreset: loaded.parserPreset,
541
+ }
542
+
543
+ for (const plan of plans) {
544
+ plan.message = normalizeCommitSubject(plan.message)
545
+ const report = await lint(plan.message, loaded.rules, lintOptions)
546
+ if (report.valid) continue
547
+
548
+ const errors =
549
+ report.errors?.map((error) => error.message).join('; ') || 'invalid'
550
+ throw new Error(
551
+ `Commit message failed commitlint:\n ${plan.message}\n ${errors}\n` +
552
+ 'Use lower-case subject without camelCase (e.g. "public dir" not "publicDir").',
553
+ )
554
+ }
555
+ }
556
+
557
+ function fileHasChanges(cwd, file) {
558
+ const filePath = path.join(cwd, file)
559
+ if (!fs.existsSync(filePath)) return false
560
+
561
+ const tracked =
562
+ spawnSync('git', ['ls-files', '--error-unmatch', '--', file], {
563
+ cwd: cwd,
564
+ encoding: 'utf8',
565
+ }).status === 0
566
+
567
+ if (!tracked) return true
568
+
569
+ return (
570
+ spawnSync('git', ['diff', '--quiet', 'HEAD', '--', file], {
571
+ cwd: cwd,
572
+ encoding: 'utf8',
573
+ }).status === 1
574
+ )
575
+ }
576
+
577
+ async function confirm(count) {
578
+ if (!input.isTTY) throw new Error('No TTY — use --yes or --dry-run')
579
+ const rl = readline.createInterface({ input, output })
580
+ try {
581
+ const answer = await rl.question(`Create ${count} commit(s)? [y/N] `)
582
+ return /^y(es)?$/i.test(answer.trim())
583
+ } finally {
584
+ rl.close()
585
+ }
586
+ }
587
+
588
+ function git(cwd, args) {
589
+ const result = spawnSync('git', args, { cwd: cwd, encoding: 'utf8' })
590
+ if (result.status) throw new Error(result.stderr || `git ${args.join(' ')}`)
591
+ return result
592
+ }
593
+
594
+ function out(cwd, args) {
595
+ return (git(cwd, args).stdout || '').trim()
596
+ }
597
+
598
+ function lines(cwd, args) {
599
+ return out(cwd, args).split('\n').filter(Boolean)
600
+ }
@@ -0,0 +1,267 @@
1
+ // Extracted from the classic dancing banana GIF (Trym Stene, 1999).
2
+ // 8 frames, 24×25px — fixed canvas, solid 2-space blocks.
3
+
4
+ const RESET = '\x1b[0m'
5
+ const FRAME_WIDTH = 24
6
+ const FRAME_HEIGHT = 25
7
+
8
+ const COLORS = {
9
+ B: 236,
10
+ d: 136,
11
+ E: 255,
12
+ R: 196,
13
+ W: 255,
14
+ Y: 226,
15
+ y: 214,
16
+ }
17
+
18
+ const FRAMES = [
19
+ [
20
+ '........................',
21
+ '........................',
22
+ '........................',
23
+ '........................',
24
+ '............BB..........',
25
+ '............BBB.........',
26
+ '............dYB.........',
27
+ '............YYYB........',
28
+ '............YYYBB.......',
29
+ '..........BBBBYyB.......',
30
+ '.........BEBEEByy.......',
31
+ '.........BEBEEByy.......',
32
+ '..........BBBBYBy.......',
33
+ '...........BYYBBy.......',
34
+ '............RRRyy.......',
35
+ '............RBByy.......',
36
+ '.......BB..BYYYyB.BB....',
37
+ '......BEB.BBYYYyBBBEB...',
38
+ '......BEE.BYYYyBBBEEB...',
39
+ '.......BBBBYyyyBWBBB....',
40
+ '........BBYYyyBBWB......',
41
+ '.......BBBBBBBBBB.......',
42
+ '.......BB......BB.......',
43
+ '......BWWBB..BBWWB......',
44
+ '.......BBBB..BBBB.......',
45
+ ],
46
+ [
47
+ '........................',
48
+ '........................',
49
+ '........................',
50
+ '............BB..........',
51
+ '............ddB.........',
52
+ '............dYB.........',
53
+ '............YYYBB.......',
54
+ '............YYYyB.......',
55
+ '..........BBBBYyB.......',
56
+ '.........BBBWWByy.......',
57
+ '.........BEBEEByy.......',
58
+ '...........BYYBBy.......',
59
+ '...........BBBRBy.......',
60
+ '............RRRyy.......',
61
+ '...........BBYYyy.......',
62
+ '.....BB....BYYYyy...BB..',
63
+ '....BEEB..BBYYYyB..BEEB.',
64
+ '....BEEB..BYYYyBB..BEEB.',
65
+ '.....BBBBBBYYyyBB.BBBB..',
66
+ '.........BYYyyB.........',
67
+ '.........BByBBB.........',
68
+ '........B...............',
69
+ '.......BB......BB.......',
70
+ '......BWWBB..BBWWB......',
71
+ '.......BBB....BBB.......',
72
+ ],
73
+ [
74
+ '............B...........',
75
+ '...........BdB..........',
76
+ '...........BdBB.........',
77
+ '..........BBYyB.........',
78
+ '..........BYYYy.........',
79
+ '.BBB.....BBBBBB......BB.',
80
+ '.WWW.....WBWWBWB....BWWB',
81
+ '.WWB.....WWWWWWB....BBWB',
82
+ '.BBB.....BBBBBBB....BBB.',
83
+ '.....BB..BBBBBRB.BBB....',
84
+ '.......BBBRRRRBB.B......',
85
+ '.........BYBBYYB........',
86
+ '.........BYYYYyB........',
87
+ '.........BYYYYyB........',
88
+ '.........BYYYYy.........',
89
+ '.........BYYYyy.........',
90
+ '.........BYYyyB.........',
91
+ '.........BYYyBB.........',
92
+ '.........BBBBB..........',
93
+ '..........B..B..........',
94
+ '.........BB..BB.........',
95
+ '.........B....B.........',
96
+ '.......BBBB..BBBB.......',
97
+ '......BWWWB..BWWWB......',
98
+ '......BBB......BBB......',
99
+ ],
100
+ [
101
+ '........................',
102
+ '..........BB............',
103
+ '.........Bdd............',
104
+ '.........Bdd............',
105
+ '.........yYYB...........',
106
+ '........ByYYB...........',
107
+ '........BBBYBB..........',
108
+ '.......BBWWBWWB.........',
109
+ '.......BBBWBBWB.........',
110
+ '.WWB...ByBBYBB...BWW....',
111
+ '.EEE...BBYYYYB...EEE....',
112
+ '.BBB...ByRRRRB...BBB....',
113
+ '....B..ByBRRBB..B.......',
114
+ '....BB.ByYBBYB.BB.......',
115
+ '.......BBYYYYB..........',
116
+ '........BYYYYB..........',
117
+ '........ByYYYB..........',
118
+ '.........yyYYB..........',
119
+ '.........ByyYB..........',
120
+ '.........BBBBB..........',
121
+ '.........B...B..........',
122
+ '..............B.........',
123
+ '.......BB.....BBB.......',
124
+ '......BWWBB..BBWWB......',
125
+ '......BBBB....BBBB......',
126
+ ],
127
+ [
128
+ '........................',
129
+ '........................',
130
+ '........................',
131
+ '........................',
132
+ '..........BB............',
133
+ '.........BBB............',
134
+ '.........BYd............',
135
+ '........BYYY............',
136
+ '.......BBYYY............',
137
+ '.......ByYBBBB..........',
138
+ '.......yyBEEBEB.........',
139
+ '.......yyBEEBEB.........',
140
+ '.......yBYBBBB..........',
141
+ '.......yBBYYB...........',
142
+ '.......yyRRR............',
143
+ '.......yyBBR............',
144
+ '....BB.ByYYYB..BB.......',
145
+ '...BEBBByYYYBB.BEB......',
146
+ '...BEEBBByYYYB.EEB......',
147
+ '....BBBWByyyYBBBB.......',
148
+ '......BWBByyYYBB........',
149
+ '.......BBBBBBBBBB.......',
150
+ '.......BB......BB.......',
151
+ '......BWWBB..BBWWB......',
152
+ '.......BBBB..BBBB.......',
153
+ ],
154
+ [
155
+ '........................',
156
+ '........................',
157
+ '........................',
158
+ '..........BB............',
159
+ '.........Bdd............',
160
+ '.........BYd............',
161
+ '.......BBYYY............',
162
+ '.......ByYYY............',
163
+ '.......ByYBBBB..........',
164
+ '.......yyBWWBBB.........',
165
+ '.......yyBEEBEB.........',
166
+ '.......yBBYYB...........',
167
+ '.......yBRBBB...........',
168
+ '.......yyRRR............',
169
+ '.......yyYYBB...........',
170
+ '..BB...yyYYYB....BB.....',
171
+ '.BEEB..ByYYYBB..BEEB....',
172
+ '.BEEB..BByYYYB..BEEB....',
173
+ '..BBBB.BByyYYBBBBBB.....',
174
+ '.........ByyYYB.........',
175
+ '.........BBByBB.........',
176
+ '...............B........',
177
+ '.......BB......BB.......',
178
+ '......BWWBB..BBWWB......',
179
+ '.......BBB....BBB.......',
180
+ ],
181
+ [
182
+ '...........B............',
183
+ '..........BdB...........',
184
+ '.........BBdB...........',
185
+ '.........ByYBB..........',
186
+ '.........yYYYB..........',
187
+ '.BB......BBBBBB.....BBB.',
188
+ 'BWWB....BWBWWBW.....WWW.',
189
+ 'BWBB....BWWWWWW.....BWW.',
190
+ '.BBB....BBBBBBB.....BBB.',
191
+ '....BBB.BRBBBBB..BB.....',
192
+ '......B.BBRRRRBBB.......',
193
+ '........BYYBBYB.........',
194
+ '........ByYYYYB.........',
195
+ '........ByYYYYB.........',
196
+ '.........yYYYYB.........',
197
+ '.........yyYYYB.........',
198
+ '.........ByyYYB.........',
199
+ '.........BByYYB.........',
200
+ '..........BBBBB.........',
201
+ '..........B..B..........',
202
+ '.........BB..BB.........',
203
+ '.........B....B.........',
204
+ '.......BBBB..BBBB.......',
205
+ '......BWWWB..BWWWB......',
206
+ '......BBB......BBB......',
207
+ ],
208
+ [
209
+ '........................',
210
+ '............BB..........',
211
+ '............ddB.........',
212
+ '............ddB.........',
213
+ '...........BYYy.........',
214
+ '...........BYYyB........',
215
+ '..........BBYBBB........',
216
+ '.........BWWBWWBB.......',
217
+ '.........BWBBWBBB.......',
218
+ '....WWB...BBYBByB...BWW.',
219
+ '....EEE...BYYYYBB...EEE.',
220
+ '....BBB...BRRRRyB...BBB.',
221
+ '.......B..BBRRByB..B....',
222
+ '.......BB.BYBBYyB.BB....',
223
+ '..........BYYYYBB.......',
224
+ '..........BYYYYB........',
225
+ '..........BYYYyB........',
226
+ '..........BYYyy.........',
227
+ '..........BYyyB.........',
228
+ '..........BBBBB.........',
229
+ '..........B...B.........',
230
+ '.........B..............',
231
+ '.......BBB.....BB.......',
232
+ '......BWWBB..BBWWB......',
233
+ '......BBBB....BBBB......',
234
+ ],
235
+ ]
236
+
237
+ function renderRow(row) {
238
+ const padded = row.padEnd(FRAME_WIDTH, '.').slice(0, FRAME_WIDTH)
239
+ let line = ''
240
+ for (const cell of padded) {
241
+ if (cell === '.') {
242
+ line += ' '
243
+ continue
244
+ }
245
+ const code = COLORS[cell]
246
+ if (code === undefined) {
247
+ line += ' '
248
+ continue
249
+ }
250
+ const bold = cell === 'E' ? '\x1b[1m' : ''
251
+ line += `${bold}\x1b[48;5;${code}m ${RESET}`
252
+ }
253
+ return line
254
+ }
255
+
256
+ export function getBananaFrame(index) {
257
+ const frame = FRAMES[index % FRAMES.length]
258
+ const lines = frame.map(renderRow)
259
+ while (lines.length < FRAME_HEIGHT) {
260
+ lines.push(' '.repeat(FRAME_WIDTH * 2))
261
+ }
262
+ return lines.slice(0, FRAME_HEIGHT).join('\n')
263
+ }
264
+
265
+ export function getBananaFrameCount() {
266
+ return FRAMES.length
267
+ }
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env python3
2
+ """Extract dancing-banana.js frames from the classic GIF."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections import deque
7
+ from pathlib import Path
8
+
9
+ from PIL import Image
10
+
11
+ OUT = Path(__file__).resolve().parents[1] / 'dancing-banana.js'
12
+ BG = (255, 255, 255)
13
+ TARGET_W = 24
14
+
15
+ CHAR = {
16
+ (0, 0, 0): 'B',
17
+ (255, 255, 0): 'Y',
18
+ (206, 206, 0): 'y',
19
+ (156, 156, 0): 'd',
20
+ (255, 0, 0): 'R',
21
+ (255, 255, 255): 'W',
22
+ }
23
+
24
+ JS_TEMPLATE = '''\
25
+ // Extracted from the classic dancing banana GIF (Trym Stene, 1999).
26
+ // 8 frames, {w}×{h}px — fixed canvas, solid 2-space blocks.
27
+
28
+ const RESET = '\\x1b[0m'
29
+ const FRAME_WIDTH = {w}
30
+ const FRAME_HEIGHT = {h}
31
+
32
+ const COLORS = {{
33
+ B: 236,
34
+ R: 196,
35
+ W: 255,
36
+ E: 255,
37
+ Y: 226,
38
+ d: 136,
39
+ y: 214,
40
+ }}
41
+
42
+ const FRAMES = [
43
+ {frames}
44
+ ]
45
+
46
+ function renderRow(row) {{
47
+ const padded = row.padEnd(FRAME_WIDTH, '.').slice(0, FRAME_WIDTH)
48
+ let line = ''
49
+ for (const cell of padded) {{
50
+ if (cell === '.') {{
51
+ line += ' '
52
+ continue
53
+ }}
54
+ const code = COLORS[cell]
55
+ if (code === undefined) {{
56
+ line += ' '
57
+ continue
58
+ }}
59
+ const bold = cell === 'E' ? '\\x1b[1m' : ''
60
+ line += `${{bold}}\\x1b[48;5;${{code}}m ${{RESET}}`
61
+ }}
62
+ return line
63
+ }}
64
+
65
+ export function getBananaFrame(index) {{
66
+ const frame = FRAMES[index % FRAMES.length]
67
+ const lines = frame.map(renderRow)
68
+ while (lines.length < FRAME_HEIGHT) {{
69
+ lines.push(' '.repeat(FRAME_WIDTH * 2))
70
+ }}
71
+ return lines.slice(0, FRAME_HEIGHT).join('\\n')
72
+ }}
73
+
74
+ export function getBananaFrameCount() {{
75
+ return FRAMES.length
76
+ }}
77
+ '''
78
+
79
+
80
+ def remove_bg(frame: Image.Image) -> Image.Image:
81
+ px = frame.load()
82
+ w, h = frame.size
83
+ seen = [[False] * w for _ in range(h)]
84
+ q: deque[tuple[int, int]] = deque()
85
+
86
+ for x in range(w):
87
+ for y in (0, h - 1):
88
+ if px[x, y][:3] == BG:
89
+ q.append((x, y))
90
+ seen[y][x] = True
91
+
92
+ for y in range(h):
93
+ for x in (0, w - 1):
94
+ if not seen[y][x] and px[x, y][:3] == BG:
95
+ q.append((x, y))
96
+ seen[y][x] = True
97
+
98
+ while q:
99
+ x, y = q.popleft()
100
+ px[x, y] = (255, 255, 255, 0)
101
+ for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
102
+ if 0 <= nx < w and 0 <= ny < h and not seen[ny][nx] and px[nx, ny][:3] == BG:
103
+ seen[ny][nx] = True
104
+ q.append((nx, ny))
105
+
106
+ return frame
107
+
108
+
109
+ def color_key(px: tuple[int, int, int, int]) -> str:
110
+ if px[3] < 128:
111
+ return '.'
112
+ rgb = px[:3]
113
+ if rgb in CHAR:
114
+ return CHAR[rgb]
115
+ nearest = min(CHAR, key=lambda c: sum((a - b) ** 2 for a, b in zip(rgb, c)))
116
+ return CHAR[nearest]
117
+
118
+
119
+ def trim_frames(frames: list[list[str]]) -> tuple[int, int, list[list[str]]]:
120
+ h = len(frames[0])
121
+ w = len(frames[0][0])
122
+
123
+ while h and all(frames[f][0][c] == '.' for f in range(len(frames)) for c in range(w)):
124
+ for f in range(len(frames)):
125
+ frames[f].pop(0)
126
+ h -= 1
127
+
128
+ while h and all(frames[f][h - 1][c] == '.' for f in range(len(frames)) for c in range(w)):
129
+ for f in range(len(frames)):
130
+ frames[f].pop()
131
+ h -= 1
132
+
133
+ while w and all(frames[f][r][0] == '.' for f in range(len(frames)) for r in range(h)):
134
+ for f in range(len(frames)):
135
+ frames[f] = [row[1:] for row in frames[f]]
136
+ w -= 1
137
+
138
+ while w and all(frames[f][r][w - 1] == '.' for f in range(len(frames)) for r in range(h)):
139
+ for f in range(len(frames)):
140
+ frames[f] = [row[:-1] for row in frames[f]]
141
+ w -= 1
142
+
143
+ return w, h, frames
144
+
145
+
146
+ def content_bounds(frames: list[list[str]]) -> tuple[int, int, int, int]:
147
+ top = len(frames[0])
148
+ bottom = 0
149
+ left = len(frames[0][0])
150
+ right = 0
151
+
152
+ for frame in frames:
153
+ for y, row in enumerate(frame):
154
+ for x, cell in enumerate(row):
155
+ if cell != '.':
156
+ top = min(top, y)
157
+ bottom = max(bottom, y)
158
+ left = min(left, x)
159
+ right = max(right, x)
160
+
161
+ return top, bottom, left, right
162
+
163
+
164
+ def normalize_canvas(frames: list[list[str]]) -> tuple[int, int, list[list[str]]]:
165
+ top, bottom, left, right = content_bounds(frames)
166
+ canvas_h = bottom - top + 1
167
+ canvas_w = right - left + 1
168
+
169
+ normalized: list[list[str]] = []
170
+ for frame in frames:
171
+ rows = [row[left : right + 1] for row in frame[top : bottom + 1]]
172
+ normalized.append([row.ljust(canvas_w, '.')[:canvas_w] for row in rows])
173
+
174
+ return canvas_w, canvas_h, normalized
175
+
176
+
177
+ def enhance_eyes(frames: list[list[str]]) -> list[list[str]]:
178
+ h = len(frames[0])
179
+ w = len(frames[0][0])
180
+ out: list[list[list[str]]] = [[list(row) for row in frame] for frame in frames]
181
+
182
+ for grid in out:
183
+ for y in range(h):
184
+ for x in range(w):
185
+ if grid[y][x] != 'W':
186
+ continue
187
+ if y < int(h * 0.42) or y > int(h * 0.75):
188
+ continue
189
+ neighbors = []
190
+ for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
191
+ if 0 <= nx < w and 0 <= ny < h:
192
+ neighbors.append(grid[ny][nx])
193
+ if any(n in 'YyBR' for n in neighbors):
194
+ grid[y][x] = 'E'
195
+
196
+ return [[''.join(row) for row in grid] for grid in out]
197
+
198
+
199
+ def main() -> None:
200
+ gif_path = Path(
201
+ '/Users/Arkasha/.cursor/projects/Volumes-Work-ITCase-itcase-ui/assets/'
202
+ 'dancing-banana-gif-809950cd-831b-4c1e-8547-2d3612956da9.gif',
203
+ )
204
+ img = Image.open(gif_path)
205
+
206
+ minx, miny, maxx, maxy = 9999, 9999, 0, 0
207
+ frames_raw: list[Image.Image] = []
208
+
209
+ for i in range(img.n_frames):
210
+ img.seek(i)
211
+ frame = remove_bg(img.convert('RGBA'))
212
+ frames_raw.append(frame)
213
+ fw, fh = frame.size
214
+ for y in range(fh):
215
+ for x in range(fw):
216
+ if frame.getpixel((x, y))[3] >= 128:
217
+ minx = min(minx, x)
218
+ miny = min(miny, y)
219
+ maxx = max(maxx, x)
220
+ maxy = max(maxy, y)
221
+
222
+ crop_h = maxy - miny + 1
223
+ target_h = max(8, round(crop_h * TARGET_W / (maxx - minx + 1)))
224
+
225
+ frames: list[list[str]] = []
226
+ for frame in frames_raw:
227
+ cropped = frame.crop((minx, miny, maxx + 1, maxy + 1))
228
+ small = cropped.resize((TARGET_W, target_h), Image.NEAREST)
229
+ rows = []
230
+ for y in range(target_h):
231
+ rows.append(''.join(color_key(small.getpixel((x, y))) for x in range(TARGET_W)))
232
+ frames.append(rows)
233
+
234
+ w, h, frames = trim_frames(frames)
235
+ w, h, frames = normalize_canvas(frames)
236
+ frames = enhance_eyes(frames)
237
+
238
+ frame_blocks = []
239
+ for frame in frames:
240
+ frame_blocks.append(' [')
241
+ for row in frame:
242
+ frame_blocks.append(f" '{row}',")
243
+ frame_blocks.append(' ],')
244
+
245
+ OUT.write_text(
246
+ JS_TEMPLATE.format(
247
+ w=w,
248
+ h=h,
249
+ frames='\n'.join(frame_blocks),
250
+ ),
251
+ )
252
+
253
+
254
+ if __name__ == '__main__':
255
+ main()