gent-cli 12.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": "12.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));
@@ -365,52 +365,87 @@ 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 up = (n) => `\x1b[${n}A`;
382
383
 
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); });
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
+ });
414
449
  }
415
450
 
416
451
  // Static single frame for non-TTY (piped) output.
@@ -419,10 +454,9 @@ function still(sceneName) {
419
454
  const state = { count: 1, tip: TIPS[Math.floor(Math.random() * TIPS.length)] };
420
455
  (SCENES[sceneName] || SCENES.idle).fn(cv, 12, state);
421
456
  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
457
  }
424
458
 
425
- // ── Entry ────────────────────────────────────────────────────────────────
459
+ // ── Public: standalone `gent pet` command ────────────────────────────────
426
460
  async function petCommand(scene, options = {}) {
427
461
  if (process.env.NO_COLOR || (options && options.color === false)) chalk.level = 0;
428
462
 
@@ -435,14 +469,32 @@ async function petCommand(scene, options = {}) {
435
469
  return;
436
470
  }
437
471
 
438
- // Signed-out nudge: default idle auth scene the first time.
439
- if (name === 'idle' && !options.stay) {
472
+ // Signed-out nudge: bare `gent pet` greets you at the door.
473
+ if (name === 'idle') {
440
474
  const authed = await authStorage.isAuthenticated().catch(() => false);
441
475
  if (!authed) name = 'auth';
442
476
  }
443
477
 
444
478
  if (!process.stdout.isTTY) return still(name);
445
- play(name, { once: !!options.once });
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 */ }
446
497
  }
447
498
 
448
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
@@ -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