gent-cli 11.0.0 → 13.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/commands/merge.js +3 -0
- package/src/commands/pet.js +500 -0
- package/src/commands/pull.js +3 -0
- package/src/commands/push.js +3 -0
- package/src/index.js +10 -0
package/README.md
CHANGED
|
@@ -25,6 +25,7 @@ Beyond a faithful git-like workflow, Gent adds:
|
|
|
25
25
|
- **`gent resolve`** — an interactive conflict resolver (ours / theirs / both / edit / AI).
|
|
26
26
|
- **`gent summary`** — a repository health dashboard, plus **`gent log --graph`**.
|
|
27
27
|
- **Optional AI** (`gent commit --ai`, `gent explain`, `gent summary --ai`, AI option in `gent resolve`) — off by default, enabled with `ANTHROPIC_API_KEY`.
|
|
28
|
+
- **Genti, your terminal mascot** — a chunky pixel bot that *acts out* your workflow: it carries a file crate to the cloud on `gent push`, walks one home on `gent pull`, and reconciles two branches on `gent merge`. It plays once (in place, no scrollback spam) after a successful command. Meet it directly with `gent pet` (add `--loop` to keep it running; try `gent pet push|pull|merge|auth`). Set `GENT_NO_PET=1` (or run in CI / a non-interactive shell) to turn the celebrations off.
|
|
28
29
|
|
|
29
30
|
See [docs/COMMANDS.md](docs/COMMANDS.md) for the full reference and
|
|
30
31
|
[docs/ALGORITHMS.md](docs/ALGORITHMS.md) for how the engines work.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gent-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "13.0.0",
|
|
4
4
|
"description": "A modern, Git-like version control CLI with cloud sync, AI-powered superpowers (ask/review/docs/changelog), and zero-friction setup (gent setup/doctor/config).",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
package/src/commands/merge.js
CHANGED
|
@@ -13,6 +13,7 @@ const { generateCommitHash } = require('../utils/helpers');
|
|
|
13
13
|
const authStorage = require('../utils/auth-storage');
|
|
14
14
|
const { findMergeBase, mergeTreeEntries, autoMerge } = require('../utils/merge-engine');
|
|
15
15
|
const { storeTree, readBlobAsString, storeBlob } = require('../utils/hash-engine');
|
|
16
|
+
const pet = require('./pet');
|
|
16
17
|
const journal = require('../utils/journal');
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -78,6 +79,7 @@ async function merge(sourceBranch, options) {
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
spinner.succeed(chalk.green(`Fast-forward merge: ${currentBranch} → ${theirsHash.substring(0, 7)}`));
|
|
82
|
+
await pet.celebrate('merge');
|
|
81
83
|
return;
|
|
82
84
|
}
|
|
83
85
|
|
|
@@ -185,6 +187,7 @@ async function merge(sourceBranch, options) {
|
|
|
185
187
|
console.log(chalk.gray(`\n Base: ${baseHash ? baseHash.substring(0, 7) : 'none'}`));
|
|
186
188
|
console.log(chalk.gray(` Ours: ${oursHash.substring(0, 7)} Theirs: ${theirsHash.substring(0, 7)}`));
|
|
187
189
|
console.log(chalk.green(` ${autoResolved} file(s) merged automatically`));
|
|
190
|
+
await pet.celebrate('merge');
|
|
188
191
|
} else {
|
|
189
192
|
// Stage the merge state for manual resolution
|
|
190
193
|
const staging = await readJSON(path.join(gentPath, STAGING_FILE));
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pet Command — "Genti", the gent mascot.
|
|
3
|
+
*
|
|
4
|
+
* A chunky pixel-block creature that lives in your terminal and *acts out*
|
|
5
|
+
* gent workflows:
|
|
6
|
+
*
|
|
7
|
+
* gent pet → idle: Genti breathes, blinks, waves, drops a tip
|
|
8
|
+
* gent pet push → walks a file crate to the cloud, over and over
|
|
9
|
+
* gent pet pull → carries a crate back from the cloud
|
|
10
|
+
* gent pet merge → stands between two branches and "thinks" them together
|
|
11
|
+
* gent pet auth → little sign-in scene
|
|
12
|
+
*
|
|
13
|
+
* --once play a few cycles, then exit (good for scripts / shell startup)
|
|
14
|
+
* --no-color plain output (also honors NO_COLOR)
|
|
15
|
+
*
|
|
16
|
+
* Rendering: everything is drawn onto a colored character Canvas (one cell =
|
|
17
|
+
* one terminal column with its own color), then flushed as ANSI each frame.
|
|
18
|
+
* This keeps every sprite pixel-aligned regardless of color runs.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const chalk = require('chalk');
|
|
22
|
+
const authStorage = require('../utils/auth-storage');
|
|
23
|
+
|
|
24
|
+
const FRAME_MS = 90;
|
|
25
|
+
|
|
26
|
+
// ── Palette ──────────────────────────────────────────────────────────────
|
|
27
|
+
const C = {
|
|
28
|
+
body: chalk.hex('#c97b5a'), // Genti's orange skin
|
|
29
|
+
shade: chalk.hex('#9c5a3f'), // bottom shading
|
|
30
|
+
eye: chalk.hex('#15110f'), // dark eye holes
|
|
31
|
+
mouth: chalk.hex('#5a2f22'),
|
|
32
|
+
crate: chalk.hex('#e0b64d'), // file crate edges
|
|
33
|
+
crateIn:chalk.hex('#b98a1f'), // crate fill
|
|
34
|
+
cloud: chalk.hex('#d6def0'), // remote / cloud
|
|
35
|
+
cloudSh:chalk.hex('#8a93ad'),
|
|
36
|
+
spark: chalk.hex('#ffe08a'),
|
|
37
|
+
branchA:chalk.hex('#5ac8c9'),
|
|
38
|
+
branchB:chalk.hex('#c98ad6'),
|
|
39
|
+
node: chalk.hex('#8ae06a'),
|
|
40
|
+
ok: chalk.hex('#8ae06a'),
|
|
41
|
+
dim: chalk.gray,
|
|
42
|
+
say: chalk.hex('#e6e6e6'),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// legend char → { color fn, glyph }
|
|
46
|
+
const INK = {
|
|
47
|
+
'#': [C.body, '█'],
|
|
48
|
+
'@': [C.shade, '█'],
|
|
49
|
+
'O': [C.eye, '█'],
|
|
50
|
+
'_': [C.mouth, '▄'],
|
|
51
|
+
'o': [C.mouth, '▄'],
|
|
52
|
+
'=': [C.crate, '█'],
|
|
53
|
+
':': [C.crateIn, '▒'],
|
|
54
|
+
'%': [C.cloud, '█'],
|
|
55
|
+
'&': [C.cloudSh, '█'],
|
|
56
|
+
'*': [C.spark, '✦'],
|
|
57
|
+
'|': [C.branchA, '│'],
|
|
58
|
+
'/': [C.branchA, '╱'],
|
|
59
|
+
'A': [C.branchA, '●'],
|
|
60
|
+
'\\':[C.branchB, '╲'],
|
|
61
|
+
'B': [C.branchB, '●'],
|
|
62
|
+
'M': [C.node, '●'],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ── Canvas ───────────────────────────────────────────────────────────────
|
|
66
|
+
class Canvas {
|
|
67
|
+
constructor(w, h) {
|
|
68
|
+
this.w = w; this.h = h;
|
|
69
|
+
this.clear();
|
|
70
|
+
}
|
|
71
|
+
clear() {
|
|
72
|
+
this.cells = Array.from({ length: this.h }, () =>
|
|
73
|
+
Array.from({ length: this.w }, () => ({ ch: ' ', fn: null })));
|
|
74
|
+
}
|
|
75
|
+
put(x, y, ch, fn) {
|
|
76
|
+
if (y < 0 || y >= this.h || x < 0 || x >= this.w) return;
|
|
77
|
+
this.cells[y][x] = { ch, fn };
|
|
78
|
+
}
|
|
79
|
+
// Draw a sprite (array of strings). ' ' and '.' are transparent.
|
|
80
|
+
sprite(rows, x, y) {
|
|
81
|
+
rows.forEach((row, dy) => {
|
|
82
|
+
for (let dx = 0; dx < row.length; dx++) {
|
|
83
|
+
const c = row[dx];
|
|
84
|
+
if (c === ' ' || c === '.') continue;
|
|
85
|
+
const ink = INK[c];
|
|
86
|
+
if (ink) this.put(x + dx, y + dy, ink[1], ink[0]);
|
|
87
|
+
else this.put(x + dx, y + dy, c, C.say);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
text(x, y, str, fn) {
|
|
92
|
+
for (let i = 0; i < str.length; i++) this.put(x + i, y, str[i], fn);
|
|
93
|
+
}
|
|
94
|
+
render() {
|
|
95
|
+
const out = [];
|
|
96
|
+
for (let y = 0; y < this.h; y++) {
|
|
97
|
+
let line = '';
|
|
98
|
+
let run = '';
|
|
99
|
+
let runFn = null;
|
|
100
|
+
const flush = () => {
|
|
101
|
+
if (!run) return;
|
|
102
|
+
line += runFn ? runFn(run) : run;
|
|
103
|
+
run = '';
|
|
104
|
+
};
|
|
105
|
+
for (let x = 0; x < this.w; x++) {
|
|
106
|
+
const cell = this.cells[y][x];
|
|
107
|
+
if (cell.fn !== runFn) { flush(); runFn = cell.fn; }
|
|
108
|
+
run += cell.ch;
|
|
109
|
+
}
|
|
110
|
+
flush();
|
|
111
|
+
out.push(line);
|
|
112
|
+
}
|
|
113
|
+
return out.join('\n');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Mascot sprite ────────────────────────────────────────────────────────
|
|
118
|
+
// Body is a 12-wide block with a 1-col margin (transparent) each side.
|
|
119
|
+
function mascot({ blink = false, mouth = '_', armUp = false } = {}) {
|
|
120
|
+
const W = 12;
|
|
121
|
+
const rows = [];
|
|
122
|
+
rows[0] = ' ' + '#'.repeat(W) + ' ';
|
|
123
|
+
rows[1] = ' ' + '#'.repeat(W) + ' ';
|
|
124
|
+
rows[2] = ' ' + '#'.repeat(W) + ' '; // eyes row
|
|
125
|
+
rows[3] = ' ' + '#'.repeat(W) + ' ';
|
|
126
|
+
rows[4] = ' ' + '#'.repeat(W) + ' '; // mouth row
|
|
127
|
+
rows[5] = ' ' + '@'.repeat(W) + ' '; // shaded chin
|
|
128
|
+
|
|
129
|
+
const grid = rows.map(r => r.split(''));
|
|
130
|
+
const eye = blink ? '#' : 'O';
|
|
131
|
+
// eyes at body cols 3-4 and 8-9 → +1 for margin
|
|
132
|
+
[3, 4].forEach(c => grid[2][c + 1] = eye);
|
|
133
|
+
[8, 9].forEach(c => grid[2][c + 1] = eye);
|
|
134
|
+
// mouth at cols 5-8 (row4)
|
|
135
|
+
for (let c = 5; c <= 8; c++) grid[4][c + 1] = mouth;
|
|
136
|
+
// ears (stick out at row2)
|
|
137
|
+
grid[2][0] = '#';
|
|
138
|
+
grid[2][W + 1] = '#';
|
|
139
|
+
|
|
140
|
+
let body = grid.map(r => r.join(''));
|
|
141
|
+
|
|
142
|
+
// arm (raised wave) sits to the right of the head on row1
|
|
143
|
+
if (armUp) {
|
|
144
|
+
const r1 = body[1].split('');
|
|
145
|
+
r1[W + 1] = '#';
|
|
146
|
+
body[1] = r1.join('');
|
|
147
|
+
body.unshift(' #'); // tiny raised hand
|
|
148
|
+
} else {
|
|
149
|
+
body.unshift(' ');
|
|
150
|
+
}
|
|
151
|
+
return body; // 7 rows (incl. leading arm/space row), 14 wide
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Legs are separate so they can shuffle while the body glides.
|
|
155
|
+
function legs(step) {
|
|
156
|
+
// step: 'stand' | 'a' | 'b'
|
|
157
|
+
const map = {
|
|
158
|
+
stand: ' ## ## ',
|
|
159
|
+
a: ' ## # ',
|
|
160
|
+
b: ' # ## ',
|
|
161
|
+
};
|
|
162
|
+
return [map[step] || map.stand];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Props ────────────────────────────────────────────────────────────────
|
|
166
|
+
const CRATE = [
|
|
167
|
+
'======',
|
|
168
|
+
'=::::=',
|
|
169
|
+
'=::::=',
|
|
170
|
+
'======',
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
const CLOUD = [
|
|
174
|
+
' %%%%%% ',
|
|
175
|
+
'%%%%%%%%%%',
|
|
176
|
+
'&&%%%%%%&&',
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
// ── Speech bubble (drawn straight to canvas as text) ─────────────────────
|
|
180
|
+
function drawBubble(cv, line1, line2, color) {
|
|
181
|
+
const w = Math.max(line1.length, (line2 || '').length);
|
|
182
|
+
cv.text(2, 0, '╭' + '─'.repeat(w + 2) + '╮', C.dim);
|
|
183
|
+
cv.text(2, 1, '│ ', C.dim);
|
|
184
|
+
cv.text(4, 1, line1.padEnd(w), color || C.say);
|
|
185
|
+
cv.text(4 + w, 1, ' │', C.dim);
|
|
186
|
+
if (line2) {
|
|
187
|
+
cv.text(2, 2, '│ ', C.dim);
|
|
188
|
+
cv.text(4, 2, line2.padEnd(w), chalk.bold.white);
|
|
189
|
+
cv.text(4 + w, 2, ' │', C.dim);
|
|
190
|
+
cv.text(2, 3, '╰┬' + '─'.repeat(w) + '─╯', C.dim);
|
|
191
|
+
cv.text(4, 4, '╲', C.dim);
|
|
192
|
+
} else {
|
|
193
|
+
cv.text(2, 2, '╰┬' + '─'.repeat(w) + '─╯', C.dim);
|
|
194
|
+
cv.text(4, 3, '╲', C.dim);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Scenes ───────────────────────────────────────────────────────────────
|
|
199
|
+
// Every scene fills a fresh canvas for tick `t`. Stage lives below the bubble.
|
|
200
|
+
const STAGE_TOP = 5; // first row used by the world floor
|
|
201
|
+
const CV_W = 52;
|
|
202
|
+
const CV_H = 15;
|
|
203
|
+
|
|
204
|
+
function walkStep(t) { return (Math.floor(t / 3) % 2) ? 'a' : 'b'; }
|
|
205
|
+
|
|
206
|
+
function sceneIdle(cv, t, tip) {
|
|
207
|
+
drawBubble(cv, tip.say, tip.cmd ? '$ ' + tip.cmd : null, tip.color);
|
|
208
|
+
const bob = (Math.floor(t / 8) % 2); // gentle breathing
|
|
209
|
+
const blink = (t % 40) < 2;
|
|
210
|
+
const wave = (t % 60) < 12; // occasional wave
|
|
211
|
+
const y = STAGE_TOP + bob;
|
|
212
|
+
cv.sprite(mascot({ blink, armUp: wave }), 6, y);
|
|
213
|
+
cv.sprite(legs('stand'), 6, y + 7);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function sceneAuth(cv, t) {
|
|
217
|
+
const stages = [
|
|
218
|
+
['Knock knock — let me in!', 'gent login'],
|
|
219
|
+
['New around here?', 'gent register'],
|
|
220
|
+
['Who am I again?', 'gent whoami'],
|
|
221
|
+
];
|
|
222
|
+
const idx = Math.floor(t / 34) % stages.length;
|
|
223
|
+
const [say, cmd] = stages[idx];
|
|
224
|
+
drawBubble(cv, say, '$ ' + cmd, C.branchA);
|
|
225
|
+
const blink = (t % 30) < 2;
|
|
226
|
+
const y = STAGE_TOP + (Math.floor(t / 8) % 2);
|
|
227
|
+
// a little door/key on the right
|
|
228
|
+
cv.sprite(['%%%%', '%::%', '%::%', '%%%%'], 34, STAGE_TOP + 1);
|
|
229
|
+
cv.text(34, STAGE_TOP, ' remote', C.cloudSh);
|
|
230
|
+
cv.sprite(mascot({ blink, armUp: (t % 40) < 14 }), 6, y);
|
|
231
|
+
cv.sprite(legs('stand'), 6, y + 7);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// PUSH: walk crate right → toss into cloud → walk back. Repeat.
|
|
235
|
+
function scenePush(cv, t, state) {
|
|
236
|
+
drawBubble(cv, 'Pushing your commits to the cloud…', '$ gent push', C.ok);
|
|
237
|
+
const cloudX = 40, cloudY = STAGE_TOP;
|
|
238
|
+
cv.sprite(CLOUD, cloudX, cloudY);
|
|
239
|
+
cv.text(cloudX + 2, cloudY + 3, 'remote', C.cloudSh);
|
|
240
|
+
|
|
241
|
+
const CYCLE = 66;
|
|
242
|
+
const p = t % CYCLE;
|
|
243
|
+
const my = STAGE_TOP + 1;
|
|
244
|
+
|
|
245
|
+
const startX = 4, turnX = 30;
|
|
246
|
+
if (p < 26) {
|
|
247
|
+
// walk out carrying crate
|
|
248
|
+
const mx = startX + Math.round((turnX - startX) * (p / 26));
|
|
249
|
+
cv.sprite(mascot({ blink: (t % 22) < 2 }), mx, my);
|
|
250
|
+
cv.sprite(legs(walkStep(t)), mx, my + 7);
|
|
251
|
+
cv.sprite(CRATE, mx + 14, my + 2);
|
|
252
|
+
} else if (p < 34) {
|
|
253
|
+
// toss: crate flies up-right into the cloud, sparkle
|
|
254
|
+
const k = (p - 26) / 8;
|
|
255
|
+
const bx = Math.round(turnX + 14 + (cloudX - (turnX + 14)) * k);
|
|
256
|
+
const by = Math.round((my + 2) - 3 * k);
|
|
257
|
+
cv.sprite(mascot({ mouth: 'o', armUp: true }), turnX, my);
|
|
258
|
+
cv.sprite(legs('stand'), turnX, my + 7);
|
|
259
|
+
cv.sprite(CRATE, bx, by);
|
|
260
|
+
if (k > 0.6) cv.text(cloudX + 4, cloudY + 1, '*', C.spark);
|
|
261
|
+
} else if (p < 58) {
|
|
262
|
+
// walk back empty-handed
|
|
263
|
+
const mx = turnX - Math.round((turnX - startX) * ((p - 34) / 24));
|
|
264
|
+
cv.sprite(mascot({ blink: (t % 22) < 2 }), mx, my);
|
|
265
|
+
cv.sprite(legs(walkStep(t)), mx, my + 7);
|
|
266
|
+
} else {
|
|
267
|
+
// brief cheer + count up
|
|
268
|
+
if (p === 58) state.count++;
|
|
269
|
+
cv.sprite(mascot({ mouth: 'o', armUp: (p % 4 < 2) }), startX, my);
|
|
270
|
+
cv.sprite(legs('stand'), startX, my + 7);
|
|
271
|
+
cv.text(cloudX + 1, cloudY + 1, '* *', C.spark);
|
|
272
|
+
}
|
|
273
|
+
cv.text(2, CV_H - 1, `pushed ${state.count} crate${state.count === 1 ? '' : 's'} ✓`, C.ok);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// PULL: crate drops from cloud → Genti catches → carries it home.
|
|
277
|
+
function scenePull(cv, t, state) {
|
|
278
|
+
drawBubble(cv, 'Pulling the latest from remote…', '$ gent pull', C.branchA);
|
|
279
|
+
const cloudX = 40, cloudY = STAGE_TOP;
|
|
280
|
+
cv.sprite(CLOUD, cloudX, cloudY);
|
|
281
|
+
cv.text(cloudX + 2, cloudY + 3, 'remote', C.cloudSh);
|
|
282
|
+
|
|
283
|
+
const CYCLE = 64;
|
|
284
|
+
const p = t % CYCLE;
|
|
285
|
+
const my = STAGE_TOP + 1;
|
|
286
|
+
const homeX = 4, catchX = 30;
|
|
287
|
+
|
|
288
|
+
if (p < 10) {
|
|
289
|
+
// Genti walks out to meet the delivery
|
|
290
|
+
const mx = homeX + Math.round((catchX - homeX) * (p / 10));
|
|
291
|
+
cv.sprite(mascot({ blink: (t % 20) < 2 }), mx, my);
|
|
292
|
+
cv.sprite(legs(walkStep(t)), mx, my + 7);
|
|
293
|
+
} else if (p < 20) {
|
|
294
|
+
// crate falls from cloud toward Genti's hands
|
|
295
|
+
const k = (p - 10) / 10;
|
|
296
|
+
const bx = Math.round(cloudX - (cloudX - (catchX + 14)) * k);
|
|
297
|
+
const by = Math.round((cloudY + 2) + ((my + 2) - (cloudY + 2)) * k);
|
|
298
|
+
cv.sprite(mascot({ mouth: 'o', armUp: true }), catchX, my);
|
|
299
|
+
cv.sprite(legs('stand'), catchX, my + 7);
|
|
300
|
+
cv.sprite(CRATE, bx, by);
|
|
301
|
+
} else if (p < 46) {
|
|
302
|
+
// carry it home
|
|
303
|
+
const mx = catchX - Math.round((catchX - homeX) * ((p - 20) / 26));
|
|
304
|
+
cv.sprite(mascot({ blink: (t % 22) < 2 }), mx, my);
|
|
305
|
+
cv.sprite(legs(walkStep(t)), mx, my + 7);
|
|
306
|
+
cv.sprite(CRATE, mx + 14, my + 2);
|
|
307
|
+
} else {
|
|
308
|
+
if (p === 46) state.count++;
|
|
309
|
+
cv.sprite(mascot({ mouth: 'o' }), homeX, my);
|
|
310
|
+
cv.sprite(legs('stand'), homeX, my + 7);
|
|
311
|
+
cv.sprite(CRATE, homeX + 14, my + 2);
|
|
312
|
+
cv.text(homeX + 6, my - 1, '*', C.spark);
|
|
313
|
+
}
|
|
314
|
+
cv.text(2, CV_H - 1, `pulled ${state.count} crate${state.count === 1 ? '' : 's'} ✓`, C.branchA);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// MERGE: two branches converge; Genti thinks, then a merge node lights up.
|
|
318
|
+
function sceneMerge(cv, t, state) {
|
|
319
|
+
const CYCLE = 70;
|
|
320
|
+
const p = t % CYCLE;
|
|
321
|
+
const thinking = p < 40;
|
|
322
|
+
const dots = '.'.repeat(1 + (Math.floor(t / 5) % 3));
|
|
323
|
+
drawBubble(cv,
|
|
324
|
+
thinking ? `Reconciling two histories${dots}` : 'Merged cleanly — no conflicts!',
|
|
325
|
+
'$ gent merge feature',
|
|
326
|
+
thinking ? C.branchB : C.ok);
|
|
327
|
+
|
|
328
|
+
const my = STAGE_TOP + 1;
|
|
329
|
+
const baseY = my + 3;
|
|
330
|
+
// branch A (top) flows down-right, branch B (bottom) flows up-right, meet at node
|
|
331
|
+
const nodeX = 40, nodeY = baseY;
|
|
332
|
+
for (let x = 24; x < nodeX; x++) {
|
|
333
|
+
const ay = baseY - 3 + Math.round((x - 24) / (nodeX - 24) * 3);
|
|
334
|
+
const by = baseY + 3 - Math.round((x - 24) / (nodeX - 24) * 3);
|
|
335
|
+
cv.put(x, ay, '╲'.length ? '╲' : '\\', C.branchA);
|
|
336
|
+
cv.put(x, by, '╱', C.branchB);
|
|
337
|
+
}
|
|
338
|
+
cv.put(23, baseY - 3, '●', C.branchA);
|
|
339
|
+
cv.put(23, baseY + 3, '●', C.branchB);
|
|
340
|
+
|
|
341
|
+
if (thinking) {
|
|
342
|
+
// thought bubble ". o O" above head
|
|
343
|
+
cv.text(18, my - 1, '. o O', C.dim);
|
|
344
|
+
cv.sprite(mascot({ mouth: 'o', blink: (t % 16) < 2 }), 6, my);
|
|
345
|
+
} else {
|
|
346
|
+
if (p === 40) state.count++;
|
|
347
|
+
cv.put(nodeX, nodeY, '●', C.node);
|
|
348
|
+
cv.text(nodeX - 1, nodeY - 1, '*', C.spark);
|
|
349
|
+
cv.text(nodeX - 1, nodeY + 1, '*', C.spark);
|
|
350
|
+
cv.sprite(mascot({ mouth: 'o', armUp: (p % 4 < 2) }), 6, my);
|
|
351
|
+
}
|
|
352
|
+
cv.sprite(legs('stand'), 6, my + 7);
|
|
353
|
+
cv.text(2, CV_H - 1, `merges resolved ${state.count} ✓`, C.node);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── Idle tips (used only by the idle scene) ─────────────────────────────
|
|
357
|
+
const TIPS = [
|
|
358
|
+
{ say: 'Ready when you are.', cmd: 'gent status', color: C.body },
|
|
359
|
+
{ say: 'Publish your work to the cloud.', cmd: 'gent push', color: C.ok },
|
|
360
|
+
{ say: 'Grab everyone else\'s changes.', cmd: 'gent pull', color: C.branchA },
|
|
361
|
+
{ say: 'New idea? Branch it.', cmd: 'gent checkout -b idea', color: C.spark },
|
|
362
|
+
{ say: 'Ask me about this repo.', cmd: 'gent ask "what is this?"', color: C.branchB },
|
|
363
|
+
{ say: 'Want a review of your diff?', cmd: 'gent review', color: C.node },
|
|
364
|
+
{ say: 'Commit small, commit often.', cmd: 'gent commit -m "wip"', color: C.body },
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
// ── Scene registry ───────────────────────────────────────────────────────
|
|
368
|
+
// `cycle` = ticks in one full loop; playing "once" runs exactly one cycle.
|
|
369
|
+
const SCENES = {
|
|
370
|
+
idle: { fn: (cv, t, st) => sceneIdle(cv, t, st.tip), cycle: 66 },
|
|
371
|
+
push: { fn: scenePush, cycle: 66 },
|
|
372
|
+
pull: { fn: scenePull, cycle: 64 },
|
|
373
|
+
merge: { fn: sceneMerge, cycle: 70 },
|
|
374
|
+
auth: { fn: (cv, t) => sceneAuth(cv, t), cycle: 102 },
|
|
375
|
+
login: { fn: (cv, t) => sceneAuth(cv, t), cycle: 102 },
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// ── ANSI helpers ─────────────────────────────────────────────────────────
|
|
379
|
+
const HIDE = '\x1b[?25l';
|
|
380
|
+
const SHOW = '\x1b[?25h';
|
|
381
|
+
const CLEAR_LINE = '\x1b[2K';
|
|
382
|
+
const up = (n) => `\x1b[${n}A`;
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Play a scene. Redraws IN PLACE (no full-screen clear, no scrollback spam).
|
|
386
|
+
* Resolves when finished.
|
|
387
|
+
*
|
|
388
|
+
* @param {string} sceneName
|
|
389
|
+
* @param {object} opts
|
|
390
|
+
* @param {boolean} opts.loop keep looping until Ctrl+C (default: play once)
|
|
391
|
+
* @param {boolean} opts.footer show the "Ctrl+C to leave / try …" hint line
|
|
392
|
+
* @param {boolean} opts.goodbye print a farewell line when done
|
|
393
|
+
* @returns {Promise<void>}
|
|
394
|
+
*/
|
|
395
|
+
function play(sceneName, { loop = false, footer = true, goodbye = false } = {}) {
|
|
396
|
+
return new Promise((resolve) => {
|
|
397
|
+
const scene = SCENES[sceneName] || SCENES.idle;
|
|
398
|
+
const cv = new Canvas(CV_W, CV_H);
|
|
399
|
+
const state = { count: 0, tip: TIPS[Math.floor(Math.random() * TIPS.length)] };
|
|
400
|
+
const totalTicks = loop ? Infinity : scene.cycle + 1; // one clean cycle
|
|
401
|
+
let t = 0;
|
|
402
|
+
let printed = false;
|
|
403
|
+
let done = false;
|
|
404
|
+
|
|
405
|
+
const footerLine = () => footer
|
|
406
|
+
? chalk.gray(' ') +
|
|
407
|
+
(loop ? chalk.gray('Ctrl+C to leave') : C.body('Genti')) +
|
|
408
|
+
chalk.gray(' · more scenes: ') + C.say('gent pet push|pull|merge')
|
|
409
|
+
: '';
|
|
410
|
+
|
|
411
|
+
// Draw one frame, moving the cursor back over the previous frame.
|
|
412
|
+
const paint = () => {
|
|
413
|
+
cv.clear();
|
|
414
|
+
scene.fn(cv, t, state);
|
|
415
|
+
if (sceneName === 'idle' && loop && t > 0 && t % scene.cycle === 0) {
|
|
416
|
+
state.tip = TIPS[Math.floor(Math.random() * TIPS.length)];
|
|
417
|
+
}
|
|
418
|
+
const lines = cv.render().split('\n');
|
|
419
|
+
lines.push(footerLine());
|
|
420
|
+
const block = lines.map(l => CLEAR_LINE + l).join('\n');
|
|
421
|
+
if (printed) process.stdout.write(up(lines.length));
|
|
422
|
+
process.stdout.write(block + '\n');
|
|
423
|
+
printed = true;
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const finish = () => {
|
|
427
|
+
if (done) return;
|
|
428
|
+
done = true;
|
|
429
|
+
clearInterval(timer);
|
|
430
|
+
process.removeListener('SIGINT', onSig);
|
|
431
|
+
process.stdout.write(SHOW);
|
|
432
|
+
if (goodbye) {
|
|
433
|
+
console.log(C.body(' Genti waves.') + chalk.gray(' Come back with ') + C.say('gent pet') + chalk.gray('.'));
|
|
434
|
+
}
|
|
435
|
+
resolve();
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const onSig = () => finish();
|
|
439
|
+
|
|
440
|
+
process.stdout.write(HIDE);
|
|
441
|
+
paint();
|
|
442
|
+
const timer = setInterval(() => {
|
|
443
|
+
t++;
|
|
444
|
+
if (t >= totalTicks) { paint(); return finish(); }
|
|
445
|
+
paint();
|
|
446
|
+
}, FRAME_MS);
|
|
447
|
+
process.on('SIGINT', onSig);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Static single frame for non-TTY (piped) output.
|
|
452
|
+
function still(sceneName) {
|
|
453
|
+
const cv = new Canvas(CV_W, CV_H);
|
|
454
|
+
const state = { count: 1, tip: TIPS[Math.floor(Math.random() * TIPS.length)] };
|
|
455
|
+
(SCENES[sceneName] || SCENES.idle).fn(cv, 12, state);
|
|
456
|
+
console.log(cv.render());
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── Public: standalone `gent pet` command ────────────────────────────────
|
|
460
|
+
async function petCommand(scene, options = {}) {
|
|
461
|
+
if (process.env.NO_COLOR || (options && options.color === false)) chalk.level = 0;
|
|
462
|
+
|
|
463
|
+
let name = (scene || 'idle').toLowerCase();
|
|
464
|
+
if (!SCENES[name]) {
|
|
465
|
+
console.log(chalk.yellow(`Genti doesn't know the scene "${name}".`));
|
|
466
|
+
console.log(chalk.gray('Try: ') + C.say('gent pet') + chalk.gray(' · ') +
|
|
467
|
+
C.say('push') + chalk.gray(' · ') + C.say('pull') + chalk.gray(' · ') +
|
|
468
|
+
C.say('merge') + chalk.gray(' · ') + C.say('auth'));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Signed-out nudge: bare `gent pet` greets you at the door.
|
|
473
|
+
if (name === 'idle') {
|
|
474
|
+
const authed = await authStorage.isAuthenticated().catch(() => false);
|
|
475
|
+
if (!authed) name = 'auth';
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (!process.stdout.isTTY) return still(name);
|
|
479
|
+
// Default: play once. `--loop` keeps it running until Ctrl+C.
|
|
480
|
+
await play(name, { loop: !!options.loop, footer: true, goodbye: true });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Public: a one-shot celebration other commands fire after they succeed.
|
|
485
|
+
* Silent + safe in non-interactive contexts (CI, pipes, GENT_NO_PET=1).
|
|
486
|
+
* Never throws — a mascot must never break a real command.
|
|
487
|
+
*/
|
|
488
|
+
async function celebrate(scene) {
|
|
489
|
+
try {
|
|
490
|
+
if (!process.stdout.isTTY) return;
|
|
491
|
+
if (process.env.NO_COLOR) { /* still animate, just uncolored */ }
|
|
492
|
+
if (process.env.GENT_NO_PET || process.env.CI) return;
|
|
493
|
+
if (!SCENES[scene]) return;
|
|
494
|
+
console.log(); // one blank line between command output and Genti
|
|
495
|
+
await play(scene, { loop: false, footer: false, goodbye: false });
|
|
496
|
+
} catch (_) { /* ignore — decoration only */ }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
module.exports = petCommand;
|
|
500
|
+
module.exports.celebrate = celebrate;
|
package/src/commands/pull.js
CHANGED
|
@@ -29,6 +29,7 @@ const apiClient = require('../utils/api-client');
|
|
|
29
29
|
const authStorage = require('../utils/auth-storage');
|
|
30
30
|
const { storeBlob, readBlob } = require('../utils/hash-engine');
|
|
31
31
|
const { findMergeBase, mergeTreeEntries } = require('../utils/merge-engine');
|
|
32
|
+
const pet = require('./pet');
|
|
32
33
|
const { generateCommitHash } = require('../utils/helpers');
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -137,6 +138,7 @@ async function pull(remoteName, branchName, options) {
|
|
|
137
138
|
|
|
138
139
|
spinner.succeed(chalk.green(`Fast-forward: ${newCount} new commit(s)`));
|
|
139
140
|
console.log(chalk.gray(` ${remote}/${branch} → ${remoteHead.substring(0, 7)}`));
|
|
141
|
+
await pet.celebrate('pull');
|
|
140
142
|
} else {
|
|
141
143
|
// Diverged — need 3-way merge
|
|
142
144
|
spinner.text = 'Branches diverged, merging...';
|
|
@@ -192,6 +194,7 @@ async function pull(remoteName, branchName, options) {
|
|
|
192
194
|
} else {
|
|
193
195
|
spinner.succeed(chalk.green(`Merged ${newCount} remote commit(s)`));
|
|
194
196
|
console.log(chalk.gray(` Merge commit: ${mergeCommit.hash.substring(0, 7)}`));
|
|
197
|
+
await pet.celebrate('pull');
|
|
195
198
|
}
|
|
196
199
|
}
|
|
197
200
|
} catch (error) {
|
package/src/commands/push.js
CHANGED
|
@@ -42,6 +42,7 @@ const { COMMITS_FILE, CONFIG_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl }
|
|
|
42
42
|
const apiClient = require('../utils/api-client');
|
|
43
43
|
const authStorage = require('../utils/auth-storage');
|
|
44
44
|
const { readBlob, readTree, objectExists, readBlobAsString } = require('../utils/hash-engine');
|
|
45
|
+
const pet = require('./pet');
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
48
|
* Push commits to remote
|
|
@@ -243,6 +244,8 @@ async function push(remoteName, branchName, options) {
|
|
|
243
244
|
console.log(chalk.gray(` ${localHead.substring(0, 7)} → ${remote}/${branch}`));
|
|
244
245
|
console.log(chalk.gray(` ${packBlobs.length} blob(s), ${packTrees.length} tree(s) transferred`));
|
|
245
246
|
|
|
247
|
+
await pet.celebrate('push');
|
|
248
|
+
|
|
246
249
|
} catch (error) {
|
|
247
250
|
spinner.fail(chalk.red('Push failed'));
|
|
248
251
|
|
package/src/index.js
CHANGED
|
@@ -78,6 +78,7 @@ const webCommand = require('./commands/web');
|
|
|
78
78
|
const shareCommand = require('./commands/share');
|
|
79
79
|
const searchCommand = require('./commands/search');
|
|
80
80
|
const templateCommand = require('./commands/template');
|
|
81
|
+
const petCommand = require('./commands/pet');
|
|
81
82
|
|
|
82
83
|
// Configure CLI
|
|
83
84
|
program
|
|
@@ -432,6 +433,13 @@ program
|
|
|
432
433
|
.option('-e, --email <email>', 'Account email (for reset)')
|
|
433
434
|
.action(passwordCommand);
|
|
434
435
|
|
|
436
|
+
// ─── Fun ────────────────────────────────────────────────
|
|
437
|
+
program
|
|
438
|
+
.command('pet [scene]')
|
|
439
|
+
.description('Meet Genti — an animated pixel mascot that acts out gent (push|pull|merge|auth)')
|
|
440
|
+
.option('--loop', 'Keep looping until Ctrl+C (default: play once)')
|
|
441
|
+
.action(petCommand);
|
|
442
|
+
|
|
435
443
|
// Help command
|
|
436
444
|
program
|
|
437
445
|
.command('help [command]')
|
|
@@ -456,6 +464,7 @@ const HELP_GROUPS = [
|
|
|
456
464
|
['Account', ['register', 'login', 'logout', 'whoami', 'password']],
|
|
457
465
|
['AI', ['ask', 'review', 'docs', 'changelog', 'ai']],
|
|
458
466
|
['Config & tools', ['config', 'doctor', 'template', 'help']],
|
|
467
|
+
['Fun', ['pet']],
|
|
459
468
|
];
|
|
460
469
|
|
|
461
470
|
function configureGroupedHelp(program) {
|
|
@@ -526,6 +535,7 @@ function showQuickstart() {
|
|
|
526
535
|
console.log(` ${chalk.cyan('gent changelog')} ${chalk.gray('grouped release notes')}`);
|
|
527
536
|
console.log();
|
|
528
537
|
console.log(chalk.gray('Full command list: ') + chalk.cyan('gent --help'));
|
|
538
|
+
console.log(chalk.gray('Say hi to your mascot: ') + chalk.cyan('gent pet'));
|
|
529
539
|
console.log();
|
|
530
540
|
}
|
|
531
541
|
|