@itcase/config 1.6.71 → 1.6.72

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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itcase/config",
3
- "version": "1.6.71",
3
+ "version": "1.6.72",
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,520 @@
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 { logUpdateStderr } from 'log-update'
10
+
11
+ import { buildScopeEnum } from '../commitlint/commitlintCommon.js'
12
+ import { getBananaFrame } from './dancing-banana.js'
13
+
14
+ const PACKAGE_NAME = '@itcase/config'
15
+ const CLI_RELATIVE = 'smart-commit/cli.js'
16
+
17
+ const MAX_DIFF = 80_000
18
+ const AGENT_TIMEOUT_MS = 120_000
19
+ const AGENT_STEP_MS = 2_500
20
+ const BANANA_FRAME_MS = 100
21
+ const AGENT_WAIT_STEPS = [
22
+ 'Asking Cursor Agent',
23
+ 'Still thinking',
24
+ 'Waiting for response',
25
+ ]
26
+ const HELP = `smart-commit [--dry-run] [--yes|-y] [--type t] [--model id] [--verbose]
27
+
28
+ Cursor Agent → commit messages by SCOPES → [y/N] → git commit
29
+ Run via: npm exec smart-commit (from project with @itcase/config)
30
+ Needs: agent login (or CURSOR_API_KEY) + commitlint.config.* with SCOPES
31
+ `
32
+ const AGENT_MISSING = `Cursor Agent CLI not found — smart-commit cannot run.
33
+
34
+ curl https://cursor.com/install -fsS | bash
35
+ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
36
+ source ~/.bashrc
37
+ agent --version
38
+ agent login
39
+
40
+ If agent is still missing, see README.md → Smart commit.
41
+ Or set CURSOR_API_KEY instead of login.
42
+ `
43
+
44
+ const argv = process.argv.slice(2)
45
+ if (argv.includes('-h') || argv.includes('--help')) {
46
+ process.stdout.write(HELP)
47
+ process.exit(0)
48
+ }
49
+
50
+ main(argv).catch((error) => {
51
+ process.stderr.write(`${error.message || error}\n`)
52
+ process.exit(1)
53
+ })
54
+
55
+ async function main(argv) {
56
+ const option = parseArgs(argv)
57
+ const cwd = process.cwd()
58
+ assertItcaseConfigPackage(cwd)
59
+ const { scopes, types } = await loadCommitlintConfig(cwd)
60
+ const files = listFiles(cwd, option)
61
+ if (!files.length) {
62
+ process.stdout.write('No changes to commit.\n')
63
+ return
64
+ }
65
+
66
+ const prompt = buildPrompt(scopes, types, files, cwd, option.type)
67
+ const raw = await withAgentProgress(
68
+ { fileCount: files.length, verbose: option.verbose },
69
+ (progress) =>
70
+ askAgent(
71
+ cwd,
72
+ option.model,
73
+ prompt,
74
+ option.verbose,
75
+ progress.onAgentActivity,
76
+ ),
77
+ )
78
+ const plans = parsePlan(raw, files)
79
+
80
+ process.stdout.write('\nCommits:\n')
81
+ for (const plan of plans) process.stdout.write(` • ${plan.message}\n`)
82
+ process.stdout.write('\n')
83
+
84
+ if (option.dryRun) {
85
+ process.stdout.write('Dry run — nothing committed.\n')
86
+ return
87
+ }
88
+ if (!option.yes && !(await confirm(plans.length))) {
89
+ process.stdout.write('Aborted.\n')
90
+ return
91
+ }
92
+
93
+ for (const plan of plans) {
94
+ git(cwd, ['add', '--', ...plan.files])
95
+ const commit = spawnSync('git', ['commit', '-m', plan.message], {
96
+ cwd: cwd,
97
+ encoding: 'utf8',
98
+ stdio: ['ignore', 'inherit', 'inherit'],
99
+ })
100
+ if (commit.status) throw new Error(`git commit failed: ${plan.message}`)
101
+ }
102
+ process.stdout.write(`Created ${plans.length} commit(s).\n`)
103
+ }
104
+
105
+ function parseArgs(argv) {
106
+ const option = {
107
+ type: '',
108
+ dryRun: false,
109
+ model: '',
110
+ staged: false,
111
+ verbose: false,
112
+ yes: false,
113
+ }
114
+ for (let i = 0; i < argv.length; i++) {
115
+ const arg = argv[i]
116
+ if (arg === '--dry-run') option.dryRun = true
117
+ else if (arg === '--yes' || arg === '-y') option.yes = true
118
+ else if (arg === '--verbose') option.verbose = true
119
+ else if (arg === '--staged') option.staged = true
120
+ else if (arg === '--type') option.type = argv[++i] || ''
121
+ else if (arg === '--model') option.model = argv[++i] || ''
122
+ else if (arg.startsWith('--type=')) option.type = arg.slice(7)
123
+ else if (arg.startsWith('--model=')) option.model = arg.slice(8)
124
+ else throw new Error(`Unknown argument: ${arg}`)
125
+ }
126
+ return option
127
+ }
128
+
129
+ function assertItcaseConfigPackage(cwd) {
130
+ const scriptPath = fileURLToPath(import.meta.url)
131
+
132
+ if (
133
+ scriptPath.includes(
134
+ `${path.sep}itcase-config${path.sep}smart-commit${path.sep}`,
135
+ )
136
+ ) {
137
+ return
138
+ }
139
+
140
+ const configSegment = `${path.sep}@itcase${path.sep}config${path.sep}`
141
+ if (!scriptPath.includes(configSegment)) {
142
+ throw wrongPackageError(cwd)
143
+ }
144
+
145
+ const localCli = path.join(
146
+ cwd,
147
+ 'node_modules',
148
+ '@itcase',
149
+ 'config',
150
+ CLI_RELATIVE,
151
+ )
152
+ if (!fs.existsSync(localCli)) {
153
+ throw new Error(
154
+ `@itcase/config not installed in ${cwd}.\n` +
155
+ ' npm install --save-dev @itcase/config\n' +
156
+ 'Then run: npm exec smart-commit',
157
+ )
158
+ }
159
+
160
+ const resolvedSelf = fs.realpathSync(scriptPath)
161
+ const resolvedLocal = fs.realpathSync(localCli)
162
+ if (resolvedSelf !== resolvedLocal) {
163
+ throw wrongPackageError(cwd)
164
+ }
165
+
166
+ const packageJsonPath = path.join(
167
+ cwd,
168
+ 'node_modules',
169
+ '@itcase',
170
+ 'config',
171
+ 'package.json',
172
+ )
173
+ if (fs.existsSync(packageJsonPath)) {
174
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
175
+ if (packageJson.name !== PACKAGE_NAME) {
176
+ throw wrongPackageError(cwd)
177
+ }
178
+ }
179
+ }
180
+
181
+ function wrongPackageError(cwd) {
182
+ return new Error(
183
+ `Wrong smart-commit — use @itcase/config from ${cwd}, not npm registry package.\n` +
184
+ ' npm install --save-dev @itcase/config\n' +
185
+ ' npm exec smart-commit --dry-run\n' +
186
+ 'Avoid: npx smart-commit (may install unrelated package from npm).',
187
+ )
188
+ }
189
+
190
+ async function loadCommitlintConfig(cwd) {
191
+ exportReleaseBranchEnv(cwd)
192
+
193
+ const file = [
194
+ 'commitlint.config.mjs',
195
+ 'commitlint.config.js',
196
+ 'commitlint.config.cjs',
197
+ ]
198
+ .map((name) => path.join(cwd, name))
199
+ .find((candidate) => fs.existsSync(candidate))
200
+ if (!file) throw new Error(`commitlint.config.* not found in ${cwd}`)
201
+
202
+ const config = (await import(pathToFileURL(file).href)).default
203
+ const scopes = config?.SCOPES
204
+ if (!scopes) {
205
+ throw new Error(
206
+ 'SCOPES missing — use createCommitlintProjectConfig(SCOPES)',
207
+ )
208
+ }
209
+
210
+ const types = config.rules?.['type-enum']?.[2]
211
+ if (!Array.isArray(types) || !types.length) {
212
+ throw new Error('type-enum missing in commitlint.config.*')
213
+ }
214
+
215
+ return { scopes, types }
216
+ }
217
+
218
+ function exportReleaseBranchEnv(cwd) {
219
+ if (process.env.RELEASE_BRANCH === 'true') return
220
+
221
+ const branch = out(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
222
+ if (!branch) return
223
+
224
+ const releaseBranches = readReleaseBranches(cwd)
225
+ if (releaseBranches.includes(branch)) {
226
+ process.env.RELEASE_BRANCH = 'true'
227
+ }
228
+ }
229
+
230
+ function readReleaseBranches(cwd) {
231
+ const configPath = path.join(cwd, '.husky', 'config')
232
+ if (!fs.existsSync(configPath)) return ['master']
233
+
234
+ const text = fs.readFileSync(configPath, 'utf8')
235
+ const match = text.match(/RELEASE_BRANCHES="([^"]+)"/)
236
+ if (!match) return ['master']
237
+
238
+ return match[1].split(/\s+/).filter(Boolean)
239
+ }
240
+
241
+ function listFiles(cwd, option) {
242
+ const staged = lines(cwd, [
243
+ 'diff',
244
+ '--cached',
245
+ '--name-only',
246
+ '--diff-filter=ACDMR',
247
+ ])
248
+ if (option.staged || staged.length) return staged
249
+ return [
250
+ ...new Set([
251
+ ...lines(cwd, ['diff', '--name-only', '--diff-filter=ACDMR']),
252
+ ...lines(cwd, ['ls-files', '--others', '--exclude-standard']),
253
+ ]),
254
+ ]
255
+ }
256
+
257
+ function buildPrompt(scopes, types, files, cwd, type) {
258
+ let diff = [
259
+ out(cwd, ['diff', '--cached', '--', ...files]),
260
+ out(cwd, ['diff', '--', ...files]),
261
+ ]
262
+ .filter(Boolean)
263
+ .join('\n\n')
264
+ if (diff.length > MAX_DIFF) diff = `${diff.slice(0, MAX_DIFF)}\n…truncated`
265
+
266
+ return `Plan git commits. Do not edit files or run git commit.
267
+ Return ONLY JSON: {"commits":[{"message":"type(scope): subject","files":["a.ts"]}]}
268
+ Types: ${types.join(', ')}
269
+ Scopes (exact): ${buildScopeEnum(scopes).join(', ')}
270
+ Subject: short, imperative, lower-case. Single line only — no body, footer, or Co-authored-by.
271
+ Split by scope. Each listed file in exactly one commit.
272
+ Do not add Co-authored-by or Made-with trailers.
273
+ ${type ? `Prefer type "${type}".\n` : ''}SCOPES: ${JSON.stringify(scopes)}
274
+ Files:
275
+ ${files.map((file) => `- ${file}`).join('\n')}
276
+ status:
277
+ ${out(cwd, ['status', '--short', '--', ...files]) || '(empty)'}
278
+ diff:
279
+ ${diff || '(none)'}
280
+ `
281
+ }
282
+
283
+ function buildAgentProgressSteps(fileCount) {
284
+ const fileLabel =
285
+ fileCount === 1 ? '1 changed file' : `${fileCount} changed files`
286
+
287
+ return [
288
+ `Reading ${fileLabel}`,
289
+ 'Loading commitlint scopes and types',
290
+ 'Analyzing diff',
291
+ 'Grouping changes by scope',
292
+ 'Drafting commit messages',
293
+ ]
294
+ }
295
+
296
+ async function withAgentProgress(context, callback) {
297
+ const earlySteps = buildAgentProgressSteps(context.fileCount)
298
+ const startedAt = Date.now()
299
+ let earlyStepIndex = 0
300
+ let waitStepIndex = 0
301
+ let phase = 'early'
302
+ let receivedChars = 0
303
+ let frameIndex = 0
304
+
305
+ const onAgentActivity = (charCount) => {
306
+ if (charCount > 0) {
307
+ receivedChars = charCount
308
+ phase = 'receiving'
309
+ }
310
+ }
311
+
312
+ const currentLabel = () => {
313
+ if (phase === 'receiving') {
314
+ return `Receiving response (${receivedChars} chars)`
315
+ }
316
+ if (phase === 'waiting') {
317
+ return AGENT_WAIT_STEPS[waitStepIndex % AGENT_WAIT_STEPS.length]
318
+ }
319
+ return earlySteps[Math.min(earlyStepIndex, earlySteps.length - 1)]
320
+ }
321
+
322
+ const formatText = () => {
323
+ const elapsed = Math.floor((Date.now() - startedAt) / 1000)
324
+ return `${currentLabel()}… (${elapsed}s)`
325
+ }
326
+
327
+ if (!process.stderr.isTTY || context.verbose) {
328
+ process.stderr.write(`${formatText()}\n`)
329
+ return callback({ onAgentActivity })
330
+ }
331
+
332
+ const render = () => {
333
+ logUpdateStderr(`${getBananaFrame(frameIndex)}\n${formatText()}`)
334
+ frameIndex++
335
+ }
336
+
337
+ render()
338
+ const frameTimer = setInterval(render, BANANA_FRAME_MS)
339
+ const stepTimer = setInterval(() => {
340
+ if (phase === 'early') {
341
+ if (earlyStepIndex < earlySteps.length - 1) {
342
+ earlyStepIndex++
343
+ } else {
344
+ phase = 'waiting'
345
+ }
346
+ } else if (phase === 'waiting') {
347
+ waitStepIndex++
348
+ }
349
+ }, AGENT_STEP_MS)
350
+
351
+ try {
352
+ return await callback({ onAgentActivity })
353
+ } finally {
354
+ clearInterval(frameTimer)
355
+ clearInterval(stepTimer)
356
+ logUpdateStderr.clear()
357
+ }
358
+ }
359
+
360
+ function askAgent(cwd, model, prompt, verbose, onAgentActivity) {
361
+ const cmd = resolveAgent()
362
+ const args = [
363
+ '-p',
364
+ '--mode',
365
+ 'ask',
366
+ '--output-format',
367
+ 'text',
368
+ '--trust',
369
+ '--workspace',
370
+ cwd,
371
+ ]
372
+ if (model) args.push('--model', model)
373
+ if (process.env.CURSOR_API_KEY)
374
+ args.push('--api-key', process.env.CURSOR_API_KEY)
375
+ args.push(prompt)
376
+
377
+ if (verbose) process.stderr.write(`Using: ${cmd}\n`)
378
+
379
+ return new Promise((resolve, reject) => {
380
+ const child = spawn(cmd, args, {
381
+ cwd: cwd,
382
+ stdio: ['ignore', 'pipe', 'pipe'],
383
+ })
384
+
385
+ let stdout = ''
386
+ let stderr = ''
387
+
388
+ child.stdout.on('data', (chunk) => {
389
+ stdout += chunk
390
+ if (onAgentActivity) onAgentActivity(stdout.length)
391
+ if (verbose) process.stderr.write(chunk)
392
+ })
393
+ child.stderr.on('data', (chunk) => {
394
+ stderr += chunk
395
+ if (verbose) process.stderr.write(chunk)
396
+ })
397
+
398
+ const timeout = setTimeout(() => {
399
+ child.kill('SIGTERM')
400
+ reject(
401
+ new Error(
402
+ `Cursor Agent timed out after ${AGENT_TIMEOUT_MS / 1000}s. Check agent login / network.`,
403
+ ),
404
+ )
405
+ }, AGENT_TIMEOUT_MS)
406
+
407
+ child.on('error', (error) => {
408
+ clearTimeout(timeout)
409
+ reject(error)
410
+ })
411
+
412
+ child.on('close', (code) => {
413
+ clearTimeout(timeout)
414
+ if (code) {
415
+ reject(new Error(formatAgentError(stderr, stdout)))
416
+ return
417
+ }
418
+ resolve(stdout.trim())
419
+ })
420
+ })
421
+ }
422
+
423
+ function formatAgentError(stderr, stdout) {
424
+ const text = (stderr || stdout || AGENT_MISSING).trim()
425
+ if (/authentication required/i.test(text)) {
426
+ return `Cursor Agent failed.\n${text}\n\nRun: agent login\nOr set CURSOR_API_KEY.`
427
+ }
428
+ return `Cursor Agent failed.\n${text}`
429
+ }
430
+
431
+ function resolveAgent() {
432
+ const home = process.env.HOME || ''
433
+ const candidates = [
434
+ which('agent'),
435
+ which('cursor-agent'),
436
+ `${home}/.local/bin/agent`,
437
+ `${home}/.local/bin/cursor-agent`,
438
+ `${home}/Library/Application Support/Cursor/User/globalStorage/anysphere.cursor-agent-worker/agent-cli/.local/bin/cursor-agent`,
439
+ ]
440
+ const found = candidates.find(
441
+ (candidate) => candidate && fs.existsSync(candidate),
442
+ )
443
+ if (!found) throw new Error(AGENT_MISSING)
444
+ return found
445
+ }
446
+
447
+ function which(name) {
448
+ const result = spawnSync('which', [name], { encoding: 'utf8' })
449
+ return result.status === 0 ? result.stdout.trim() : ''
450
+ }
451
+
452
+ function parsePlan(raw, allowedFiles) {
453
+ const allowed = new Set(allowedFiles)
454
+ const body = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1] || raw
455
+ const start = body.indexOf('{')
456
+ const end = body.lastIndexOf('}')
457
+ if (start < 0 || end <= start)
458
+ throw new Error(`No JSON in Cursor output:\n${raw}`)
459
+
460
+ let data
461
+ try {
462
+ data = JSON.parse(body.slice(start, end + 1))
463
+ } catch (error) {
464
+ throw new Error(`Bad JSON from Cursor:\n${raw}`, { cause: error })
465
+ }
466
+
467
+ const seen = new Set()
468
+ const plans = []
469
+ for (const item of data.commits || []) {
470
+ const message = normalizeCommitMessage(item.message)
471
+ const files = (item.files || [])
472
+ .map(String)
473
+ .filter((file) => allowed.has(file) && !seen.has(file))
474
+ files.forEach((file) => seen.add(file))
475
+ if (message && files.length) plans.push({ message, files })
476
+ }
477
+
478
+ if (!plans.length) throw new Error(`Empty commit plan:\n${raw}`)
479
+ const missing = allowedFiles.filter((file) => !seen.has(file))
480
+ if (missing.length)
481
+ throw new Error(`Cursor omitted files:\n${missing.join('\n')}`)
482
+ return plans
483
+ }
484
+
485
+ function normalizeCommitMessage(message) {
486
+ const lines = String(message || '')
487
+ .split('\n')
488
+ .map((line) => line.trim())
489
+ .filter(Boolean)
490
+ .filter((line) => !/^Co-authored-by:/i.test(line))
491
+ .filter((line) => !/^Made-with:/i.test(line))
492
+
493
+ const subject = (lines[0] || '').replace(/\s+Co-authored-by:.*$/i, '').trim()
494
+ return subject
495
+ }
496
+
497
+ async function confirm(count) {
498
+ if (!input.isTTY) throw new Error('No TTY — use --yes or --dry-run')
499
+ const rl = readline.createInterface({ input, output })
500
+ try {
501
+ const answer = await rl.question(`Create ${count} commit(s)? [y/N] `)
502
+ return /^y(es)?$/i.test(answer.trim())
503
+ } finally {
504
+ rl.close()
505
+ }
506
+ }
507
+
508
+ function git(cwd, args) {
509
+ const result = spawnSync('git', args, { cwd: cwd, encoding: 'utf8' })
510
+ if (result.status) throw new Error(result.stderr || `git ${args.join(' ')}`)
511
+ return result
512
+ }
513
+
514
+ function out(cwd, args) {
515
+ return (git(cwd, args).stdout || '').trim()
516
+ }
517
+
518
+ function lines(cwd, args) {
519
+ return out(cwd, args).split('\n').filter(Boolean)
520
+ }
@@ -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()