myshell-tools 2.15.0 → 3.2.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 +83 -1
- package/README.md +39 -44
- package/dist/cli.js +33 -22
- package/dist/cli.js.map +1 -1
- package/dist/commands/cost.js +44 -21
- package/dist/commands/cost.js.map +1 -1
- package/dist/commands/doctor.d.ts +17 -4
- package/dist/commands/doctor.js +62 -35
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/login.d.ts +18 -10
- package/dist/commands/login.js +81 -109
- package/dist/commands/login.js.map +1 -1
- package/dist/core/json-envelope.js +31 -3
- package/dist/core/json-envelope.js.map +1 -1
- package/dist/core/native-session.d.ts +57 -0
- package/dist/core/native-session.js +68 -0
- package/dist/core/native-session.js.map +1 -0
- package/dist/core/orchestrate.js +106 -43
- package/dist/core/orchestrate.js.map +1 -1
- package/dist/core/types.d.ts +26 -0
- package/dist/infra/atomic.d.ts +9 -1
- package/dist/infra/atomic.js +12 -2
- package/dist/infra/atomic.js.map +1 -1
- package/dist/infra/config.d.ts +8 -0
- package/dist/infra/config.js.map +1 -1
- package/dist/infra/credentials.d.ts +69 -3
- package/dist/infra/credentials.js +118 -9
- package/dist/infra/credentials.js.map +1 -1
- package/dist/infra/health.d.ts +57 -0
- package/dist/infra/health.js +96 -0
- package/dist/infra/health.js.map +1 -0
- package/dist/infra/insights.d.ts +14 -0
- package/dist/infra/insights.js +31 -0
- package/dist/infra/insights.js.map +1 -1
- package/dist/interface/menu.d.ts +70 -5
- package/dist/interface/menu.js +292 -88
- package/dist/interface/menu.js.map +1 -1
- package/dist/interface/render.js +20 -13
- package/dist/interface/render.js.map +1 -1
- package/dist/providers/claude.d.ts +24 -8
- package/dist/providers/claude.js +77 -15
- package/dist/providers/claude.js.map +1 -1
- package/dist/providers/codex-parse.js +14 -2
- package/dist/providers/codex-parse.js.map +1 -1
- package/dist/providers/codex.d.ts +13 -1
- package/dist/providers/codex.js +19 -8
- package/dist/providers/codex.js.map +1 -1
- package/dist/providers/detect.js +22 -1
- package/dist/providers/detect.js.map +1 -1
- package/dist/providers/port.d.ts +15 -0
- package/dist/providers/registry.d.ts +8 -4
- package/dist/providers/registry.js +7 -6
- package/dist/providers/registry.js.map +1 -1
- package/dist/ui/help.d.ts +17 -0
- package/dist/ui/help.js +106 -0
- package/dist/ui/help.js.map +1 -0
- package/package.json +1 -1
package/dist/interface/menu.js
CHANGED
|
@@ -19,17 +19,19 @@ import readline from 'node:readline';
|
|
|
19
19
|
import { execa } from 'execa';
|
|
20
20
|
import { saveConfig } from '../infra/config.js';
|
|
21
21
|
import { readLedger } from '../infra/ledger.js';
|
|
22
|
-
import { summarizeSpend,
|
|
22
|
+
import { summarizeSpend, formatTokens } from '../infra/insights.js';
|
|
23
23
|
import { detectEnvironment, getInstallCommand } from '../providers/detect.js';
|
|
24
24
|
import { installProvider, installCommandFor } from '../providers/install.js';
|
|
25
25
|
import { listNativeSessions, importNativeSession } from '../providers/native-sessions.js';
|
|
26
26
|
import { DEFAULT_POLICY, POLICY_PRESETS } from '../core/policy.js';
|
|
27
|
+
import { planNativeSession } from '../core/native-session.js';
|
|
27
28
|
import { runTask } from './run.js';
|
|
28
29
|
import { runLogin } from '../commands/login.js';
|
|
29
30
|
import { runDoctor } from '../commands/doctor.js';
|
|
30
31
|
import { runCost } from '../commands/cost.js';
|
|
31
32
|
import { runInstall } from '../commands/install.js';
|
|
32
33
|
import { box, separator, menu, prompt } from '../ui/tui.js';
|
|
34
|
+
import { loadClaudeTokenCapturedAt, claudeTokenStatus } from '../infra/credentials.js';
|
|
33
35
|
// ---------------------------------------------------------------------------
|
|
34
36
|
// Pure helpers — exported for unit tests
|
|
35
37
|
// ---------------------------------------------------------------------------
|
|
@@ -130,6 +132,53 @@ export function relativeTime(thenMs, nowMs) {
|
|
|
130
132
|
const days = Math.floor(diffMs / (24 * 60 * 60 * 1000));
|
|
131
133
|
return `${days}d ago`;
|
|
132
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Build the short version-status suffix shown next to the version number in the
|
|
137
|
+
* header title — so the user always knows, at a glance, whether they are current.
|
|
138
|
+
*
|
|
139
|
+
* Pure — no I/O. Returns a leading-space string ready to append to the title:
|
|
140
|
+
* - update available + known latest → ` → 3.1.0 available`
|
|
141
|
+
* - up to date (latest known, no update) → ` (latest)`
|
|
142
|
+
* - check failed / offline (latest unknown) → `` (claim nothing)
|
|
143
|
+
*
|
|
144
|
+
* @param updateInfo - Result of the update check, or undefined when not run.
|
|
145
|
+
*/
|
|
146
|
+
export function versionStatusLabel(updateInfo) {
|
|
147
|
+
if (updateInfo === undefined)
|
|
148
|
+
return '';
|
|
149
|
+
if (updateInfo.updateAvailable && updateInfo.latest !== null) {
|
|
150
|
+
return ` → ${updateInfo.latest} available`;
|
|
151
|
+
}
|
|
152
|
+
if (updateInfo.latest !== null)
|
|
153
|
+
return ' (latest)';
|
|
154
|
+
return '';
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Detect whether myshell-tools is running via `npx` rather than a global install.
|
|
158
|
+
*
|
|
159
|
+
* Pure — takes the running script path (process.argv[1]) and the environment.
|
|
160
|
+
* npx executes packages from a cache directory containing a `_npx` segment
|
|
161
|
+
* (e.g. ~/.npm/_npx/<hash>/node_modules/myshell-tools/dist/cli.js), so the
|
|
162
|
+
* presence of that segment in the script path is the reliable signal. Handles
|
|
163
|
+
* both POSIX and Windows separators. Never throws.
|
|
164
|
+
*
|
|
165
|
+
* Why it matters: self-update runs `npm install -g`, which an npx invocation
|
|
166
|
+
* will ignore on the next run (npx re-serves its own cache). So under npx we
|
|
167
|
+
* skip silent auto-update and instead tell the user to install globally.
|
|
168
|
+
*
|
|
169
|
+
* @param scriptPath - The running script path (typically process.argv[1]).
|
|
170
|
+
* @param env - Environment to read npm_* hints from.
|
|
171
|
+
*/
|
|
172
|
+
export function isRunningUnderNpx(scriptPath, env) {
|
|
173
|
+
if (scriptPath !== undefined && (scriptPath.includes('/_npx/') || scriptPath.includes('\\_npx\\'))) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
const execpath = env['npm_execpath'];
|
|
177
|
+
if (execpath !== undefined && (execpath.includes('/_npx/') || execpath.includes('\\_npx\\'))) {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
133
182
|
/**
|
|
134
183
|
* Build the header box lines (provider status) from real EnvironmentStatus.
|
|
135
184
|
* Returns string[] safe to pass as the `lines` arg to box().
|
|
@@ -139,8 +188,12 @@ export function relativeTime(thenMs, nowMs) {
|
|
|
139
188
|
* ⚠️ when ps.installed && !ps.authenticated (append " not signed in")
|
|
140
189
|
* ❌ when !ps.installed (append install command)
|
|
141
190
|
* Plan label appended when ps.plan is non-null (e.g. " (Max x5)").
|
|
191
|
+
*
|
|
192
|
+
* @param claudeToken - Optional pre-computed token lifetime status. When the token
|
|
193
|
+
* is near expiry or expired, ONE concise warning line is appended. Computed by
|
|
194
|
+
* the caller (startMenu) so this function stays pure and I/O-free.
|
|
142
195
|
*/
|
|
143
|
-
export function renderHeaderLines(env, _version) {
|
|
196
|
+
export function renderHeaderLines(env, _version, claudeToken) {
|
|
144
197
|
const lines = [];
|
|
145
198
|
for (const ps of [env.claude, env.codex]) {
|
|
146
199
|
const planSuffix = ps.plan != null ? ` (${ps.plan})` : '';
|
|
@@ -160,30 +213,46 @@ export function renderHeaderLines(env, _version) {
|
|
|
160
213
|
const ps = env.opencode;
|
|
161
214
|
const planSuffix = ps.plan != null ? ` (${ps.plan})` : '';
|
|
162
215
|
if (ps.authenticated) {
|
|
163
|
-
|
|
216
|
+
// Be explicit that "ready" is based on free models, not a credential probe.
|
|
217
|
+
lines.push(`✅ ${ps.id}: ready (free models)${planSuffix}`);
|
|
164
218
|
}
|
|
165
219
|
else {
|
|
166
220
|
lines.push(`⚠️ ${ps.id}: not signed in${planSuffix}`);
|
|
167
221
|
}
|
|
168
222
|
}
|
|
223
|
+
// Token expiry warning — only when near-expiry or expired (not on every launch).
|
|
224
|
+
if (claudeToken != null && (claudeToken.expired || claudeToken.nearExpiry)) {
|
|
225
|
+
if (claudeToken.expired) {
|
|
226
|
+
lines.push(`⚠️ claude token EXPIRED — run: myshell-tools login claude --code`);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
lines.push(`⚠️ claude token expires in ${claudeToken.daysLeft} days — run: myshell-tools login claude --code`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
169
232
|
return lines;
|
|
170
233
|
}
|
|
171
234
|
/**
|
|
172
|
-
* Render the
|
|
235
|
+
* Render the activity status line shown beneath the provider header.
|
|
173
236
|
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* shows
|
|
237
|
+
* This is a SUBSCRIPTION tool, not an API-key tool — per-token dollar figures
|
|
238
|
+
* don't map to flat subscription billing and read as misleading bloat, so the
|
|
239
|
+
* always-on line shows REAL, measured signals only: task count and tokens. The
|
|
240
|
+
* estimated-dollar view lives on-demand in `myshell-tools cost`, clearly
|
|
241
|
+
* captioned there as an API-equivalent (not your actual bill).
|
|
242
|
+
*
|
|
243
|
+
* Uses real numbers only — all values come from the SpendSummary derived from
|
|
244
|
+
* `readLedger`. No digit-% literals appear here.
|
|
177
245
|
*
|
|
178
246
|
* @param spend - Output of summarizeSpend() over real ledger entries.
|
|
179
247
|
* @param color - When false, no ANSI escape codes are emitted.
|
|
180
248
|
*/
|
|
181
249
|
export function renderBudgetLine(spend, _color) {
|
|
182
250
|
if (spend.calls === 0) {
|
|
183
|
-
return '
|
|
251
|
+
return 'No runs yet — press n to start';
|
|
184
252
|
}
|
|
185
|
-
const
|
|
186
|
-
const
|
|
253
|
+
const taskWord = spend.calls === 1 ? 'task' : 'tasks';
|
|
254
|
+
const todayPart = 'Today: ' + String(spend.calls) + ' ' + taskWord + ' · ' + formatTokens(spend.todayTokens) + ' tokens';
|
|
255
|
+
const totalPart = formatTokens(spend.totalTokens) + ' tokens all-time';
|
|
187
256
|
return todayPart + ' · ' + totalPart;
|
|
188
257
|
}
|
|
189
258
|
/**
|
|
@@ -313,6 +382,8 @@ async function runWelcome(ctx, out, readLine, mutableConfig, installProviderFn,
|
|
|
313
382
|
let env = ctx.env;
|
|
314
383
|
const headerLines = renderHeaderLines(env, ctx.version);
|
|
315
384
|
out.write('\n' + box(`🧠 myshell-tools v${ctx.version} — Setup`, headerLines) + '\n\n');
|
|
385
|
+
// ---- Orientation header --------------------------------------------------
|
|
386
|
+
out.write('Quick setup — a few questions, ~30 seconds. Press Enter to accept the [Capitalized] default.\n\n');
|
|
316
387
|
// ---- Offer to install any missing provider (claude / codex) --------------
|
|
317
388
|
// Consent is required: we ask once per missing provider.
|
|
318
389
|
// Display: (Y/n) — default YES, so Enter installs; explicit n skips.
|
|
@@ -369,14 +440,12 @@ async function runWelcome(ctx, out, readLine, mutableConfig, installProviderFn,
|
|
|
369
440
|
await loginFn(out, id, { readLine });
|
|
370
441
|
}
|
|
371
442
|
}
|
|
372
|
-
// ---- Mode
|
|
373
|
-
|
|
374
|
-
out.write(' [
|
|
375
|
-
|
|
376
|
-
out.write('> ');
|
|
377
|
-
const key = await readLine();
|
|
443
|
+
// ---- Mode selection — single collapsed prompt ----------------------------
|
|
444
|
+
// Accepts 1/2/3 directly; Enter (or empty) keeps current default (balanced).
|
|
445
|
+
out.write('\nMode — [1] cost-saver [2] balanced (default) [3] quality-first (Enter = balanced): ');
|
|
446
|
+
const modeKey = await readLine();
|
|
378
447
|
// EOF during setup — save bare onboarded config and return
|
|
379
|
-
if (
|
|
448
|
+
if (modeKey === null) {
|
|
380
449
|
const saved = {
|
|
381
450
|
onboarded: true,
|
|
382
451
|
setAsDefault: false,
|
|
@@ -385,11 +454,19 @@ async function runWelcome(ctx, out, readLine, mutableConfig, installProviderFn,
|
|
|
385
454
|
await saveConfig(saved);
|
|
386
455
|
return saved;
|
|
387
456
|
}
|
|
388
|
-
let
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
457
|
+
let newMode = mutableConfig.mode;
|
|
458
|
+
if (modeKey === '1')
|
|
459
|
+
newMode = 'cost-saver';
|
|
460
|
+
else if (modeKey === '2')
|
|
461
|
+
newMode = 'balanced';
|
|
462
|
+
else if (modeKey === '3')
|
|
463
|
+
newMode = 'quality-first';
|
|
464
|
+
// Enter/empty/anything else → keep current (balanced default)
|
|
465
|
+
const updated = {
|
|
466
|
+
onboarded: mutableConfig.onboarded,
|
|
467
|
+
setAsDefault: mutableConfig.setAsDefault,
|
|
468
|
+
...(newMode !== undefined ? { mode: newMode } : {}),
|
|
469
|
+
};
|
|
393
470
|
// Default is NO for set-as-default — require explicit 'y' to enable.
|
|
394
471
|
out.write('Set myshell-tools as your default shell tool? (y/N) ');
|
|
395
472
|
const defaultAns = await readLine();
|
|
@@ -440,6 +517,9 @@ async function runModeSelect(config, out, readLine) {
|
|
|
440
517
|
onboarded: config.onboarded,
|
|
441
518
|
setAsDefault: config.setAsDefault,
|
|
442
519
|
...(newMode !== undefined ? { mode: newMode } : {}),
|
|
520
|
+
// Preserve other prefs so changing mode doesn't silently reset them.
|
|
521
|
+
...(config.autoUpdate === false ? { autoUpdate: false } : {}),
|
|
522
|
+
...(config.nativeSessions === true ? { nativeSessions: true } : {}),
|
|
443
523
|
};
|
|
444
524
|
await saveConfig(updated);
|
|
445
525
|
out.write(`Mode set to: ${newMode ?? 'balanced'}\n`);
|
|
@@ -460,6 +540,9 @@ async function toggleDefaultShell(config, out) {
|
|
|
460
540
|
onboarded: config.onboarded,
|
|
461
541
|
setAsDefault,
|
|
462
542
|
...(config.mode !== undefined ? { mode: config.mode } : {}),
|
|
543
|
+
// Preserve other prefs so toggling default-shell doesn't silently reset them.
|
|
544
|
+
...(config.autoUpdate === false ? { autoUpdate: false } : {}),
|
|
545
|
+
...(config.nativeSessions === true ? { nativeSessions: true } : {}),
|
|
463
546
|
};
|
|
464
547
|
await saveConfig(updated);
|
|
465
548
|
return updated;
|
|
@@ -471,6 +554,7 @@ async function runSettings(_ctx, mutableCtx, out, readLine) {
|
|
|
471
554
|
` [1] Mode: ${cfg.mode ?? 'balanced'}`,
|
|
472
555
|
` [2] Set as default shell: ${cfg.setAsDefault ? 'on' : 'off'}`,
|
|
473
556
|
` [3] Auto-update: ${cfg.autoUpdate !== false ? 'on' : 'off'}`,
|
|
557
|
+
` [4] Native sessions (experimental): ${cfg.nativeSessions === true ? 'on' : 'off'}`,
|
|
474
558
|
'',
|
|
475
559
|
' [Enter] Back',
|
|
476
560
|
'',
|
|
@@ -490,6 +574,9 @@ async function runSettings(_ctx, mutableCtx, out, readLine) {
|
|
|
490
574
|
else if (key === '3') {
|
|
491
575
|
mutableCtx.config = await toggleAutoUpdate(mutableCtx.config, out);
|
|
492
576
|
}
|
|
577
|
+
else if (key === '4') {
|
|
578
|
+
mutableCtx.config = await toggleNativeSessions(mutableCtx.config, out);
|
|
579
|
+
}
|
|
493
580
|
// anything else → back
|
|
494
581
|
}
|
|
495
582
|
/**
|
|
@@ -509,11 +596,34 @@ async function toggleAutoUpdate(config, out) {
|
|
|
509
596
|
setAsDefault: config.setAsDefault,
|
|
510
597
|
...(config.mode !== undefined ? { mode: config.mode } : {}),
|
|
511
598
|
...(!enable ? { autoUpdate: false } : {}),
|
|
599
|
+
...(config.nativeSessions === true ? { nativeSessions: true } : {}),
|
|
512
600
|
};
|
|
513
601
|
await saveConfig(updated);
|
|
514
602
|
out.write(`Auto-update: ${enable ? 'on' : 'off'}\n`);
|
|
515
603
|
return updated;
|
|
516
604
|
}
|
|
605
|
+
/**
|
|
606
|
+
* Toggle the EXPERIMENTAL native-session preference and persist it.
|
|
607
|
+
*
|
|
608
|
+
* When on, conversations that stay on the same provider reuse that provider's
|
|
609
|
+
* native session (Claude `--session-id`/`--resume`) instead of replaying a
|
|
610
|
+
* compacted history block — better context fidelity and less re-sent context.
|
|
611
|
+
* Default OFF; live behavior should be verified with the gated integration test
|
|
612
|
+
* (`npm run test:integration`) before relying on it.
|
|
613
|
+
*/
|
|
614
|
+
async function toggleNativeSessions(config, out) {
|
|
615
|
+
const enable = config.nativeSessions !== true;
|
|
616
|
+
const updated = {
|
|
617
|
+
onboarded: config.onboarded,
|
|
618
|
+
setAsDefault: config.setAsDefault,
|
|
619
|
+
...(config.mode !== undefined ? { mode: config.mode } : {}),
|
|
620
|
+
...(config.autoUpdate === false ? { autoUpdate: false } : {}),
|
|
621
|
+
...(enable ? { nativeSessions: true } : {}),
|
|
622
|
+
};
|
|
623
|
+
await saveConfig(updated);
|
|
624
|
+
out.write(`Native sessions (experimental): ${enable ? 'on' : 'off'}\n`);
|
|
625
|
+
return updated;
|
|
626
|
+
}
|
|
517
627
|
// ---------------------------------------------------------------------------
|
|
518
628
|
// Manage conversations screen
|
|
519
629
|
// ---------------------------------------------------------------------------
|
|
@@ -596,9 +706,9 @@ async function runManage(ctx, out, readLine) {
|
|
|
596
706
|
if (!Number.isNaN(num) && num >= 1 && num <= metas.length) {
|
|
597
707
|
const conv = metas[num - 1];
|
|
598
708
|
if (conv !== undefined) {
|
|
599
|
-
out.write(`Delete "${conv.title}"? (y/
|
|
709
|
+
out.write(`Delete "${conv.title}"? (y/N) `);
|
|
600
710
|
const confirmAns = await readLine();
|
|
601
|
-
if ((confirmAns
|
|
711
|
+
if (parseYesNo(confirmAns, false)) {
|
|
602
712
|
await ctx.store.remove(conv.id);
|
|
603
713
|
out.write('Deleted.\n');
|
|
604
714
|
}
|
|
@@ -806,9 +916,10 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine, loginFn, dete
|
|
|
806
916
|
// can drive time with a fake clock.
|
|
807
917
|
const interruptTimes = [];
|
|
808
918
|
const INTERRUPT_WINDOW_MS = 1_500;
|
|
809
|
-
// The 'exit'
|
|
810
|
-
// loop via
|
|
919
|
+
// The 'exit' and 'menu' signals are communicated from the SIGINT handler to
|
|
920
|
+
// the main loop via these flags (the handler can't break the outer while directly).
|
|
811
921
|
let shouldExit = false;
|
|
922
|
+
let shouldMenu = false;
|
|
812
923
|
// Handle Ctrl+C with the press-counting model.
|
|
813
924
|
const sigintHandler = () => {
|
|
814
925
|
const now = ctx.clock.now();
|
|
@@ -829,13 +940,10 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine, loginFn, dete
|
|
|
829
940
|
currentAc.abort();
|
|
830
941
|
currentAc = null;
|
|
831
942
|
}
|
|
832
|
-
//
|
|
833
|
-
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
// flag). In practice the loop is either awaiting readLine() or runTask().
|
|
837
|
-
// For the readLine case the user typed nothing yet — we need to interrupt
|
|
838
|
-
// that await. We use a shared resolver pattern: see loopBreaker below.
|
|
943
|
+
// Set shouldMenu so the loop returns 'menu' after any running task settles.
|
|
944
|
+
shouldMenu = true;
|
|
945
|
+
// For the readLine case (idle prompt) we can interrupt the await immediately
|
|
946
|
+
// via the loopBreaker resolver.
|
|
839
947
|
loopBreaker?.('menu');
|
|
840
948
|
}
|
|
841
949
|
else {
|
|
@@ -855,7 +963,7 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine, loginFn, dete
|
|
|
855
963
|
let loopResult = 'menu';
|
|
856
964
|
try {
|
|
857
965
|
while (true) {
|
|
858
|
-
out.write('
|
|
966
|
+
out.write('> ');
|
|
859
967
|
// Race readLine() against a loopBreak signal from the SIGINT handler.
|
|
860
968
|
// When Ctrl+C fires (to-menu or exit-app), loopBreaker is called with the
|
|
861
969
|
// desired result, which wins the race and breaks the loop.
|
|
@@ -890,58 +998,89 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine, loginFn, dete
|
|
|
890
998
|
const policy = mutableCtx.config.mode !== undefined
|
|
891
999
|
? POLICY_PRESETS[mutableCtx.config.mode]
|
|
892
1000
|
: DEFAULT_POLICY;
|
|
1001
|
+
// ---- Bug 4 fix: no-provider gate ----------------------------------------
|
|
1002
|
+
// Check whether any provider is actually authenticated before dispatching a
|
|
1003
|
+
// task that is doomed to fail. opencode counts as authenticated-when-installed.
|
|
1004
|
+
const hasAuthenticatedProvider = mutableCtx.env.claude.authenticated ||
|
|
1005
|
+
mutableCtx.env.codex.authenticated ||
|
|
1006
|
+
mutableCtx.env.opencode.authenticated ||
|
|
1007
|
+
mutableCtx.env.opencode.installed;
|
|
1008
|
+
if (!hasAuthenticatedProvider) {
|
|
1009
|
+
out.write('\n[info] No signed-in provider yet — press Ctrl+C to go back, then [j] Claude / [k] Codex / [o] opencode to sign in.\n');
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
893
1012
|
// Load prior history before each turn so the provider receives conversation
|
|
894
1013
|
// context. load() returns only the entries persisted so far — the current
|
|
895
1014
|
// user turn is appended by orchestrate() after this point, so there is no
|
|
896
1015
|
// double-inclusion risk.
|
|
897
1016
|
const priorHistory = await ctx.store.load(convId);
|
|
898
|
-
// Build
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1017
|
+
// ---- Build deps from the live mutableCtx.env ----------------------------
|
|
1018
|
+
// This helper is inlined as a function so it can be called again after
|
|
1019
|
+
// inline re-login with the refreshed env (bug 5 fix: no stale auth state).
|
|
1020
|
+
const buildDeps = () => {
|
|
1021
|
+
// Build per-provider advertised model sets from the live env so route()
|
|
1022
|
+
// can prefer a model the CLI actually advertises. Only include installed
|
|
1023
|
+
// providers (exactOptionalPropertyTypes is ON).
|
|
1024
|
+
// Use mutableCtx.env (not ctx.env) so post-login re-detect is reflected.
|
|
1025
|
+
const availableModels = {};
|
|
1026
|
+
if (mutableCtx.env.claude.installed && mutableCtx.env.claude.availableModels.length > 0) {
|
|
1027
|
+
availableModels['claude'] = mutableCtx.env.claude.availableModels;
|
|
1028
|
+
}
|
|
1029
|
+
if (mutableCtx.env.codex.installed && mutableCtx.env.codex.availableModels.length > 0) {
|
|
1030
|
+
availableModels['codex'] = mutableCtx.env.codex.availableModels;
|
|
1031
|
+
}
|
|
1032
|
+
if (mutableCtx.env.opencode.installed && mutableCtx.env.opencode.availableModels.length > 0) {
|
|
1033
|
+
availableModels['opencode'] = mutableCtx.env.opencode.availableModels;
|
|
1034
|
+
}
|
|
1035
|
+
// Collect authenticated providers from the live env so route() prefers
|
|
1036
|
+
// signed-in providers over signed-out ones. Uses mutableCtx.env so
|
|
1037
|
+
// post-login re-detection is reflected without restart.
|
|
1038
|
+
const authenticatedProviders = [];
|
|
1039
|
+
if (mutableCtx.env.claude.authenticated)
|
|
1040
|
+
authenticatedProviders.push('claude');
|
|
1041
|
+
if (mutableCtx.env.codex.authenticated)
|
|
1042
|
+
authenticatedProviders.push('codex');
|
|
1043
|
+
if (mutableCtx.env.opencode.authenticated)
|
|
1044
|
+
authenticatedProviders.push('opencode');
|
|
1045
|
+
// EXPERIMENTAL native session plan (opt-in via config.nativeSessions).
|
|
1046
|
+
// Pure decision; null when disabled. When present, orchestrate uses the
|
|
1047
|
+
// provider's native session for matching tiers instead of replaying history.
|
|
1048
|
+
const nativeSession = planNativeSession({
|
|
1049
|
+
enabled: mutableCtx.config.nativeSessions === true,
|
|
1050
|
+
conversationId: convId,
|
|
1051
|
+
history: priorHistory,
|
|
1052
|
+
});
|
|
1053
|
+
// planNativeSession returns [] when disabled / no conversation id.
|
|
1054
|
+
return {
|
|
1055
|
+
clock: ctx.clock,
|
|
1056
|
+
session: ctx.store.writer(convId),
|
|
1057
|
+
ledger: ctx.ledger,
|
|
1058
|
+
policy,
|
|
1059
|
+
providers: ctx.providers,
|
|
1060
|
+
cwd: ctx.cwd,
|
|
1061
|
+
sandbox: ctx.sandbox,
|
|
1062
|
+
timeoutMs: ctx.timeoutMs,
|
|
1063
|
+
...(priorHistory.length > 0 ? { history: priorHistory } : {}),
|
|
1064
|
+
...(Object.keys(availableModels).length > 0 ? { availableModels } : {}),
|
|
1065
|
+
...(authenticatedProviders.length > 0 ? { authenticatedProviders } : {}),
|
|
1066
|
+
...(nativeSession.length > 0 ? { nativeSession } : {}),
|
|
1067
|
+
};
|
|
934
1068
|
};
|
|
1069
|
+
const deps = buildDeps();
|
|
935
1070
|
const ac = new AbortController();
|
|
936
1071
|
currentAc = ac;
|
|
937
1072
|
const result = await runTask(line, deps, out, ac.signal);
|
|
938
1073
|
currentAc = null;
|
|
939
|
-
// Check for
|
|
940
|
-
// while runTask was awaited — the abort fires and the flag is set).
|
|
1074
|
+
// Check for SIGINT-driven signals that fired while runTask was awaited.
|
|
941
1075
|
if (shouldExit) {
|
|
942
1076
|
loopResult = 'exit';
|
|
943
1077
|
break;
|
|
944
1078
|
}
|
|
1079
|
+
// Bug 3 fix: shouldMenu may have been set by a 2×Ctrl+C during the task.
|
|
1080
|
+
if (shouldMenu) {
|
|
1081
|
+
loopResult = 'menu';
|
|
1082
|
+
break;
|
|
1083
|
+
}
|
|
945
1084
|
// Inline re-login on auth failure: offer to sign in and retry once.
|
|
946
1085
|
if (result.final !== undefined &&
|
|
947
1086
|
!result.final.success &&
|
|
@@ -953,16 +1092,23 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine, loginFn, dete
|
|
|
953
1092
|
const ans = await readLine();
|
|
954
1093
|
if (parseYesNo(ans, true)) {
|
|
955
1094
|
await loginFn(out, failingProvider, { readLine });
|
|
1095
|
+
// Bug 5 fix: re-detect with the freshly-authenticated env so the retry
|
|
1096
|
+
// deps reflect the now-signed-in provider (not the stale pre-login state).
|
|
956
1097
|
mutableCtx.env = await detectEnvironmentFn();
|
|
1098
|
+
const retryDeps = buildDeps();
|
|
957
1099
|
// Retry the same task once.
|
|
958
1100
|
const retryAc = new AbortController();
|
|
959
1101
|
currentAc = retryAc;
|
|
960
|
-
const retryResult = await runTask(line,
|
|
1102
|
+
const retryResult = await runTask(line, retryDeps, out, retryAc.signal);
|
|
961
1103
|
currentAc = null;
|
|
962
1104
|
if (shouldExit) {
|
|
963
1105
|
loopResult = 'exit';
|
|
964
1106
|
break;
|
|
965
1107
|
}
|
|
1108
|
+
if (shouldMenu) {
|
|
1109
|
+
loopResult = 'menu';
|
|
1110
|
+
break;
|
|
1111
|
+
}
|
|
966
1112
|
// If still auth failure after retry, inform and continue to prompt.
|
|
967
1113
|
if (retryResult.final !== undefined &&
|
|
968
1114
|
!retryResult.final.success &&
|
|
@@ -982,18 +1128,40 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine, loginFn, dete
|
|
|
982
1128
|
// ---------------------------------------------------------------------------
|
|
983
1129
|
// Main screen render
|
|
984
1130
|
// ---------------------------------------------------------------------------
|
|
985
|
-
async function renderMainScreen(ctx, mutableCtx, metas, out, updateInfo) {
|
|
1131
|
+
async function renderMainScreen(ctx, mutableCtx, metas, spend, out, updateInfo, claudeTokenInfo, runningUnderNpx = false, healthIssues = []) {
|
|
986
1132
|
out.write('\n');
|
|
987
|
-
// Header box — always box(), 🧠 emoji, real provider data
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1133
|
+
// Header box — always box(), 🧠 emoji, real provider data.
|
|
1134
|
+
// Title carries the live version status so the user always knows whether they
|
|
1135
|
+
// are current: "(latest)" when up to date, "→ X.Y.Z available" when not.
|
|
1136
|
+
const headerLines = renderHeaderLines(mutableCtx.env, ctx.version, claudeTokenInfo ?? undefined);
|
|
1137
|
+
const versionLabel = versionStatusLabel(updateInfo);
|
|
1138
|
+
out.write(box(`🧠 myshell-tools v${ctx.version}${versionLabel}`, headerLines) + '\n\n');
|
|
1139
|
+
// Update banner — only shown when a newer version is genuinely available.
|
|
991
1140
|
if (updateInfo?.updateAvailable === true && updateInfo.latest !== null) {
|
|
992
|
-
|
|
1141
|
+
if (runningUnderNpx) {
|
|
1142
|
+
// Self-update can't persist under npx (it re-serves its own cache next run).
|
|
1143
|
+
// Be honest and point to the durable fix instead of a no-op "press u".
|
|
1144
|
+
out.write(` ▲ Update available: ${updateInfo.current} → ${updateInfo.latest}\n` +
|
|
1145
|
+
` You're running via npx, so updates won't stick. Install globally to stay current:\n` +
|
|
1146
|
+
` npm install -g myshell-tools@latest\n\n`);
|
|
1147
|
+
}
|
|
1148
|
+
else {
|
|
1149
|
+
out.write(` ▲ Update available: ${updateInfo.current} → ${updateInfo.latest} (press u)\n\n`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
// Health issues — surfaced automatically, only when something is actually
|
|
1153
|
+
// wrong (writable/Node/pricing). Silence means healthy; the user never runs a
|
|
1154
|
+
// diagnostic command. Errors get ✗, warnings get ⚠️.
|
|
1155
|
+
for (const issue of healthIssues) {
|
|
1156
|
+
const marker = issue.severity === 'error' ? '✗' : '⚠️ ';
|
|
1157
|
+
out.write(` ${marker} ${issue.message}\n`);
|
|
993
1158
|
}
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1159
|
+
if (healthIssues.length > 0)
|
|
1160
|
+
out.write('\n');
|
|
1161
|
+
// Budget line — real ledger data, never fabricated. The SpendSummary is
|
|
1162
|
+
// computed by the caller and cached across keystrokes (the ledger only
|
|
1163
|
+
// changes when a task completes), so navigating the menu never re-parses the
|
|
1164
|
+
// unbounded ledger.jsonl on every keypress.
|
|
997
1165
|
out.write(' ' + renderBudgetLine(spend, out.color) + '\n\n');
|
|
998
1166
|
// Recent conversations — separator() then list
|
|
999
1167
|
out.write(separator('Recent Conversations') + '\n');
|
|
@@ -1020,8 +1188,9 @@ async function renderMainScreen(ctx, mutableCtx, metas, out, updateInfo) {
|
|
|
1020
1188
|
{ key: 'k', label: 'Login Codex', section: 'Auth' },
|
|
1021
1189
|
{ key: 'o', label: opencodeLabel, section: 'Auth' },
|
|
1022
1190
|
];
|
|
1023
|
-
// [u] Update now — shown only when a newer version is actually available
|
|
1024
|
-
|
|
1191
|
+
// [u] Update now — shown only when a newer version is actually available AND
|
|
1192
|
+
// an in-place self-update can persist (not under npx, where it would be a no-op).
|
|
1193
|
+
const updateEntry = updateInfo?.updateAvailable === true && updateInfo.latest !== null && !runningUnderNpx
|
|
1025
1194
|
? [{ key: 'u', label: `Update now (→ ${updateInfo.latest})`, section: 'Options' }]
|
|
1026
1195
|
: [];
|
|
1027
1196
|
// Menu — sectioned via menu()
|
|
@@ -1067,6 +1236,10 @@ export async function startMenu(ctx, out) {
|
|
|
1067
1236
|
const checkForUpdateFn = ctx.checkForUpdate;
|
|
1068
1237
|
const updateSelfFn = ctx.updateSelf;
|
|
1069
1238
|
const relaunchFn = ctx.relaunch;
|
|
1239
|
+
// npx context: real detection from the running script path, or test override.
|
|
1240
|
+
const runningUnderNpx = ctx.runningUnderNpx !== undefined
|
|
1241
|
+
? ctx.runningUnderNpx
|
|
1242
|
+
: isRunningUnderNpx(process.argv[1], process.env);
|
|
1070
1243
|
// Build the readLine function — either injected (for tests) or backed by a
|
|
1071
1244
|
// real readline interface driven by the event-driven LineReader queue.
|
|
1072
1245
|
let readLine;
|
|
@@ -1114,7 +1287,11 @@ export async function startMenu(ctx, out) {
|
|
|
1114
1287
|
// ---- Auto-update at launch (default ON) ----------------------------------
|
|
1115
1288
|
// Guard: only runs once; requires both the update and relaunch seams to be wired.
|
|
1116
1289
|
// Disabled when MYSHELL_NO_UPDATE is set in the environment or autoUpdate===false.
|
|
1290
|
+
// Skipped under npx: `npm install -g` won't be picked up by the next npx run
|
|
1291
|
+
// (it re-serves its own cache), so silently installing globally would surprise
|
|
1292
|
+
// the user with no durable benefit. The main screen shows the install hint instead.
|
|
1117
1293
|
if (autoUpdateEnabled(mutableCtx.config, process.env) &&
|
|
1294
|
+
!runningUnderNpx &&
|
|
1118
1295
|
updateInfo?.updateAvailable === true &&
|
|
1119
1296
|
updateInfo.latest !== null &&
|
|
1120
1297
|
updateSelfFn !== undefined) {
|
|
@@ -1130,9 +1307,31 @@ export async function startMenu(ctx, out) {
|
|
|
1130
1307
|
out.write('Auto-update failed — continuing with current version.\n');
|
|
1131
1308
|
}
|
|
1132
1309
|
// ---- B. Main screen loop -------------------------------------------------
|
|
1310
|
+
// Compute Claude token lifetime once per launch (real disk read, or injected
|
|
1311
|
+
// value from ctx for tests). Passed to renderMainScreen so renderHeaderLines
|
|
1312
|
+
// stays pure.
|
|
1313
|
+
let claudeTokenInfo;
|
|
1314
|
+
if (ctx.claudeTokenInfo !== undefined) {
|
|
1315
|
+
// Injected by tests (including explicit null to suppress warning).
|
|
1316
|
+
claudeTokenInfo = ctx.claudeTokenInfo;
|
|
1317
|
+
}
|
|
1318
|
+
else {
|
|
1319
|
+
// Real path: load from disk. Never throws; null when no capture date stored.
|
|
1320
|
+
const capturedAt = await loadClaudeTokenCapturedAt().catch(() => undefined);
|
|
1321
|
+
claudeTokenInfo = claudeTokenStatus(capturedAt, Date.now());
|
|
1322
|
+
}
|
|
1323
|
+
// Spend summary is cached and only recomputed when the ledger may have
|
|
1324
|
+
// changed (after a task runs). Avoids re-reading the unbounded ledger.jsonl
|
|
1325
|
+
// on every keystroke — the menu hot path stays O(1) in ledger size.
|
|
1326
|
+
let spend = summarizeSpend(await readLedger(ctx.cwd), ctx.clock.isoNow());
|
|
1327
|
+
let spendDirty = false;
|
|
1133
1328
|
while (true) {
|
|
1329
|
+
if (spendDirty) {
|
|
1330
|
+
spend = summarizeSpend(await readLedger(ctx.cwd), ctx.clock.isoNow());
|
|
1331
|
+
spendDirty = false;
|
|
1332
|
+
}
|
|
1134
1333
|
const metas = await ctx.store.list();
|
|
1135
|
-
await renderMainScreen(ctx, mutableCtx, metas, out, updateInfo);
|
|
1334
|
+
await renderMainScreen(ctx, mutableCtx, metas, spend, out, updateInfo, claudeTokenInfo, runningUnderNpx, ctx.healthIssues ?? []);
|
|
1136
1335
|
out.write('> ');
|
|
1137
1336
|
const key = await readLine();
|
|
1138
1337
|
// ---- EOF / close — exit gracefully (FIX 1: no ERR_USE_AFTER_CLOSE) ----
|
|
@@ -1150,6 +1349,7 @@ export async function startMenu(ctx, out) {
|
|
|
1150
1349
|
if (firstMsg !== null && firstMsg.length > 0) {
|
|
1151
1350
|
const meta = await ctx.store.create(firstMsg);
|
|
1152
1351
|
const chatResult = await runChatLoop(ctx, mutableCtx, meta.id, out, readLine, loginFn, detectEnvironmentFn);
|
|
1352
|
+
spendDirty = true; // a task may have run — refresh the spend summary
|
|
1153
1353
|
if (chatResult === 'exit')
|
|
1154
1354
|
break;
|
|
1155
1355
|
}
|
|
@@ -1161,6 +1361,7 @@ export async function startMenu(ctx, out) {
|
|
|
1161
1361
|
const latest = all[0];
|
|
1162
1362
|
if (latest !== undefined) {
|
|
1163
1363
|
const chatResult = await runChatLoop(ctx, mutableCtx, latest.id, out, readLine, loginFn, detectEnvironmentFn);
|
|
1364
|
+
spendDirty = true; // a task may have run — refresh the spend summary
|
|
1164
1365
|
if (chatResult === 'exit')
|
|
1165
1366
|
break;
|
|
1166
1367
|
}
|
|
@@ -1175,6 +1376,7 @@ export async function startMenu(ctx, out) {
|
|
|
1175
1376
|
const target = metas[digit - 1];
|
|
1176
1377
|
if (target !== undefined) {
|
|
1177
1378
|
const chatResult = await runChatLoop(ctx, mutableCtx, target.id, out, readLine, loginFn, detectEnvironmentFn);
|
|
1379
|
+
spendDirty = true; // a task may have run — refresh the spend summary
|
|
1178
1380
|
if (chatResult === 'exit')
|
|
1179
1381
|
break;
|
|
1180
1382
|
}
|
|
@@ -1191,6 +1393,7 @@ export async function startMenu(ctx, out) {
|
|
|
1191
1393
|
// ---- [i] Import a native conversation -----------------------------------
|
|
1192
1394
|
if (key === 'i') {
|
|
1193
1395
|
const importResult = await runImportNative(ctx, mutableCtx, out, readLine, loginFn, detectEnvironmentFn);
|
|
1396
|
+
spendDirty = true; // an imported session may run a task — refresh spend
|
|
1194
1397
|
if (importResult === 'exit')
|
|
1195
1398
|
break;
|
|
1196
1399
|
continue;
|
|
@@ -1222,10 +1425,11 @@ export async function startMenu(ctx, out) {
|
|
|
1222
1425
|
// tests stay hermetic). If install succeeds, proceeds to sign in.
|
|
1223
1426
|
if (key === 'o') {
|
|
1224
1427
|
if (!mutableCtx.env.opencode.installed) {
|
|
1225
|
-
out.write(`Install opencode (${installCommandFor('opencode').replace('npm install -g ', '')})?
|
|
1226
|
-
out.write('> ');
|
|
1428
|
+
out.write(`Install opencode (${installCommandFor('opencode').replace('npm install -g ', '')})? (Y/n) `);
|
|
1227
1429
|
const ans = await readLine();
|
|
1228
|
-
|
|
1430
|
+
// EOF (null) means no interactive user — never auto-install on a closed
|
|
1431
|
+
// pipe. Otherwise honor the (Y/n) default-yes (Enter = install).
|
|
1432
|
+
const skip = ans === null || !parseYesNo(ans, true);
|
|
1229
1433
|
if (skip) {
|
|
1230
1434
|
out.write(`[2mSkipped. You can install it later: ${installCommandFor('opencode')}[0m\n`);
|
|
1231
1435
|
continue;
|