gent-cli 12.0.0 → 14.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": "12.0.0",
3
+ "version": "14.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,12 +8,14 @@ const inquirer = require('inquirer');
8
8
  const ora = require('ora');
9
9
  const boxen = require('boxen');
10
10
  const authService = require('../services/auth-service');
11
+ const pet = require('./pet');
11
12
 
12
13
  /**
13
14
  * Login user
14
15
  * @param {Object} options - Command options
15
16
  */
16
17
  async function login(options) {
18
+ pet.banner('Knock knock — let\'s sign you in.', 'Enter your details below.');
17
19
  console.log(chalk.cyan('\n🔐 Login to your Gent account\n'));
18
20
 
19
21
  try {
@@ -75,6 +77,8 @@ ${chalk.bold('Commands:')}
75
77
  borderColor: 'cyan'
76
78
  }));
77
79
 
80
+ await pet.celebrate('auth');
81
+
78
82
  } catch (error) {
79
83
  console.error(chalk.red('\n✗ Login failed'));
80
84
  console.error(chalk.red(`Error: ${error.message}\n`));
@@ -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));
@@ -365,52 +365,116 @@ const TIPS = [
365
365
  ];
366
366
 
367
367
  // ── Scene registry ───────────────────────────────────────────────────────
368
+ // `cycle` = ticks in one full loop; playing "once" runs exactly one cycle.
368
369
  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) },
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 },
375
376
  };
376
377
 
377
378
  // ── ANSI helpers ─────────────────────────────────────────────────────────
378
- const HOME = '[H';
379
- const CLEAR = '[2J[H';
380
- const HIDE = '[?25l';
381
- const SHOW = '[?25h';
379
+ const HIDE = '\x1b[?25l';
380
+ const SHOW = '\x1b[?25h';
381
+ const CLEAR_LINE = '\x1b[2K';
382
+ const HOME = '\x1b[H';
383
+ const ENTER_ALT = '\x1b[?1049h'; // switch to the alternate screen buffer
384
+ const LEAVE_ALT = '\x1b[?1049l'; // …and restore the user's scrollback on exit
385
+ const up = (n) => `\x1b[${n}A`;
382
386
 
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);
387
+ /**
388
+ * Play a scene. Resolves when finished.
389
+ *
390
+ * Two redraw strategies:
391
+ * - altScreen:true → take over the whole screen (alternate buffer), redraw
392
+ * from HOME each frame, restore on exit. Zero scrollback drift. Used by the
393
+ * standalone `gent pet` command.
394
+ * - altScreen:false → inline redraw below existing output via cursor-up, so a
395
+ * command's own text stays visible above. Used by post-command celebrations.
396
+ *
397
+ * @param {string} sceneName
398
+ * @param {object} opts
399
+ * @param {boolean} opts.loop keep looping until Ctrl+C (default: play once)
400
+ * @param {boolean} opts.footer show the hint line under the stage
401
+ * @param {boolean} opts.goodbye print a farewell line when done
402
+ * @param {boolean} opts.altScreen use the alternate screen buffer
403
+ * @returns {Promise<void>}
404
+ */
405
+ function play(sceneName, { loop = false, footer = true, goodbye = false, altScreen = false, maxTicks = Infinity } = {}) {
406
+ return new Promise((resolve) => {
407
+ const scene = SCENES[sceneName] || SCENES.idle;
408
+ const cv = new Canvas(CV_W, CV_H);
409
+ const state = { count: 0, tip: TIPS[Math.floor(Math.random() * TIPS.length)] };
410
+ const totalTicks = loop ? Infinity : Math.min(scene.cycle + 1, maxTicks); // one clean cycle, capped
411
+ let t = 0;
412
+ let printed = false;
413
+ let done = false;
414
+
415
+ const footerLine = () => footer
416
+ ? chalk.gray(' ') +
417
+ (loop ? chalk.gray('Ctrl+C to leave') : C.body('Genti')) +
418
+ chalk.gray(' · more scenes: ') + C.say('gent pet push|pull|merge')
419
+ : '';
420
+
421
+ const paint = () => {
422
+ cv.clear();
423
+ scene.fn(cv, t, state);
424
+ if (sceneName === 'idle' && loop && t > 0 && t % scene.cycle === 0) {
425
+ state.tip = TIPS[Math.floor(Math.random() * TIPS.length)];
426
+ }
427
+ const lines = cv.render().split('\n');
428
+ lines.push(footerLine());
429
+ const block = lines.map(l => CLEAR_LINE + l).join('\n');
430
+ if (altScreen) {
431
+ process.stdout.write(HOME + block);
432
+ } else {
433
+ if (printed) process.stdout.write(up(lines.length));
434
+ process.stdout.write(block + '\n');
435
+ }
436
+ printed = true;
437
+ };
438
+
439
+ const finish = () => {
440
+ if (done) return;
441
+ done = true;
442
+ clearInterval(timer);
443
+ process.removeListener('SIGINT', onSig);
444
+ process.stdout.write(SHOW + (altScreen ? LEAVE_ALT : ''));
445
+ if (goodbye) {
446
+ console.log(C.body(' Genti waves.') + chalk.gray(' Come back with ') + C.say('gent pet') + chalk.gray('.'));
447
+ }
448
+ resolve();
449
+ };
450
+
451
+ const onSig = () => finish();
452
+
453
+ process.stdout.write((altScreen ? ENTER_ALT + HOME : '') + HIDE);
454
+ paint();
455
+ const timer = setInterval(() => {
456
+ t++;
457
+ if (t >= totalTicks) { paint(); return finish(); }
458
+ paint();
459
+ }, FRAME_MS);
460
+ process.on('SIGINT', onSig);
461
+ });
462
+ }
412
463
 
413
- process.on('SIGINT', () => { clearInterval(timer); bye(); process.exit(0); });
464
+ /**
465
+ * A compact STATIC Genti header — safe to print above an interactive prompt
466
+ * (inquirer, etc.) because it never animates or moves the cursor.
467
+ * @param {string} greeting bold line beside the mascot
468
+ * @param {string} [sub] dimmer line under the greeting
469
+ */
470
+ function banner(greeting, sub) {
471
+ if (process.env.GENT_NO_PET || !process.stdout.isTTY) return;
472
+ const cv = new Canvas(52, 9);
473
+ cv.sprite(mascot({}), 1, 0);
474
+ cv.sprite(legs('stand'), 1, 7);
475
+ cv.text(18, 2, greeting, C.body);
476
+ if (sub) cv.text(18, 4, sub, C.say);
477
+ console.log('\n' + cv.render());
414
478
  }
415
479
 
416
480
  // Static single frame for non-TTY (piped) output.
@@ -419,10 +483,9 @@ function still(sceneName) {
419
483
  const state = { count: 1, tip: TIPS[Math.floor(Math.random() * TIPS.length)] };
420
484
  (SCENES[sceneName] || SCENES.idle).fn(cv, 12, state);
421
485
  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
486
  }
424
487
 
425
- // ── Entry ────────────────────────────────────────────────────────────────
488
+ // ── Public: standalone `gent pet` command ────────────────────────────────
426
489
  async function petCommand(scene, options = {}) {
427
490
  if (process.env.NO_COLOR || (options && options.color === false)) chalk.level = 0;
428
491
 
@@ -435,14 +498,36 @@ async function petCommand(scene, options = {}) {
435
498
  return;
436
499
  }
437
500
 
438
- // Signed-out nudge: default idle auth scene the first time.
439
- if (name === 'idle' && !options.stay) {
501
+ // Signed-out nudge: bare `gent pet` greets you at the door.
502
+ if (name === 'idle') {
440
503
  const authed = await authStorage.isAuthenticated().catch(() => false);
441
504
  if (!authed) name = 'auth';
442
505
  }
443
506
 
444
507
  if (!process.stdout.isTTY) return still(name);
445
- play(name, { once: !!options.once });
508
+ // Default: play once, on the alternate screen so nothing pollutes scrollback.
509
+ // `--loop` keeps it running until Ctrl+C.
510
+ await play(name, { loop: !!options.loop, footer: true, goodbye: true, altScreen: true });
511
+ }
512
+
513
+ /**
514
+ * Public: a one-shot celebration other commands fire after they succeed.
515
+ * Silent + safe in non-interactive contexts (CI, pipes, GENT_NO_PET=1).
516
+ * Never throws — a mascot must never break a real command.
517
+ */
518
+ async function celebrate(scene) {
519
+ try {
520
+ if (!process.stdout.isTTY) return;
521
+ if (process.env.NO_COLOR) { /* still animate, just uncolored */ }
522
+ if (process.env.GENT_NO_PET || process.env.CI) return;
523
+ if (!SCENES[scene]) return;
524
+ console.log(); // one blank line between command output and Genti
525
+ // Auth flows just need a quick friendly wave; action scenes tell a fuller story.
526
+ const maxTicks = (scene === 'auth' || scene === 'login') ? 34 : Infinity;
527
+ await play(scene, { loop: false, footer: false, goodbye: false, maxTicks });
528
+ } catch (_) { /* ignore — decoration only */ }
446
529
  }
447
530
 
448
531
  module.exports = petCommand;
532
+ module.exports.celebrate = celebrate;
533
+ module.exports.banner = banner;
@@ -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
 
@@ -8,12 +8,14 @@ const inquirer = require('inquirer');
8
8
  const ora = require('ora');
9
9
  const boxen = require('boxen');
10
10
  const authService = require('../services/auth-service');
11
+ const pet = require('./pet');
11
12
 
12
13
  /**
13
14
  * Register a new user
14
15
  * @param {Object} options - Command options
15
16
  */
16
17
  async function register(options) {
18
+ pet.banner('New here? Let\'s get you set up.', 'Create your account below.');
17
19
  console.log(chalk.cyan('\n🚀 Create your Gent account\n'));
18
20
 
19
21
  try {
@@ -120,6 +122,8 @@ ${chalk.bold('Next steps:')}
120
122
  borderColor: 'green'
121
123
  }));
122
124
 
125
+ await pet.celebrate('auth');
126
+
123
127
  } catch (error) {
124
128
  console.error(chalk.red('\n✗ Registration failed'));
125
129
  console.error(chalk.red(`Error: ${error.message}\n`));
package/src/index.js CHANGED
@@ -437,8 +437,7 @@ program
437
437
  program
438
438
  .command('pet [scene]')
439
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')
440
+ .option('--loop', 'Keep looping until Ctrl+C (default: play once)')
442
441
  .action(petCommand);
443
442
 
444
443
  // Help command