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 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": "11.0.0",
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": {
@@ -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;
@@ -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) {
@@ -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