myshell-tools 2.8.0 → 2.13.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.
@@ -181,6 +181,55 @@ export function renderConversationList(metas, nowMs) {
181
181
  return `[${idx}] ${pin}${rel} ${m.title}${categorySuffix}`;
182
182
  });
183
183
  }
184
+ // ---------------------------------------------------------------------------
185
+ // Ctrl+C escape model — pure helpers
186
+ // ---------------------------------------------------------------------------
187
+ /**
188
+ * Count how many timestamps in `times` fall within the half-open window
189
+ * `[now - windowMs, now]` (both endpoints inclusive).
190
+ *
191
+ * Pure — no I/O, no side effects, never throws.
192
+ *
193
+ * @param times - Immutable array of epoch-ms timestamps (e.g. from ctx.clock.now()).
194
+ * @param now - The current epoch-ms (e.g. from ctx.clock.now()).
195
+ * @param windowMs - Width of the sliding window in milliseconds (e.g. 1500).
196
+ * @returns Number of entries within the window.
197
+ */
198
+ export function countRecentInterrupts(times, now, windowMs) {
199
+ const cutoff = now - windowMs;
200
+ let count = 0;
201
+ for (const t of times) {
202
+ if (t >= cutoff && t <= now)
203
+ count += 1;
204
+ }
205
+ return count;
206
+ }
207
+ /**
208
+ * Decide what action to take based on the number of recent Ctrl+C presses and
209
+ * whether a task is currently running.
210
+ *
211
+ * Rules:
212
+ * count >= 3 → `'exit-app'`
213
+ * count === 2 → `'to-menu'`
214
+ * count === 1 && taskRunning → `'cancel-task'`
215
+ * count === 1 && !taskRunning → `'hint'`
216
+ * count <= 0 → `'hint'` (defensive)
217
+ *
218
+ * Pure — never throws, no I/O, no side effects.
219
+ *
220
+ * @param count - Number of recent interrupt presses (from countRecentInterrupts).
221
+ * @param taskRunning - Whether a task is currently in-flight.
222
+ * @returns The action to take.
223
+ */
224
+ export function interpretInterrupt(count, taskRunning) {
225
+ if (count >= 3)
226
+ return 'exit-app';
227
+ if (count === 2)
228
+ return 'to-menu';
229
+ if (count === 1)
230
+ return taskRunning ? 'cancel-task' : 'hint';
231
+ return 'hint';
232
+ }
184
233
  /**
185
234
  * Build a {@link LineReader} backed by a single `node:readline` interface.
186
235
  *
@@ -322,10 +371,15 @@ async function runWelcome(ctx, out, readLine, mutableConfig, installProviderFn,
322
371
  out.write('Set myshell-tools as your default shell tool? (y/N) ');
323
372
  const defaultAns = await readLine();
324
373
  const setAsDefault = parseYesNo(defaultAns, false);
374
+ // Default is NO for auto-update — require explicit 'y' to enable.
375
+ out.write('Keep myshell-tools up to date automatically? (y/N) ');
376
+ const autoUpdateAns = await readLine();
377
+ const autoUpdate = parseYesNo(autoUpdateAns, false);
325
378
  const saved = {
326
379
  onboarded: true,
327
380
  setAsDefault,
328
381
  ...(updated.mode !== undefined ? { mode: updated.mode } : {}),
382
+ ...(autoUpdate ? { autoUpdate: true } : {}),
329
383
  };
330
384
  await saveConfig(saved);
331
385
  // When the user opts in, actually write the shell startup hook (real install,
@@ -393,6 +447,7 @@ async function runSettings(_ctx, mutableCtx, out, readLine) {
393
447
  '',
394
448
  ` [1] Mode: ${cfg.mode ?? 'balanced'}`,
395
449
  ` [2] Set as default shell: ${cfg.setAsDefault ? 'on' : 'off'}`,
450
+ ` [3] Auto-update: ${cfg.autoUpdate === true ? 'on' : 'off'}`,
396
451
  '',
397
452
  ' [Enter] Back',
398
453
  '',
@@ -409,8 +464,27 @@ async function runSettings(_ctx, mutableCtx, out, readLine) {
409
464
  else if (key === '2') {
410
465
  mutableCtx.config = await toggleDefaultShell(mutableCtx.config, out);
411
466
  }
467
+ else if (key === '3') {
468
+ mutableCtx.config = await toggleAutoUpdate(mutableCtx.config, out);
469
+ }
412
470
  // anything else → back
413
471
  }
472
+ /**
473
+ * Toggle the auto-update preference and persist the updated config.
474
+ * Reports the new state so the user knows what changed.
475
+ */
476
+ async function toggleAutoUpdate(config, out) {
477
+ const enable = config.autoUpdate !== true;
478
+ const updated = {
479
+ onboarded: config.onboarded,
480
+ setAsDefault: config.setAsDefault,
481
+ ...(config.mode !== undefined ? { mode: config.mode } : {}),
482
+ ...(enable ? { autoUpdate: true } : {}),
483
+ };
484
+ await saveConfig(updated);
485
+ out.write(`Auto-update: ${enable ? 'on' : 'off'}\n`);
486
+ return updated;
487
+ }
414
488
  // ---------------------------------------------------------------------------
415
489
  // Manage conversations screen
416
490
  // ---------------------------------------------------------------------------
@@ -515,11 +589,11 @@ async function runManage(ctx, out, readLine) {
515
589
  * Follows the injected `readLine` seam so it is fully testable without TTY.
516
590
  * Never modifies the native CLI's files.
517
591
  */
518
- async function runImportNative(ctx, mutableCtx, out, readLine) {
592
+ async function runImportNative(ctx, mutableCtx, out, readLine, loginFn, detectEnvironmentFn) {
519
593
  out.write('\nImport from:\n [1] Claude\n [2] Codex\n\n> ');
520
594
  const choice = await readLine();
521
595
  if (choice === null)
522
- return;
596
+ return 'menu';
523
597
  let provider;
524
598
  if (choice === '1') {
525
599
  provider = 'claude';
@@ -529,12 +603,12 @@ async function runImportNative(ctx, mutableCtx, out, readLine) {
529
603
  }
530
604
  else {
531
605
  out.write('Cancelled.\n');
532
- return;
606
+ return 'menu';
533
607
  }
534
608
  const sessions = await listNativeSessions(provider);
535
609
  if (sessions.length === 0) {
536
610
  out.write(`No ${provider} conversations found.\n`);
537
- return;
611
+ return 'menu';
538
612
  }
539
613
  // Render a numbered picker
540
614
  const nowMs = ctx.clock.now();
@@ -551,29 +625,58 @@ async function runImportNative(ctx, mutableCtx, out, readLine) {
551
625
  out.write('\nPick a conversation number (or Enter to cancel): ');
552
626
  const pick = await readLine();
553
627
  if (pick === null || pick.length === 0)
554
- return;
628
+ return 'menu';
555
629
  const num = parseInt(pick, 10);
556
630
  if (Number.isNaN(num) || num < 1 || num > sessions.length) {
557
631
  out.write('Invalid selection.\n');
558
- return;
632
+ return 'menu';
559
633
  }
560
634
  const session = sessions[num - 1];
561
635
  if (session === undefined)
562
- return;
636
+ return 'menu';
563
637
  const { id, imported } = await importNativeSession(session, ctx.store);
564
638
  const convTitle = session.title.length > 0 ? session.title : '(untitled)';
565
639
  out.write(`Imported ${imported} messages into a new conversation: "${convTitle}"\n`);
566
- // Enter the chat loop for the newly imported conversation
567
- await runChatLoop(ctx, mutableCtx, id, out, readLine);
640
+ // Enter the chat loop for the newly imported conversation.
641
+ // Return value propagates the 'exit' signal to the caller (startMenu).
642
+ return runChatLoop(ctx, mutableCtx, id, out, readLine, loginFn, detectEnvironmentFn);
568
643
  }
569
644
  // ---------------------------------------------------------------------------
570
645
  // Raw provider passthrough
571
646
  // ---------------------------------------------------------------------------
647
+ /**
648
+ * Decide whether a raw-session SIGINT count warrants escaping back to the menu.
649
+ *
650
+ * Returns true when count >= 2 (rapid double Ctrl+C), false otherwise.
651
+ * A single press (count === 1) is left entirely to the child process — the
652
+ * terminal already delivers SIGINT to the whole foreground process group, so
653
+ * claude/codex/opencode handles its own cancel without interference from us.
654
+ *
655
+ * Pure — no I/O, no side effects, never throws.
656
+ *
657
+ * @param count - Number of recent Ctrl+C presses (from countRecentInterrupts).
658
+ * @returns True when the user should be returned to the myshell-tools menu.
659
+ */
660
+ export function shouldEscapeRawSession(count) {
661
+ return count >= 2;
662
+ }
572
663
  /**
573
664
  * Launch the native `claude`, `codex`, or `opencode` interactive CLI directly
574
665
  * (stdio:inherit), so the user gets a raw provider session. The session is owned
575
666
  * by the native CLI (not by myshell-tools); we simply hand over the terminal and wait.
576
667
  *
668
+ * On Unix, a best-effort "Ctrl+C twice → back to menu" escape is registered:
669
+ * - A single Ctrl+C is left entirely to the child (the terminal delivers SIGINT
670
+ * to the whole foreground group; we must NOT interfere with single presses).
671
+ * - Two presses within 1 500 ms → SIGTERM the child and return to the menu.
672
+ * On Windows the SIGINT handler is NOT registered (process-group semantics differ
673
+ * and forced interception risks a broken console) — behaviour is exactly as today.
674
+ *
675
+ * The SIGINT listener is always removed in a finally block so it never leaks back
676
+ * to the menu loop. This is best-effort: forcibly terminating the child to return
677
+ * to the menu may leave the terminal in a non-ideal state; the existing
678
+ * "Returned from <bin>." message and menu re-render happen on return regardless.
679
+ *
577
680
  * On exit (any exit code), control returns to the myshell-tools menu.
578
681
  */
579
682
  async function runRawProviderSession(out, readLine, env) {
@@ -598,15 +701,67 @@ async function runRawProviderSession(out, readLine, env) {
598
701
  }
599
702
  const bin = selected.bin;
600
703
  out.write(`\nLaunching ${bin} — press Ctrl+C or type /exit inside ${bin} to return.\n`);
704
+ // Best-effort escape hint (Unix only — on Windows we skip the handler).
705
+ if (process.platform !== 'win32') {
706
+ out.write('(Ctrl+C twice quickly → back to the myshell menu)\n');
707
+ }
601
708
  // stdio:'inherit' hands the terminal to the native CLI so its interactive
602
709
  // session runs in place. reject:false so we return to menu on any exit code.
603
- await execa(bin, [], { stdio: 'inherit', reject: false });
710
+ const subprocess = execa(bin, [], { stdio: 'inherit', reject: false });
711
+ // Unix-only: register the rapid-double-Ctrl+C escape handler.
712
+ // On Windows: skip entirely — SIGINT/process-group semantics differ and
713
+ // forced interception risks a broken console. Behaviour is as before today.
714
+ if (process.platform !== 'win32') {
715
+ const INTERRUPT_WINDOW_MS = 1_500;
716
+ const interruptTimes = [];
717
+ const rawSigintHandler = () => {
718
+ const now = Date.now();
719
+ interruptTimes.push(now);
720
+ const count = countRecentInterrupts(interruptTimes, now, INTERRUPT_WINDOW_MS);
721
+ // count === 1: do nothing — let the single Ctrl+C reach the child via the
722
+ // terminal's foreground-group delivery. Do NOT kill or write anything here.
723
+ if (shouldEscapeRawSession(count)) {
724
+ // Rapid double press → user wants to return to the menu.
725
+ out.write('\n[info] Returning to menu…\n');
726
+ subprocess.kill('SIGTERM');
727
+ }
728
+ };
729
+ process.on('SIGINT', rawSigintHandler);
730
+ try {
731
+ await subprocess;
732
+ }
733
+ finally {
734
+ process.removeListener('SIGINT', rawSigintHandler);
735
+ }
736
+ }
737
+ else {
738
+ // Windows: no SIGINT handler — await the child normally.
739
+ await subprocess;
740
+ }
604
741
  out.write(`\nReturned from ${bin}.\n`);
605
742
  }
606
743
  // ---------------------------------------------------------------------------
607
744
  // Chat loop
608
745
  // ---------------------------------------------------------------------------
609
- async function runChatLoop(ctx, mutableCtx, convId, out, readLine) {
746
+ /**
747
+ * Run the interactive chat loop for a single conversation.
748
+ *
749
+ * Returns `'menu'` when the user exits normally (/back, /exit, EOF, or 2×Ctrl+C)
750
+ * and `'exit'` when the user presses Ctrl+C three times within the 1 500 ms window,
751
+ * signalling that the entire app should quit.
752
+ *
753
+ * The SIGINT handler uses a press-counting model (window ~1 500 ms, timestamps from
754
+ * `ctx.clock.now()`):
755
+ * 1 press while a task runs → cancel the task, stay in chat.
756
+ * 1 press at the prompt → print a hint, stay in chat.
757
+ * 2 presses within the window → abort any running task, break to menu.
758
+ * 3 presses within the window → abort any running task, break and signal exit.
759
+ *
760
+ * The handler is registered on entry and removed in the `finally` block so it
761
+ * never leaks between chat sessions. NO process.exit() is called here; cli.ts
762
+ * owns process lifetime.
763
+ */
764
+ async function runChatLoop(ctx, mutableCtx, convId, out, readLine, loginFn, detectEnvironmentFn) {
610
765
  // Print a short recap of the conversation (last entry) if history exists
611
766
  const history = await ctx.store.load(convId);
612
767
  if (history.length > 0) {
@@ -617,22 +772,78 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine) {
617
772
  }
618
773
  out.write(prompt('task or /help', out.color) + '\n');
619
774
  let currentAc = null;
620
- // Handle SIGINT: cancel in-flight task or return to menu
775
+ // Interrupt timestamps populated on each SIGINT; checked against the
776
+ // 1 500 ms sliding window. Using ctx.clock.now() (not Date.now) so tests
777
+ // can drive time with a fake clock.
778
+ const interruptTimes = [];
779
+ const INTERRUPT_WINDOW_MS = 1_500;
780
+ // The 'exit' signal is communicated from the SIGINT handler to the main
781
+ // loop via this flag (the handler can't break the outer while directly).
782
+ let shouldExit = false;
783
+ // Handle Ctrl+C with the press-counting model.
621
784
  const sigintHandler = () => {
622
- if (currentAc !== null) {
623
- currentAc.abort();
624
- out.write('\n[warn] Task cancelled.\n');
785
+ const now = ctx.clock.now();
786
+ interruptTimes.push(now);
787
+ const count = countRecentInterrupts(interruptTimes, now, INTERRUPT_WINDOW_MS);
788
+ const action = interpretInterrupt(count, currentAc !== null);
789
+ if (action === 'cancel-task') {
790
+ currentAc?.abort();
625
791
  currentAc = null;
792
+ out.write('\n[warn] Task cancelled. (Ctrl+C again → menu, ×3 → exit)\n');
793
+ }
794
+ else if (action === 'hint') {
795
+ out.write('\n[info] Ctrl+C again → back to menu, ×3 → exit to shell.\n');
796
+ }
797
+ else if (action === 'to-menu') {
798
+ // Abort any running task, then tell the main loop to break back to menu.
799
+ if (currentAc !== null) {
800
+ currentAc.abort();
801
+ currentAc = null;
802
+ }
803
+ // Signal the main loop; the loop checks this flag after each await.
804
+ shouldExit = false;
805
+ // We can't directly break the loop from an event handler, so we set a
806
+ // flag and resolve any pending readLine by closing (the loop checks the
807
+ // flag). In practice the loop is either awaiting readLine() or runTask().
808
+ // For the readLine case the user typed nothing yet — we need to interrupt
809
+ // that await. We use a shared resolver pattern: see loopBreaker below.
810
+ loopBreaker?.('menu');
626
811
  }
627
812
  else {
628
- out.write('\n[info] Use /back or /exit to return to the menu.\n');
813
+ // exit-app
814
+ if (currentAc !== null) {
815
+ currentAc.abort();
816
+ currentAc = null;
817
+ }
818
+ shouldExit = true;
819
+ loopBreaker?.('exit');
629
820
  }
630
821
  };
822
+ // loopBreaker lets the SIGINT handler resolve the loop-level promise so the
823
+ // `while (true)` can break immediately even when awaiting readLine().
824
+ let loopBreaker = null;
631
825
  process.on('SIGINT', sigintHandler);
826
+ let loopResult = 'menu';
632
827
  try {
633
828
  while (true) {
634
829
  out.write('myshell-tools> ');
635
- const line = await readLine();
830
+ // Race readLine() against a loopBreak signal from the SIGINT handler.
831
+ // When Ctrl+C fires (to-menu or exit-app), loopBreaker is called with the
832
+ // desired result, which wins the race and breaks the loop.
833
+ const line = await new Promise((resolve) => {
834
+ loopBreaker = resolve;
835
+ readLine().then(resolve).catch(() => resolve(null));
836
+ });
837
+ loopBreaker = null;
838
+ // SIGINT-driven exit signals
839
+ if (line === 'menu') {
840
+ loopResult = 'menu';
841
+ break;
842
+ }
843
+ if (line === 'exit') {
844
+ loopResult = 'exit';
845
+ break;
846
+ }
636
847
  // EOF → exit the chat loop gracefully
637
848
  if (line === null)
638
849
  break;
@@ -658,15 +869,16 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine) {
658
869
  // Build per-provider advertised model sets from the live env so route()
659
870
  // can prefer a model the CLI actually advertises. Only include installed
660
871
  // providers (exactOptionalPropertyTypes is ON).
872
+ // Use mutableCtx.env (not ctx.env) so post-login re-detect is reflected.
661
873
  const menuAvailableModels = {};
662
- if (ctx.env.claude.installed && ctx.env.claude.availableModels.length > 0) {
663
- menuAvailableModels['claude'] = ctx.env.claude.availableModels;
874
+ if (mutableCtx.env.claude.installed && mutableCtx.env.claude.availableModels.length > 0) {
875
+ menuAvailableModels['claude'] = mutableCtx.env.claude.availableModels;
664
876
  }
665
- if (ctx.env.codex.installed && ctx.env.codex.availableModels.length > 0) {
666
- menuAvailableModels['codex'] = ctx.env.codex.availableModels;
877
+ if (mutableCtx.env.codex.installed && mutableCtx.env.codex.availableModels.length > 0) {
878
+ menuAvailableModels['codex'] = mutableCtx.env.codex.availableModels;
667
879
  }
668
- if (ctx.env.opencode.installed && ctx.env.opencode.availableModels.length > 0) {
669
- menuAvailableModels['opencode'] = ctx.env.opencode.availableModels;
880
+ if (mutableCtx.env.opencode.installed && mutableCtx.env.opencode.availableModels.length > 0) {
881
+ menuAvailableModels['opencode'] = mutableCtx.env.opencode.availableModels;
670
882
  }
671
883
  const deps = {
672
884
  clock: ctx.clock,
@@ -682,22 +894,63 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine) {
682
894
  };
683
895
  const ac = new AbortController();
684
896
  currentAc = ac;
685
- await runTask(line, deps, out, ac.signal);
897
+ const result = await runTask(line, deps, out, ac.signal);
686
898
  currentAc = null;
899
+ // Check for a loopBreaker signal that fired during the task (e.g. 3×Ctrl+C
900
+ // while runTask was awaited — the abort fires and the flag is set).
901
+ if (shouldExit) {
902
+ loopResult = 'exit';
903
+ break;
904
+ }
905
+ // Inline re-login on auth failure: offer to sign in and retry once.
906
+ if (result.final !== undefined &&
907
+ !result.final.success &&
908
+ result.final.errorCategory === 'auth' &&
909
+ result.final.provider !== undefined) {
910
+ const failingProvider = result.final.provider;
911
+ out.write(`\n[warn] ${failingProvider} isn't signed in.\n`);
912
+ out.write(`Sign in to ${failingProvider} now and retry? (Y/n) `);
913
+ const ans = await readLine();
914
+ if (parseYesNo(ans, true)) {
915
+ await loginFn(out, failingProvider, { readLine });
916
+ mutableCtx.env = await detectEnvironmentFn();
917
+ // Retry the same task once.
918
+ const retryAc = new AbortController();
919
+ currentAc = retryAc;
920
+ const retryResult = await runTask(line, deps, out, retryAc.signal);
921
+ currentAc = null;
922
+ if (shouldExit) {
923
+ loopResult = 'exit';
924
+ break;
925
+ }
926
+ // If still auth failure after retry, inform and continue to prompt.
927
+ if (retryResult.final !== undefined &&
928
+ !retryResult.final.success &&
929
+ retryResult.final.errorCategory === 'auth') {
930
+ out.write(`\n[warn] Still not signed in to ${failingProvider}. Returning to prompt.\n`);
931
+ }
932
+ }
933
+ }
687
934
  }
688
935
  }
689
936
  finally {
690
937
  process.removeListener('SIGINT', sigintHandler);
938
+ loopBreaker = null;
691
939
  }
940
+ return loopResult;
692
941
  }
693
942
  // ---------------------------------------------------------------------------
694
943
  // Main screen render
695
944
  // ---------------------------------------------------------------------------
696
- async function renderMainScreen(ctx, mutableCtx, metas, out) {
945
+ async function renderMainScreen(ctx, mutableCtx, metas, out, updateInfo) {
697
946
  out.write('\n');
698
947
  // Header box — always box(), 🧠 emoji, real provider data
699
948
  const headerLines = renderHeaderLines(mutableCtx.env, ctx.version);
700
949
  out.write(box(`🧠 myshell-tools v${ctx.version}`, headerLines) + '\n\n');
950
+ // Update banner — only shown when a newer version is genuinely available
951
+ if (updateInfo?.updateAvailable === true && updateInfo.latest !== null) {
952
+ out.write(` ▲ Update available: ${updateInfo.current} → ${updateInfo.latest} (press u)\n\n`);
953
+ }
701
954
  // Budget line — real ledger data, never fabricated
702
955
  const entries = await readLedger(ctx.cwd);
703
956
  const spend = summarizeSpend(entries, ctx.clock.isoNow());
@@ -727,6 +980,10 @@ async function renderMainScreen(ctx, mutableCtx, metas, out) {
727
980
  { key: 'k', label: 'Login Codex', section: 'Auth' },
728
981
  { key: 'o', label: opencodeLabel, section: 'Auth' },
729
982
  ];
983
+ // [u] Update now — shown only when a newer version is actually available
984
+ const updateEntry = updateInfo?.updateAvailable === true && updateInfo.latest !== null
985
+ ? [{ key: 'u', label: `Update now (→ ${updateInfo.latest})`, section: 'Options' }]
986
+ : [];
730
987
  // Menu — sectioned via menu()
731
988
  out.write(menu([
732
989
  { key: 'c', label: 'Continue last conversation', section: 'Conversations' },
@@ -739,6 +996,7 @@ async function renderMainScreen(ctx, mutableCtx, metas, out) {
739
996
  { key: 's', label: 'Settings', section: 'Options' },
740
997
  { key: 'd', label: 'Doctor', section: 'Options' },
741
998
  { key: '$', label: 'Cost', section: 'Options' },
999
+ ...updateEntry,
742
1000
  { key: 'q', label: 'Quit', section: 'Options' },
743
1001
  ]) + '\n\n');
744
1002
  }
@@ -766,6 +1024,9 @@ export async function startMenu(ctx, out) {
766
1024
  const installProviderFn = ctx.installProvider !== undefined ? ctx.installProvider : installProvider;
767
1025
  const loginFn = ctx.login !== undefined ? ctx.login : runLogin;
768
1026
  const detectEnvironmentFn = ctx.detectEnvironment !== undefined ? ctx.detectEnvironment : detectEnvironment;
1027
+ const checkForUpdateFn = ctx.checkForUpdate;
1028
+ const updateSelfFn = ctx.updateSelf;
1029
+ const relaunchFn = ctx.relaunch;
769
1030
  // Build the readLine function — either injected (for tests) or backed by a
770
1031
  // real readline interface driven by the event-driven LineReader queue.
771
1032
  let readLine;
@@ -803,10 +1064,33 @@ export async function startMenu(ctx, out) {
803
1064
  // status (e.g. codex now "ready" if the user signed in during setup).
804
1065
  mutableCtx.env = await detectEnvironmentFn();
805
1066
  }
1067
+ // ---- Update check (once per launch, after onboarding) --------------------
1068
+ // Fast path: uses the cache when fresh, so it never hangs.
1069
+ // Injected seam allows tests to stay hermetic (no real npm registry calls).
1070
+ let updateInfo;
1071
+ if (checkForUpdateFn !== undefined) {
1072
+ updateInfo = await checkForUpdateFn().catch(() => undefined);
1073
+ }
1074
+ // ---- Opt-in auto-update at launch ----------------------------------------
1075
+ // Guard: only runs once; requires both the update and relaunch seams to be wired.
1076
+ if (mutableCtx.config.autoUpdate === true &&
1077
+ updateInfo?.updateAvailable === true &&
1078
+ updateInfo.latest !== null &&
1079
+ updateSelfFn !== undefined) {
1080
+ out.write(`▲ Auto-updating ${updateInfo.current} → ${updateInfo.latest}…\n`);
1081
+ const ok = await updateSelfFn(out).catch(() => false);
1082
+ if (ok) {
1083
+ if (relaunchFn !== undefined) {
1084
+ await relaunchFn().catch(() => 1);
1085
+ }
1086
+ return; // Relinquish control to the freshly-installed version.
1087
+ }
1088
+ out.write('Auto-update failed — continuing with current version.\n');
1089
+ }
806
1090
  // ---- B. Main screen loop -------------------------------------------------
807
1091
  while (true) {
808
1092
  const metas = await ctx.store.list();
809
- await renderMainScreen(ctx, mutableCtx, metas, out);
1093
+ await renderMainScreen(ctx, mutableCtx, metas, out, updateInfo);
810
1094
  out.write('> ');
811
1095
  const key = await readLine();
812
1096
  // ---- EOF / close — exit gracefully (FIX 1: no ERR_USE_AFTER_CLOSE) ----
@@ -823,7 +1107,9 @@ export async function startMenu(ctx, out) {
823
1107
  const firstMsg = await readLine();
824
1108
  if (firstMsg !== null && firstMsg.length > 0) {
825
1109
  const meta = await ctx.store.create(firstMsg);
826
- await runChatLoop(ctx, mutableCtx, meta.id, out, readLine);
1110
+ const chatResult = await runChatLoop(ctx, mutableCtx, meta.id, out, readLine, loginFn, detectEnvironmentFn);
1111
+ if (chatResult === 'exit')
1112
+ break;
827
1113
  }
828
1114
  continue;
829
1115
  }
@@ -832,7 +1118,9 @@ export async function startMenu(ctx, out) {
832
1118
  const all = await ctx.store.list();
833
1119
  const latest = all[0];
834
1120
  if (latest !== undefined) {
835
- await runChatLoop(ctx, mutableCtx, latest.id, out, readLine);
1121
+ const chatResult = await runChatLoop(ctx, mutableCtx, latest.id, out, readLine, loginFn, detectEnvironmentFn);
1122
+ if (chatResult === 'exit')
1123
+ break;
836
1124
  }
837
1125
  else {
838
1126
  out.write('No conversations yet. Press n to start one.\n');
@@ -844,7 +1132,9 @@ export async function startMenu(ctx, out) {
844
1132
  if (!Number.isNaN(digit) && digit >= 1 && digit <= 9) {
845
1133
  const target = metas[digit - 1];
846
1134
  if (target !== undefined) {
847
- await runChatLoop(ctx, mutableCtx, target.id, out, readLine);
1135
+ const chatResult = await runChatLoop(ctx, mutableCtx, target.id, out, readLine, loginFn, detectEnvironmentFn);
1136
+ if (chatResult === 'exit')
1137
+ break;
848
1138
  }
849
1139
  else {
850
1140
  out.write(`No conversation at position ${digit}.\n`);
@@ -858,7 +1148,9 @@ export async function startMenu(ctx, out) {
858
1148
  }
859
1149
  // ---- [i] Import a native conversation -----------------------------------
860
1150
  if (key === 'i') {
861
- await runImportNative(ctx, mutableCtx, out, readLine);
1151
+ const importResult = await runImportNative(ctx, mutableCtx, out, readLine, loginFn, detectEnvironmentFn);
1152
+ if (importResult === 'exit')
1153
+ break;
862
1154
  continue;
863
1155
  }
864
1156
  // ---- [r] Open a raw provider session ------------------------------------
@@ -908,6 +1200,18 @@ export async function startMenu(ctx, out) {
908
1200
  mutableCtx.env = await detectEnvironmentFn();
909
1201
  continue;
910
1202
  }
1203
+ // ---- [u] Update now -----------------------------------------------------
1204
+ // Only active when an update is actually available and the seam is wired.
1205
+ if (key === 'u' && updateInfo?.updateAvailable === true && updateSelfFn !== undefined) {
1206
+ const ok = await updateSelfFn(out).catch(() => false);
1207
+ if (ok && updateInfo.latest !== null) {
1208
+ out.write(`✓ Updated to ${updateInfo.latest} — restart myshell-tools to use it.\n`);
1209
+ }
1210
+ else if (!ok) {
1211
+ out.write('Update failed. Run: npm install -g myshell-tools@latest\n');
1212
+ }
1213
+ continue;
1214
+ }
911
1215
  // ---- [s] Settings -------------------------------------------------------
912
1216
  if (key === 's') {
913
1217
  await runSettings(ctx, mutableCtx, out, readLine);