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.
- package/CHANGELOG.md +38 -0
- package/README.md +34 -3
- package/dist/cli.js +35 -4
- package/dist/cli.js.map +1 -1
- package/dist/commands/doctor.d.ts +36 -1
- package/dist/commands/doctor.js +130 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/install.d.ts +5 -0
- package/dist/commands/install.js +16 -0
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/login.d.ts +8 -5
- package/dist/commands/login.js +143 -44
- package/dist/commands/login.js.map +1 -1
- package/dist/core/classify.d.ts +20 -4
- package/dist/core/classify.js +241 -43
- package/dist/core/classify.js.map +1 -1
- package/dist/core/orchestrate.js +115 -85
- package/dist/core/orchestrate.js.map +1 -1
- package/dist/core/types.d.ts +10 -0
- package/dist/infra/config.d.ts +6 -0
- package/dist/infra/config.js.map +1 -1
- package/dist/infra/credentials.d.ts +28 -0
- package/dist/infra/credentials.js +52 -1
- package/dist/infra/credentials.js.map +1 -1
- package/dist/infra/update-check.d.ts +67 -0
- package/dist/infra/update-check.js +181 -0
- package/dist/infra/update-check.js.map +1 -0
- package/dist/interface/menu.d.ts +69 -0
- package/dist/interface/menu.js +334 -30
- package/dist/interface/menu.js.map +1 -1
- package/dist/interface/render.js +8 -1
- package/dist/interface/render.js.map +1 -1
- package/dist/interface/run.d.ts +12 -3
- package/dist/interface/run.js +3 -3
- package/dist/interface/run.js.map +1 -1
- package/package.json +1 -1
package/dist/interface/menu.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
663
|
-
menuAvailableModels['claude'] =
|
|
874
|
+
if (mutableCtx.env.claude.installed && mutableCtx.env.claude.availableModels.length > 0) {
|
|
875
|
+
menuAvailableModels['claude'] = mutableCtx.env.claude.availableModels;
|
|
664
876
|
}
|
|
665
|
-
if (
|
|
666
|
-
menuAvailableModels['codex'] =
|
|
877
|
+
if (mutableCtx.env.codex.installed && mutableCtx.env.codex.availableModels.length > 0) {
|
|
878
|
+
menuAvailableModels['codex'] = mutableCtx.env.codex.availableModels;
|
|
667
879
|
}
|
|
668
|
-
if (
|
|
669
|
-
menuAvailableModels['opencode'] =
|
|
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);
|