gent-cli 10.0.0 → 12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gent-cli",
3
- "version": "10.0.0",
3
+ "version": "12.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": {
@@ -8,8 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node src/index.js",
11
- "test": "node --check src/index.js && node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js && node tests/offline-e2e.js",
12
- "test:unit": "node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js",
11
+ "test": "node --check src/index.js && node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js && node tests/offline-e2e.js",
12
+ "test:unit": "node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js",
13
13
  "test:e2e": "node tests/offline-e2e.js",
14
14
  "test:remote:e2e": "node tests/remote-e2e.js",
15
15
  "demo": "bash demo.sh",
@@ -0,0 +1,448 @@
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
+ const SCENES = {
369
+ idle: { fn: (cv, t, st) => sceneIdle(cv, t, st.tip) },
370
+ push: { fn: scenePush },
371
+ pull: { fn: scenePull },
372
+ merge: { fn: sceneMerge },
373
+ auth: { fn: (cv, t) => sceneAuth(cv, t) },
374
+ login: { fn: (cv, t) => sceneAuth(cv, t) },
375
+ };
376
+
377
+ // ── ANSI helpers ─────────────────────────────────────────────────────────
378
+ const HOME = '[H';
379
+ const CLEAR = '[2J[H';
380
+ const HIDE = '[?25l';
381
+ const SHOW = '[?25h';
382
+
383
+ function play(sceneName, { once }) {
384
+ const scene = SCENES[sceneName] || SCENES.idle;
385
+ const cv = new Canvas(CV_W, CV_H);
386
+ const state = { count: 0, tip: TIPS[Math.floor(Math.random() * TIPS.length)] };
387
+ let t = 0;
388
+
389
+ process.stdout.write(HIDE + CLEAR);
390
+
391
+ const footer = () =>
392
+ chalk.gray(' scene: ') + C.body(sceneName) +
393
+ chalk.gray(' · try: ') + C.say('gent pet push|pull|merge') +
394
+ chalk.gray(' · Ctrl+C to leave');
395
+
396
+ const bye = () => {
397
+ process.stdout.write(SHOW + '\n');
398
+ console.log(C.body(' Genti waves. ') + chalk.gray('Come back with ') + C.say('gent pet') + chalk.gray('.'));
399
+ };
400
+
401
+ const timer = setInterval(() => {
402
+ cv.clear();
403
+ scene.fn(cv, t, state);
404
+ // rotate idle tip every ~6s
405
+ if (sceneName === 'idle' && t > 0 && t % 66 === 0) {
406
+ state.tip = TIPS[Math.floor(Math.random() * TIPS.length)];
407
+ }
408
+ process.stdout.write(HOME + cv.render() + '\n' + footer() + '\n');
409
+ t++;
410
+ if (once && t > 130) { clearInterval(timer); bye(); process.exit(0); }
411
+ }, FRAME_MS);
412
+
413
+ process.on('SIGINT', () => { clearInterval(timer); bye(); process.exit(0); });
414
+ }
415
+
416
+ // Static single frame for non-TTY (piped) output.
417
+ function still(sceneName) {
418
+ const cv = new Canvas(CV_W, CV_H);
419
+ const state = { count: 1, tip: TIPS[Math.floor(Math.random() * TIPS.length)] };
420
+ (SCENES[sceneName] || SCENES.idle).fn(cv, 12, state);
421
+ console.log(cv.render());
422
+ console.log(chalk.gray(' (animated in a real terminal — run ') + C.say(`gent pet ${sceneName === 'idle' ? '' : sceneName}`.trim()) + chalk.gray(')'));
423
+ }
424
+
425
+ // ── Entry ────────────────────────────────────────────────────────────────
426
+ async function petCommand(scene, options = {}) {
427
+ if (process.env.NO_COLOR || (options && options.color === false)) chalk.level = 0;
428
+
429
+ let name = (scene || 'idle').toLowerCase();
430
+ if (!SCENES[name]) {
431
+ console.log(chalk.yellow(`Genti doesn't know the scene "${name}".`));
432
+ console.log(chalk.gray('Try: ') + C.say('gent pet') + chalk.gray(' · ') +
433
+ C.say('push') + chalk.gray(' · ') + C.say('pull') + chalk.gray(' · ') +
434
+ C.say('merge') + chalk.gray(' · ') + C.say('auth'));
435
+ return;
436
+ }
437
+
438
+ // Signed-out nudge: default idle → auth scene the first time.
439
+ if (name === 'idle' && !options.stay) {
440
+ const authed = await authStorage.isAuthenticated().catch(() => false);
441
+ if (!authed) name = 'auth';
442
+ }
443
+
444
+ if (!process.stdout.isTTY) return still(name);
445
+ play(name, { once: !!options.once });
446
+ }
447
+
448
+ module.exports = petCommand;
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,14 @@ 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('--once', 'Play a few cycles, then exit (good for scripts / shell startup)')
441
+ .option('--stay', 'Stay on the idle scene even when signed out')
442
+ .action(petCommand);
443
+
435
444
  // Help command
436
445
  program
437
446
  .command('help [command]')
@@ -456,6 +465,7 @@ const HELP_GROUPS = [
456
465
  ['Account', ['register', 'login', 'logout', 'whoami', 'password']],
457
466
  ['AI', ['ask', 'review', 'docs', 'changelog', 'ai']],
458
467
  ['Config & tools', ['config', 'doctor', 'template', 'help']],
468
+ ['Fun', ['pet']],
459
469
  ];
460
470
 
461
471
  function configureGroupedHelp(program) {
@@ -526,6 +536,7 @@ function showQuickstart() {
526
536
  console.log(` ${chalk.cyan('gent changelog')} ${chalk.gray('grouped release notes')}`);
527
537
  console.log();
528
538
  console.log(chalk.gray('Full command list: ') + chalk.cyan('gent --help'));
539
+ console.log(chalk.gray('Say hi to your mascot: ') + chalk.cyan('gent pet'));
529
540
  console.log();
530
541
  }
531
542
 
@@ -92,6 +92,8 @@ module.exports = {
92
92
  '.gent',
93
93
  'node_modules',
94
94
  '.git',
95
+ '.gitignore',
96
+ '.gentignore',
95
97
  '.DS_Store',
96
98
  '*.log',
97
99
  '.env',
@@ -7,6 +7,8 @@ const fs = require('fs').promises;
7
7
  const path = require('path');
8
8
  const { GENT_DIR, DEFAULT_IGNORE_PATTERNS, IGNORE_FILE } = require('./constants');
9
9
 
10
+ const GIT_IGNORE_FILE = '.gitignore';
11
+
10
12
  /**
11
13
  * Check if a path exists
12
14
  * @param {String} path - Path to check
@@ -115,21 +117,35 @@ function shouldIgnore(filePath, patterns) {
115
117
  const normalizedPath = filePath.replace(/\\/g, '/');
116
118
 
117
119
  for (const pattern of patterns) {
120
+ const normalizedPattern = normalizeIgnorePattern(pattern);
121
+ if (!normalizedPattern) {
122
+ continue;
123
+ }
124
+
118
125
  // Exact match
119
- if (normalizedPath === pattern || normalizedPath.startsWith(pattern + '/')) {
126
+ if (normalizedPath === normalizedPattern || normalizedPath.startsWith(normalizedPattern + '/')) {
120
127
  return true;
121
128
  }
122
129
 
130
+ // Basename match
131
+ if (!normalizedPattern.includes('/') && !normalizedPattern.includes('*')) {
132
+ const parts = normalizedPath.split('/');
133
+ if (parts.includes(normalizedPattern)) {
134
+ return true;
135
+ }
136
+ }
137
+
123
138
  // Wildcard match
124
- if (pattern.includes('*')) {
125
- const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
126
- if (regex.test(normalizedPath)) {
139
+ if (normalizedPattern.includes('*')) {
140
+ const wildcardRegex = normalizedPattern.split('*').map(escapeRegex).join('.*');
141
+ const regex = new RegExp('^' + wildcardRegex + '$');
142
+ if (regex.test(normalizedPath) || regex.test(path.posix.basename(normalizedPath))) {
127
143
  return true;
128
144
  }
129
145
  }
130
146
 
131
147
  // Extension match
132
- if (pattern.startsWith('*.') && normalizedPath.endsWith(pattern.substring(1))) {
148
+ if (normalizedPattern.startsWith('*.') && normalizedPath.endsWith(normalizedPattern.substring(1))) {
133
149
  return true;
134
150
  }
135
151
  }
@@ -137,6 +153,56 @@ function shouldIgnore(filePath, patterns) {
137
153
  return false;
138
154
  }
139
155
 
156
+ function normalizeIgnorePattern(pattern) {
157
+ return pattern
158
+ .replace(/\\/g, '/')
159
+ .replace(/^\.\//, '')
160
+ .replace(/^\/+/, '')
161
+ .replace(/\/+$/, '');
162
+ }
163
+
164
+ function escapeRegex(pattern) {
165
+ return pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
166
+ }
167
+
168
+ function parseIgnoreContent(content, basePath = '') {
169
+ return content.split('\n')
170
+ .map(line => line.trim())
171
+ .filter(line => line && !line.startsWith('#') && !line.startsWith('!'))
172
+ .map(line => normalizeIgnorePattern(path.posix.join(basePath, line)))
173
+ .filter(Boolean);
174
+ }
175
+
176
+ async function collectGitIgnorePatterns(rootDir, patterns) {
177
+ async function scan(currentDir) {
178
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
179
+ const relativeDir = path.relative(rootDir, currentDir).replace(/\\/g, '/');
180
+ const gitIgnore = entries.find(entry => entry.isFile() && entry.name === GIT_IGNORE_FILE);
181
+
182
+ if (gitIgnore) {
183
+ const ignorePath = path.join(currentDir, GIT_IGNORE_FILE);
184
+ const content = await fs.readFile(ignorePath, 'utf-8');
185
+ patterns.push(...parseIgnoreContent(content, relativeDir));
186
+ }
187
+
188
+ for (const entry of entries) {
189
+ if (!entry.isDirectory()) {
190
+ continue;
191
+ }
192
+
193
+ const fullPath = path.join(currentDir, entry.name);
194
+ const relativePath = path.relative(rootDir, fullPath);
195
+ if (shouldIgnore(relativePath, patterns)) {
196
+ continue;
197
+ }
198
+
199
+ await scan(fullPath);
200
+ }
201
+ }
202
+
203
+ await scan(rootDir);
204
+ }
205
+
140
206
  /**
141
207
  * Get ignore patterns from .gentignore file
142
208
  * @param {String} dir - Directory to check
@@ -148,13 +214,11 @@ async function getIgnorePatterns(dir) {
148
214
 
149
215
  if (await pathExists(ignorePath)) {
150
216
  const content = await fs.readFile(ignorePath, 'utf-8');
151
- const lines = content.split('\n')
152
- .map(line => line.trim())
153
- .filter(line => line && !line.startsWith('#'));
154
-
155
- patterns.push(...lines);
217
+ patterns.push(...parseIgnoreContent(content));
156
218
  }
157
219
 
220
+ await collectGitIgnorePatterns(dir, patterns);
221
+
158
222
  return patterns;
159
223
  }
160
224