@krtclcdy/tersio 2.3.0 → 2.4.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -2
  3. package/tersio.js +162 -65
package/README.md CHANGED
@@ -160,7 +160,7 @@ The installer writes `<file>.bak` before replacing an extension source; the upda
160
160
  ## Requirements
161
161
 
162
162
  - [OMP](https://github.com/can1357/oh-my-pi)
163
- - Node.js 18+ with npm
163
+ - Node.js 20.12+ with npm
164
164
 
165
165
  Windows/WSL have separate OMP homes — install from the environment where OMP runs. Inside WSL, `command -v npm` must resolve to a Linux path, not `/mnt/c/`.
166
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krtclcdy/tersio",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "✂️ Token-saving OMP add-ons: terse Caveman replies, compact RTK shell output, lean Ponytail code calls — one-command combo presets.",
5
5
  "keywords": [
6
6
  "omp",
@@ -119,11 +119,14 @@
119
119
  "test": "tsx --test test/*.test.ts test/*/*.test.ts"
120
120
  },
121
121
  "engines": {
122
- "node": ">=18"
122
+ "node": ">=20.12.0"
123
123
  },
124
124
  "devDependencies": {
125
125
  "@types/node": "^26.4.1",
126
126
  "tsx": "^4.23.13",
127
127
  "typescript": "^7.0.2"
128
+ },
129
+ "dependencies": {
130
+ "@clack/prompts": "^1.8.0"
128
131
  }
129
132
  }
package/tersio.js CHANGED
@@ -10,6 +10,7 @@ import readline from 'node:readline';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { promisify } from 'node:util';
12
12
  import { createRequire } from 'node:module';
13
+ import { cancel as clackCancel, confirm as clackConfirm, select as clackSelect, spinner as clackSpinner, tasks as clackTasks } from '@clack/prompts';
13
14
  import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, sha256File, parseChecksum, readTextIfExists, rtkPlatformSpec, } from './extensions/lib/utils.js';
14
15
  const IS_WINDOWS = process.platform === 'win32';
15
16
  const RTK_BINARY_NAME = IS_WINDOWS ? 'rtk.exe' : 'rtk';
@@ -150,6 +151,9 @@ async function execP(cmd, args, opts = {}) {
150
151
  ...opts,
151
152
  });
152
153
  }
154
+ async function execNetwork(label, cmd, args, opts = {}) {
155
+ return withInteractiveSpinner(label, () => execP(cmd, args, opts));
156
+ }
153
157
  async function writeIfChanged(dest, content, options = {}) {
154
158
  const existing = await readTextIfExists(dest);
155
159
  if (existing === content) {
@@ -332,7 +336,7 @@ async function stepPonytail(pluginsDir, userDir, options) {
332
336
  else {
333
337
  // Try omp plugin install first
334
338
  try {
335
- await execP(OMP_BIN, ['plugin', 'install', PONYTAIL_GITHUB_SPEC], { cwd: pluginsDir });
339
+ await execNetwork('Installing Ponytail plugin', OMP_BIN, ['plugin', 'install', PONYTAIL_GITHUB_SPEC], { cwd: pluginsDir });
336
340
  console.log(' [ok] omp plugin install ran');
337
341
  }
338
342
  catch (e) {
@@ -340,7 +344,7 @@ async function stepPonytail(pluginsDir, userDir, options) {
340
344
  }
341
345
  if (options.reinstall) {
342
346
  try {
343
- await execP('npm', ['install', PONYTAIL_NPM_SPEC, '--save', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 120000 });
347
+ await execNetwork('Refreshing Ponytail package', 'npm', ['install', PONYTAIL_NPM_SPEC, '--save', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 120000 });
344
348
  console.log(' [ok] Ponytail refreshed');
345
349
  }
346
350
  catch (e) {
@@ -354,12 +358,12 @@ async function stepPonytail(pluginsDir, userDir, options) {
354
358
  if (!ponytailExtExists) {
355
359
  console.log(' [info] pi-extension/index.js not found after omp plugin install — trying npm/bun install...');
356
360
  try {
357
- await execP('npm', ['install'], { cwd: pluginsDir, timeout: 120000 });
361
+ await execNetwork('Installing Ponytail dependencies', 'npm', ['install'], { cwd: pluginsDir, timeout: 120000 });
358
362
  console.log(' [ok] npm install completed');
359
363
  }
360
364
  catch {
361
365
  try {
362
- await execP('bun', ['install'], { cwd: pluginsDir, timeout: 120000 });
366
+ await execNetwork('Installing Ponytail dependencies', 'bun', ['install'], { cwd: pluginsDir, timeout: 120000 });
363
367
  console.log(' [ok] bun install completed');
364
368
  }
365
369
  catch (e2) {
@@ -375,7 +379,7 @@ async function stepPonytail(pluginsDir, userDir, options) {
375
379
  try {
376
380
  const dest = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail');
377
381
  await fs.mkdir(path.dirname(dest), { recursive: true });
378
- await execP('git', ['clone', '--depth', '1', 'https://github.com/DietrichGebert/ponytail.git', dest], { timeout: 180000 });
382
+ await execNetwork('Cloning Ponytail repository', 'git', ['clone', '--depth', '1', 'https://github.com/DietrichGebert/ponytail.git', dest], { timeout: 180000 });
379
383
  console.log(' [ok] git clone completed');
380
384
  ponytailExtExists = await probeExt();
381
385
  }
@@ -432,11 +436,11 @@ async function stepSelfPlugin(pluginsDir, options) {
432
436
  await fs.mkdir(pluginsDir, { recursive: true });
433
437
  await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
434
438
  try {
435
- await execP('npm', ['install', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 180000 });
439
+ await execNetwork('Installing Tersio plugin dependencies', 'npm', ['install', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 180000 });
436
440
  }
437
441
  catch {
438
442
  try {
439
- await execP('bun', ['install'], { cwd: pluginsDir, timeout: 180000 });
443
+ await execNetwork('Installing Tersio plugin dependencies', 'bun', ['install'], { cwd: pluginsDir, timeout: 180000 });
440
444
  }
441
445
  catch (e) {
442
446
  console.log(` [fail] Could not install ${PACKAGE_NAME} into plugins dir: ${e.message}`);
@@ -535,7 +539,7 @@ async function extractRtkArchive(archivePath, extractDir) {
535
539
  async function stepRtk(binDir, options) {
536
540
  console.log('\n[3/7] Installing RTK binary...');
537
541
  try {
538
- const release = await fetchJson(RTK_RELEASE_API);
542
+ const release = await withInteractiveSpinner('Finding latest RTK release', () => fetchJson(RTK_RELEASE_API));
539
543
  const triple = resolveRtkTriple();
540
544
  if (!triple)
541
545
  return;
@@ -552,10 +556,14 @@ async function stepRtk(binDir, options) {
552
556
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'omp-rtk-'));
553
557
  try {
554
558
  const archivePath = path.join(tmpDir, asset.name);
555
- const [checksumsText] = await Promise.all([
556
- downloadRtkChecksums(release),
557
- httpsDownload(asset.browser_download_url, archivePath),
558
- ]);
559
+ const checksumsText = await withInteractiveSpinner('Downloading RTK binary', async (update) => {
560
+ const [downloadedChecksums] = await Promise.all([
561
+ downloadRtkChecksums(release),
562
+ httpsDownload(asset.browser_download_url, archivePath),
563
+ ]);
564
+ update('Verifying RTK checksum');
565
+ return downloadedChecksums;
566
+ });
559
567
  if (!await verifyRtkArchive(archivePath, asset.name, checksumsText))
560
568
  return;
561
569
  const extractDir = path.join(tmpDir, 'extracted');
@@ -634,7 +642,7 @@ async function fetchCavemanRule(options) {
634
642
  if (options.dryRun)
635
643
  return (await readTextIfExists(path.join(path.dirname(CAVEMAN_INDEX), 'rule.md'))) || '';
636
644
  try {
637
- return await httpsGet(CAVEMAN_REMOTE_RULE);
645
+ return await withInteractiveSpinner('Fetching Caveman rule', () => httpsGet(CAVEMAN_REMOTE_RULE));
638
646
  }
639
647
  catch (e) {
640
648
  console.log(` [warn] Could not fetch caveman rule: ${e.message}`);
@@ -690,29 +698,61 @@ async function runDoctor() {
690
698
  const comboIndex = path.join(extDir, 'combo-toggle', 'index.js');
691
699
  const modeReinforcement = path.join(extDir, 'shared', 'mode-reinforcement.js');
692
700
  const selfPkg = path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'package.json');
693
- // Independent probes run concurrently; logs below keep fixed order.
694
- const [ompVersion, agentEntries, extEntries, sharedStateText, configText, ponytailPkgText, ponytailExtText, pluginsPkgRaw, selfPkgText, rtkBinText, cavemanIndexText, cavemanRuleText, rtkIndexText, updaterIndexText, comboIndexText, modeReinforcementText, ruleMtime, rtkMtime, ponytailMtime, updateVersion,] = await Promise.all([
695
- execP(OMP_BIN, ['--version']).then((r) => r.stdout.trim(), () => null),
696
- fs.readdir(agentDir).catch(() => null),
697
- fs.readdir(extDir).catch(() => null),
698
- readTextIfExists(path.join(extDir, 'shared', 'session-state.js')),
699
- readTextIfExists(configPath),
700
- readTextIfExists(ponytailPkg),
701
- readTextIfExists(ponytailExt),
702
- readTextIfExists(path.join(pluginsDir, 'package.json')),
703
- readTextIfExists(selfPkg),
704
- readTextIfExists(rtkBin),
705
- readTextIfExists(cavemanIndex),
706
- readTextIfExists(cavemanRule),
707
- readTextIfExists(rtkIndex),
708
- readTextIfExists(updaterIndex),
709
- readTextIfExists(comboIndex),
710
- readTextIfExists(modeReinforcement),
711
- fs.stat(cavemanRule).catch(() => null),
712
- fs.stat(rtkBin).catch(() => null),
713
- fs.stat(ponytailPkg).catch(() => null),
714
- checkForUpdate(),
715
- ]);
701
+ // Independent probes start concurrently; sections report in fixed order as
702
+ // their data settles. Every probe resolves instead of rejecting.
703
+ const probes = {
704
+ ompVersion: execP(OMP_BIN, ['--version']).then((r) => r.stdout.trim(), () => null),
705
+ agentEntries: fs.readdir(agentDir).catch(() => null),
706
+ extEntries: fs.readdir(extDir).catch(() => null),
707
+ sharedStateText: readTextIfExists(path.join(extDir, 'shared', 'session-state.js')),
708
+ configText: readTextIfExists(configPath),
709
+ ponytailPkgText: readTextIfExists(ponytailPkg),
710
+ ponytailExtText: readTextIfExists(ponytailExt),
711
+ pluginsPkgRaw: readTextIfExists(path.join(pluginsDir, 'package.json')),
712
+ selfPkgText: readTextIfExists(selfPkg),
713
+ rtkBinText: readTextIfExists(rtkBin),
714
+ cavemanIndexText: readTextIfExists(cavemanIndex),
715
+ cavemanRuleText: readTextIfExists(cavemanRule),
716
+ rtkIndexText: readTextIfExists(rtkIndex),
717
+ updaterIndexText: readTextIfExists(updaterIndex),
718
+ comboIndexText: readTextIfExists(comboIndex),
719
+ modeReinforcementText: readTextIfExists(modeReinforcement),
720
+ ruleMtime: fs.stat(cavemanRule).catch(() => null),
721
+ rtkMtime: fs.stat(rtkBin).catch(() => null),
722
+ ponytailMtime: fs.stat(ponytailPkg).catch(() => null),
723
+ updateVersion: checkForUpdate(),
724
+ };
725
+ const rtkVersionProbe = probes.rtkBinText.then((text) => text === null ? null : execP(rtkBin, ['--version'], { timeout: 5000 }).then((r) => r.stdout.trim() || r.stderr.trim() || null, (e) => {
726
+ const err = e;
727
+ return err.stdout?.trim() || err.stderr?.trim() || null;
728
+ }));
729
+ const [ompVersion, agentEntries, extEntries, sharedStateText, configText, updateVersion] = await runInteractivePhase('Checking environment and installation', () => Promise.all([
730
+ probes.ompVersion,
731
+ probes.agentEntries,
732
+ probes.extEntries,
733
+ probes.sharedStateText,
734
+ probes.configText,
735
+ probes.updateVersion,
736
+ ]));
737
+ const [cavemanIndexText, rtkIndexText, updaterIndexText, comboIndexText, modeReinforcementText, ponytailPkgText, ponytailExtText, pluginsPkgRaw, selfPkgText] = await runInteractivePhase('Checking extensions and plugins', () => Promise.all([
738
+ probes.cavemanIndexText,
739
+ probes.rtkIndexText,
740
+ probes.updaterIndexText,
741
+ probes.comboIndexText,
742
+ probes.modeReinforcementText,
743
+ probes.ponytailPkgText,
744
+ probes.ponytailExtText,
745
+ probes.pluginsPkgRaw,
746
+ probes.selfPkgText,
747
+ ]));
748
+ const [cavemanRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, ponytailMtime] = await runInteractivePhase('Checking add-ons', () => Promise.all([
749
+ probes.cavemanRuleText,
750
+ probes.ruleMtime,
751
+ probes.rtkBinText,
752
+ probes.rtkMtime,
753
+ rtkVersionProbe,
754
+ probes.ponytailMtime,
755
+ ]));
716
756
  // Categorized output with a tally; section headers group related probes.
717
757
  const tally = { ok: 0, missing: 0, warn: 0 };
718
758
  function check(label, ok, detail = '') {
@@ -761,10 +801,6 @@ async function runDoctor() {
761
801
  section('Add-ons');
762
802
  const ruleAge = ruleMtime ? `updated ${relTime(Date.now() - ruleMtime.mtimeMs)}` : '';
763
803
  check('Caveman rule', cavemanRuleText !== null, ruleAge);
764
- const rtkVersion = rtkBinText === null ? null : await execP(rtkBin, ['--version'], { timeout: 5000 }).then((r) => r.stdout.trim() || r.stderr.trim() || null, (e) => {
765
- const err = e;
766
- return err.stdout?.trim() || err.stderr?.trim() || null;
767
- });
768
804
  const rtkAge = rtkMtime ? `updated ${relTime(Date.now() - rtkMtime.mtimeMs)}` : '';
769
805
  if (rtkBinText === null)
770
806
  check('RTK binary', false, rtkBin);
@@ -866,11 +902,27 @@ async function runUninstall(options = {}) {
866
902
  console.log(` ${rtkBin}`);
867
903
  }
868
904
  if (!confirmed) {
869
- const answer = await ask('\nProceed? [y/N]: ');
870
- if (!answer.toLowerCase().startsWith('y')) {
871
- console.log('Aborted.');
905
+ if (tty()) {
872
906
  closeRL();
873
- return false;
907
+ const confirmedChoice = await clackConfirm({ message: 'Remove the listed Tersio files?', initialValue: false });
908
+ if (typeof confirmedChoice !== 'boolean') {
909
+ clackCancel('Aborted.');
910
+ closeRL();
911
+ return false;
912
+ }
913
+ if (!confirmedChoice) {
914
+ console.log('Aborted.');
915
+ closeRL();
916
+ return false;
917
+ }
918
+ }
919
+ else {
920
+ const answer = await ask('\nProceed? [y/N]: ');
921
+ if (!answer.toLowerCase().startsWith('y')) {
922
+ console.log('Aborted.');
923
+ closeRL();
924
+ return false;
925
+ }
874
926
  }
875
927
  }
876
928
  // Remove extension directories (independent paths, so concurrently).
@@ -929,7 +981,6 @@ async function runUninstall(options = {}) {
929
981
  return true;
930
982
  }
931
983
  const SCOPE_MAP = { user: '1', project: '2', both: '3' };
932
- const COMBO_MENU = { '1': 'off', '2': 'medium', '3': 'balanced', '4': 'max' };
933
984
  // --- Update check (cached; used by the bare-invocation banner and doctor) ---
934
985
  const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
935
986
  function newerThan(a, b) {
@@ -946,7 +997,7 @@ async function latestPublishedVersion() {
946
997
  const args = IS_WINDOWS
947
998
  ? ['/d', '/s', '/c', 'npm', 'view', PACKAGE_NAME, 'version', '--prefer-online']
948
999
  : ['view', PACKAGE_NAME, 'version', '--prefer-online'];
949
- const r = await execP(IS_WINDOWS ? process.env.ComSpec || 'cmd.exe' : 'npm', args, {
1000
+ const r = await execNetwork('Checking npm registry for tersio updates', IS_WINDOWS ? process.env.ComSpec || 'cmd.exe' : 'npm', args, {
950
1001
  timeout: 4000,
951
1002
  maxBuffer: 1024 * 1024,
952
1003
  windowsHide: true,
@@ -1024,7 +1075,7 @@ async function runLatestUpdate() {
1024
1075
  else {
1025
1076
  try {
1026
1077
  const globalArgs = IS_WINDOWS ? ['/d', '/s', '/c', 'npm', 'install', '-g', `${PACKAGE_NAME}@latest`, '--no-audit', '--no-fund'] : ['install', '-g', `${PACKAGE_NAME}@latest`, '--no-audit', '--no-fund'];
1027
- const globalResult = await execP(npmCommand, globalArgs, {
1078
+ const globalResult = await execNetwork('Updating global Tersio CLI', npmCommand, globalArgs, {
1028
1079
  timeout: 300000,
1029
1080
  maxBuffer: 10 * 1024 * 1024,
1030
1081
  windowsHide: true,
@@ -1042,7 +1093,7 @@ async function runLatestUpdate() {
1042
1093
  }
1043
1094
  }
1044
1095
  try {
1045
- const result = await execP(npmCommand, npmCommandArgs, {
1096
+ const result = await execNetwork('Running latest Tersio installer', npmCommand, npmCommandArgs, {
1046
1097
  timeout: 300000,
1047
1098
  maxBuffer: 10 * 1024 * 1024,
1048
1099
  windowsHide: true,
@@ -1060,7 +1111,6 @@ async function runLatestUpdate() {
1060
1111
  process.stdout.write(err.stdout);
1061
1112
  if (err.stderr)
1062
1113
  process.stderr.write(err.stderr);
1063
- console.error(`\n[fail] Could not run ${PACKAGE_NAME}@latest: ${err.message}`);
1064
1114
  process.exitCode = 1;
1065
1115
  }
1066
1116
  }
@@ -1076,6 +1126,47 @@ function defaultProfile() {
1076
1126
  function tty() {
1077
1127
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
1078
1128
  }
1129
+ let spinnerDepth = 0;
1130
+ // Run async work under a TTY-only timer spinner. The spinner is cleared before
1131
+ // the caller prints its normal result line, preserving locked output shapes.
1132
+ async function withInteractiveSpinner(message, work) {
1133
+ if (!tty() || spinnerDepth > 0)
1134
+ return work(() => { });
1135
+ // Clack manages stdin itself. Close the legacy question interface before its
1136
+ // first use; non-interactive callers never reach this branch.
1137
+ closeRL();
1138
+ const active = clackSpinner({ indicator: 'timer' });
1139
+ active.start(message);
1140
+ spinnerDepth += 1;
1141
+ try {
1142
+ return await work((next) => { active.message(next); });
1143
+ }
1144
+ finally {
1145
+ active.clear();
1146
+ spinnerDepth -= 1;
1147
+ }
1148
+ }
1149
+ async function askInteractiveChoice(message, options, initialValue) {
1150
+ if (!tty())
1151
+ return { status: 'unavailable' };
1152
+ closeRL();
1153
+ const choice = await clackSelect({ message, options, initialValue });
1154
+ if (typeof choice !== 'string') {
1155
+ clackCancel('Aborted.');
1156
+ return { status: 'cancelled' };
1157
+ }
1158
+ return { status: 'selected', value: choice };
1159
+ }
1160
+ // Run collecting work under one TTY-only Clack task. Callers print after the
1161
+ // task completes, keeping normal output out of the spinner animation.
1162
+ async function runInteractivePhase(title, collect) {
1163
+ if (!tty())
1164
+ return collect();
1165
+ closeRL();
1166
+ let result;
1167
+ await clackTasks([{ title, task: async () => { result = await collect(); } }]);
1168
+ return result;
1169
+ }
1079
1170
  // Determine install scope: reinstall > flag > non-interactive default > prompt.
1080
1171
  async function resolveScope() {
1081
1172
  if (reinstall) {
@@ -1096,6 +1187,17 @@ async function resolveScope() {
1096
1187
  console.log(` Scope: user (${install ? 'install default' : '--scope omitted, defaulting to user with --yes'})`);
1097
1188
  return '1';
1098
1189
  }
1190
+ if (tty()) {
1191
+ const choice = await askInteractiveChoice('Install scope', [
1192
+ { value: '1', label: 'User-level', hint: 'all OMP sessions' },
1193
+ { value: '2', label: 'Project-level', hint: 'this repo only' },
1194
+ { value: '3', label: 'Both' },
1195
+ ], '1');
1196
+ if (choice.status === 'selected')
1197
+ return choice.value;
1198
+ closeRL();
1199
+ process.exit(130);
1200
+ }
1099
1201
  console.log('\nInstall scope:');
1100
1202
  console.log(' 1) User-level (all OMP sessions)');
1101
1203
  console.log(' 2) Project-level (this repo only)');
@@ -1114,22 +1216,17 @@ async function resolveProfile() {
1114
1216
  // Only for a real user at a terminal, only when no default flags were
1115
1217
  // given, and never for --apply-update runs.
1116
1218
  if (tty() && !profileFlagsGiven && !applyUpdate && (install || reinstall)) {
1117
- console.log(' Session-start defaults — Combo preset:');
1118
- console.log(' 1) off');
1119
- console.log(' 2) medium (caveman=lite, rtk=on, ponytail=lite)');
1120
- console.log(' 3) balanced (caveman=full, rtk=on, ponytail=full)');
1121
- console.log(' 4) max (caveman=ultra, rtk=on, ponytail=ultra)');
1122
- for (;;) {
1123
- const answer = (await ask(' Choose [1-4] (default 1): ')).trim();
1124
- if (!answer) {
1125
- profile.comboDefault = 'off';
1126
- break;
1127
- }
1128
- if (COMBO_MENU[answer]) {
1129
- profile.comboDefault = COMBO_MENU[answer];
1130
- break;
1131
- }
1132
- console.log(` [fail] Invalid choice: ${answer}. Choose 1, 2, 3, or 4.`);
1219
+ const choice = await askInteractiveChoice('Session-start defaults — Combo preset', [
1220
+ { value: 'off', label: 'off' },
1221
+ { value: 'medium', label: 'medium', hint: 'caveman=lite, rtk=on, ponytail=lite' },
1222
+ { value: 'balanced', label: 'balanced', hint: 'caveman=full, rtk=on, ponytail=full' },
1223
+ { value: 'max', label: 'max', hint: 'caveman=ultra, rtk=on, ponytail=ultra' },
1224
+ ], 'off');
1225
+ if (choice.status === 'selected')
1226
+ profile.comboDefault = choice.value;
1227
+ else {
1228
+ closeRL();
1229
+ process.exit(130);
1133
1230
  }
1134
1231
  }
1135
1232
  // Explicit flags always win over the Combo preset.