@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 +67 -0
- package/package.json +7 -3
- package/smart-commit/cli.js +520 -0
- package/smart-commit/dancing-banana.js +267 -0
- package/smart-commit/scripts/extract-banana.py +255 -0
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.
|
|
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()
|