@krtclcdy/tersio 2.24.1 → 2.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -21
- package/cli/banner.js +7 -4
- package/cli/common.js +14 -13
- package/cli/currency.js +1 -2
- package/cli/dashboard.js +57 -14
- package/cli/doctor-fix.js +88 -21
- package/cli/doctor.js +32 -24
- package/cli/hosts.js +19 -18
- package/cli/install.js +123 -52
- package/cli/interactive.js +29 -19
- package/cli/manifest.js +7 -0
- package/cli/profile.js +42 -11
- package/cli/rtk-wiring.js +28 -26
- package/cli/settings.js +37 -8
- package/cli/uninstall.js +40 -7
- package/cli/update.js +22 -14
- package/cli/usage.js +39 -14
- package/dashboard/dist/index.html +16 -16
- package/extensions/ai-addons-updater/index.js +69 -15
- package/extensions/ai-addons-updater/index.ts +60 -17
- package/extensions/caveman-session/index.js +28 -55
- package/extensions/caveman-session/index.ts +28 -55
- package/extensions/combo-toggle/index.js +40 -58
- package/extensions/combo-toggle/index.ts +38 -67
- package/extensions/lib/utils.js +60 -15
- package/extensions/lib/utils.ts +59 -15
- package/extensions/opencode/index.js +104 -0
- package/extensions/opencode/server.js +190 -0
- package/extensions/opencode/server.ts +200 -0
- package/extensions/rtk-session/index.js +20 -35
- package/extensions/rtk-session/index.ts +19 -34
- package/extensions/shared/carbon.js +1 -2
- package/extensions/shared/carbon.ts +1 -2
- package/extensions/shared/host.js +95 -46
- package/extensions/shared/host.ts +106 -50
- package/extensions/shared/plugin-settings.js +4 -6
- package/extensions/shared/plugin-settings.ts +4 -6
- package/extensions/shared/pricing.js +79 -25
- package/extensions/shared/pricing.ts +80 -26
- package/extensions/shared/rtk-gain.js +3 -5
- package/extensions/shared/rtk-gain.ts +3 -5
- package/extensions/shared/session-state.js +10 -33
- package/extensions/shared/session-state.ts +12 -38
- package/extensions/shared/status.js +38 -0
- package/extensions/shared/status.ts +45 -0
- package/extensions/shared/types.js +1 -2
- package/extensions/shared/types.ts +2 -2
- package/extensions/shared/usage-ledger.js +190 -56
- package/extensions/shared/usage-ledger.ts +194 -58
- package/extensions/shared/usage-store.js +228 -64
- package/extensions/shared/usage-store.ts +235 -67
- package/extensions/tersio-commands/index.js +4 -9
- package/extensions/tersio-commands/index.ts +4 -9
- package/package.json +8 -2
- package/tersio.js +3 -3
- package/cli/agent-hosts.js +0 -42
- package/cli/agents.js +0 -266
- package/cli/layer.js +0 -22
- package/cli/omp-layer.js +0 -51
- package/cli/pi-layer.js +0 -37
- package/cli/pi-wiring.js +0 -92
- package/extensions/shared/omp-prompt.js +0 -12
package/cli/doctor.js
CHANGED
|
@@ -1,34 +1,31 @@
|
|
|
1
1
|
// cli/doctor.ts — installation health checks.
|
|
2
2
|
import { existsSync, promises as fs } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, args, dryRun, fix, yes, execP, parseJsonObject, relTime, } from './common.js';
|
|
5
|
-
import { detectHosts, ompPackageDir } from './hosts.js';
|
|
4
|
+
import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, args, dryRun, fix, yes, execP, parseJsonObject, relTime, } from './common.js';
|
|
5
|
+
import { detectHosts, hostExtensionsDir, ompPackageDir } from './hosts.js';
|
|
6
6
|
import { askInteractiveChoice, askInteractiveConfirm, runInteractivePhase } from './interactive.js';
|
|
7
7
|
import { usageDbPath } from '../extensions/shared/usage-store.js';
|
|
8
8
|
import { pricesCachePath } from '../extensions/shared/pricing.js';
|
|
9
9
|
import { readTextIfExists, resolveRtkBinary } from '../extensions/lib/utils.js';
|
|
10
|
+
import { TREE_FILES } from './manifest.js';
|
|
10
11
|
async function runDoctor(recheck = false) {
|
|
11
12
|
console.log('\n=== Tersio Doctor ===');
|
|
12
|
-
// OMP loads the plugin from its plugins dir; pi loads the same package as a
|
|
13
|
-
// pi package, which its own settings.json declares and its npm dir holds.
|
|
13
|
+
// OMP loads the plugin from its plugins dir; pi loads the same package as a pi package, which its own settings.json declares and its npm dir holds.
|
|
14
14
|
const pluginsDir = OMP_PLUGINS_DIR;
|
|
15
15
|
const rtkBin = resolveRtkBinary();
|
|
16
16
|
// Ponytail
|
|
17
17
|
const ponytailPkg = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
|
|
18
|
-
const ponytailExt = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
|
|
19
18
|
const tersioPluginDir = path.join(pluginsDir, 'node_modules', '@krtclcdy', 'tersio');
|
|
20
19
|
const cavemanIndex = path.join(tersioPluginDir, 'extensions', 'caveman-session', 'index.ts');
|
|
21
20
|
const cavemanRule = path.join(tersioPluginDir, 'extensions', 'caveman-session', 'rule.md');
|
|
22
21
|
const rtkIndex = path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts');
|
|
23
22
|
const updaterIndex = path.join(tersioPluginDir, 'extensions', 'ai-addons-updater', 'index.ts');
|
|
24
|
-
// Independent probes start concurrently; sections report in fixed order as
|
|
25
|
-
// their data settles. Every probe resolves instead of rejecting.
|
|
23
|
+
// Independent probes start concurrently; sections report in fixed order as their data settles. Every probe resolves instead of rejecting.
|
|
26
24
|
const hosts = detectHosts();
|
|
27
25
|
const probes = {
|
|
28
26
|
ompPkgText: readTextIfExists(path.join(ompPackageDir(), 'package.json')),
|
|
29
27
|
configText: readTextIfExists(path.join(OMP_AGENT_DIR, 'config.yml')),
|
|
30
28
|
ponytailPkgText: readTextIfExists(ponytailPkg),
|
|
31
|
-
ponytailExtText: readTextIfExists(ponytailExt),
|
|
32
29
|
rtkBinText: rtkBin ? readTextIfExists(rtkBin) : Promise.resolve(null),
|
|
33
30
|
cavemanIndexText: readTextIfExists(cavemanIndex),
|
|
34
31
|
cavemanRuleText: readTextIfExists(cavemanRule),
|
|
@@ -43,7 +40,7 @@ async function runDoctor(recheck = false) {
|
|
|
43
40
|
const err = e;
|
|
44
41
|
return err.stdout?.trim() || err.stderr?.trim() || null;
|
|
45
42
|
}) : Promise.resolve(null);
|
|
46
|
-
const [[ompPkgText, configText], [cavemanIndexText, rtkIndexText, updaterIndexText, ponytailPkgText
|
|
43
|
+
const [[ompPkgText, configText], [cavemanIndexText, rtkIndexText, updaterIndexText, ponytailPkgText], [cavemanRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, ponytailMtime, pricesMtime]] = await runInteractivePhase('Checking installation', () => Promise.all([
|
|
47
44
|
Promise.all([
|
|
48
45
|
probes.ompPkgText,
|
|
49
46
|
probes.configText,
|
|
@@ -53,7 +50,6 @@ async function runDoctor(recheck = false) {
|
|
|
53
50
|
probes.rtkIndexText,
|
|
54
51
|
probes.updaterIndexText,
|
|
55
52
|
probes.ponytailPkgText,
|
|
56
|
-
probes.ponytailExtText,
|
|
57
53
|
]),
|
|
58
54
|
Promise.all([
|
|
59
55
|
probes.cavemanRuleText,
|
|
@@ -65,8 +61,7 @@ async function runDoctor(recheck = false) {
|
|
|
65
61
|
probes.pricesMtime,
|
|
66
62
|
]),
|
|
67
63
|
]));
|
|
68
|
-
// Categorized output with a tally; success rows stay quiet (no path echoes)
|
|
69
|
-
// while failures print the expected path or fix so they stay actionable.
|
|
64
|
+
// Categorized output with a tally; success rows stay quiet (no path echoes) while failures print the expected path or fix so they stay actionable.
|
|
70
65
|
const tally = { ok: 0, missing: 0, warn: 0 };
|
|
71
66
|
function absDate(ms) {
|
|
72
67
|
const d = new Date(ms);
|
|
@@ -88,37 +83,45 @@ async function runDoctor(recheck = false) {
|
|
|
88
83
|
tally.warn++;
|
|
89
84
|
console.log(` ⚠️ ${label}: warn ${detail}`);
|
|
90
85
|
}
|
|
91
|
-
// A host you do not use is not a fault, so it stays out of the tally and only
|
|
92
|
-
// carries the one line that installs it.
|
|
86
|
+
// A host you do not use is not a fault, so it stays out of the tally and only carries the one line that installs it.
|
|
93
87
|
function unused(label, detail) {
|
|
94
88
|
console.log(` — ${label}: not installed ${detail}`);
|
|
95
89
|
}
|
|
96
90
|
section('Hosts');
|
|
97
91
|
for (const host of hosts) {
|
|
92
|
+
// pi can hold a working tree while reading as not installed; use the tree as ground truth.
|
|
93
|
+
const piTree = (host.id === 'pi' && !host.installed && existsSync(hostExtensionsDir('pi')))
|
|
94
|
+
? hostExtensionsDir('pi')
|
|
95
|
+
: null;
|
|
96
|
+
if (piTree !== null) {
|
|
97
|
+
check(host.label, true, `extensions ${PACKAGE_NAME} ${PACKAGE_VERSION}`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
98
100
|
if (!host.installed) {
|
|
99
|
-
// A pi declaration without the tree or the package is a broken install,
|
|
100
|
-
// so it stays a counted row rather than reading as "not installed".
|
|
101
|
+
// A pi declaration without the tree or the package is a broken install, so it stays a counted row rather than reading as "not installed".
|
|
101
102
|
if (host.declared)
|
|
102
103
|
check(host.label, false, `declared (${host.declared}) but not installed`);
|
|
103
104
|
else
|
|
104
105
|
unused(host.label, `· ${host.installCmd}`);
|
|
105
106
|
continue;
|
|
106
107
|
}
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
// One line per host, omp style: kind and version, no path echo.
|
|
109
|
+
const kind = host.via === 'tree' ? 'extensions' : 'package';
|
|
110
|
+
check(host.label, true, `${kind} ${PACKAGE_NAME}${host.version ? ` ${host.version}` : ''}`);
|
|
109
111
|
}
|
|
110
112
|
section('Extensions & plugins');
|
|
111
|
-
|
|
112
|
-
if (ompEntry?.via === 'package') {
|
|
113
|
+
if (hosts.find((host) => host.id === 'omp')?.via === 'package') {
|
|
113
114
|
const explicitEntries = (configText ?? '').split('\n')
|
|
114
115
|
.map((line) => line.trim().replace(/^-\s*/, '').replace(/^['"]|['"]$/g, ''))
|
|
115
116
|
.filter((line) => line.startsWith('/') || line.startsWith('.'));
|
|
116
117
|
const duplicateExtensions = [...new Set(explicitEntries.filter((entry, index) => explicitEntries.indexOf(entry) !== index))];
|
|
117
118
|
check('Unique config registrations', duplicateExtensions.length === 0, duplicateExtensions.length ? duplicateExtensions.join(', ') : '');
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
check('
|
|
119
|
+
// A half-written tree imports a module that is absent; OMP then drops the extension.
|
|
120
|
+
const extDir = path.join(tersioPluginDir, 'extensions');
|
|
121
|
+
const missing = TREE_FILES.filter((file) => !existsSync(path.join(extDir, ...file.split('/'))));
|
|
122
|
+
check('Extension tree', missing.length === 0, missing.length === 0
|
|
123
|
+
? `${TREE_FILES.length} files ok`
|
|
124
|
+
: `${missing.length} of ${TREE_FILES.length} missing: ${missing.join(', ')}`);
|
|
122
125
|
}
|
|
123
126
|
section('Usage & records');
|
|
124
127
|
console.log(` Usage DB (tersio-owned · local hosted): ${usageDbPath()}`);
|
|
@@ -129,6 +132,11 @@ async function runDoctor(recheck = false) {
|
|
|
129
132
|
check('Caveman rule', cavemanRuleText !== null, ruleAge);
|
|
130
133
|
const rtkAge = rtkMtime ? `(updated ${relTime(Date.now() - rtkMtime.mtimeMs)} · ${absDate(rtkMtime.mtimeMs)})` : '';
|
|
131
134
|
check('RTK binary', rtkBin !== null, rtkBinText === null ? 'not found in PATH' : [rtkVersion, rtkAge].filter(Boolean).join(' '));
|
|
135
|
+
// rtk_run needs a host exec API, which doctor cannot assume.
|
|
136
|
+
if (rtkBin === null)
|
|
137
|
+
unused('RTK exec', '· install Tersio to place rtk in ~/.bun/bin');
|
|
138
|
+
else if (!hosts.some((host) => host.installed && (host.id === 'omp' || host.id === 'opencode')))
|
|
139
|
+
warnLine('RTK exec', 'no installed host exposes an exec API — rtk_run is unavailable; call rtk from bash instead');
|
|
132
140
|
if (rtkBinText !== null && !rtkVersion)
|
|
133
141
|
warnLine('RTK version', 'unavailable — binary may not be executable');
|
|
134
142
|
const ponytailAge = ponytailMtime ? `(updated ${relTime(Date.now() - ponytailMtime.mtimeMs)} · ${absDate(ponytailMtime.mtimeMs)})` : '';
|
package/cli/hosts.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
// cli/hosts.ts — the agent hosts Tersio installs into, and what is on disk.
|
|
2
|
-
// One detector, shared by the doctor and both menus, so they cannot disagree.
|
|
1
|
+
// cli/hosts.ts — the agent hosts Tersio installs into, and what is on disk. One detector, shared by the doctor and both menus, so they cannot disagree.
|
|
3
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
4
3
|
import path from 'node:path';
|
|
5
4
|
import { HOME, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION } from './common.js';
|
|
6
5
|
import { TREE_FILES } from './manifest.js';
|
|
7
6
|
import { piAgentDir } from '../extensions/lib/utils.js';
|
|
8
7
|
export function hostExtensionsDir(id, agentDir = piAgentDir()) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
if (id === 'omp')
|
|
9
|
+
return path.join(HOME, '.omp', 'agent', 'extensions');
|
|
10
|
+
if (id === 'opencode')
|
|
11
|
+
return path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
|
|
12
|
+
return path.join(agentDir, 'extensions');
|
|
12
13
|
}
|
|
13
14
|
export function missingTreeFiles(id, agentDir = piAgentDir()) {
|
|
14
15
|
const dir = hostExtensionsDir(id, agentDir);
|
|
@@ -32,8 +33,7 @@ function packageVersion(dir) {
|
|
|
32
33
|
return null;
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
|
-
// The tersio package pi declared in its own settings.json, or null. pi
|
|
36
|
-
// identifies a source by name, so an npm spec, git URL, or path all count.
|
|
36
|
+
// The tersio package pi declared in its own settings.json, or null. pi identifies a source by name, so an npm spec, git URL, or path all count.
|
|
37
37
|
export function piTersioSource(agentDir = piAgentDir()) {
|
|
38
38
|
let settings;
|
|
39
39
|
try {
|
|
@@ -61,24 +61,26 @@ export function piTersioSource(agentDir = piAgentDir()) {
|
|
|
61
61
|
const HOSTS = [
|
|
62
62
|
{ id: 'omp', label: 'Oh My Pi', bin: 'omp', installCmd: `omp plugin install ${PACKAGE_NAME}`, removeCmd: `omp plugin remove ${PACKAGE_NAME}` },
|
|
63
63
|
{ id: 'pi', label: 'Pi', bin: 'pi', installCmd: `pi install npm:${PACKAGE_NAME}`, removeCmd: `pi remove npm:${PACKAGE_NAME}` },
|
|
64
|
+
{ id: 'opencode', label: 'OpenCode', bin: 'opencode', installCmd: `tersio install --host opencode`, removeCmd: `tersio uninstall --host opencode` },
|
|
64
65
|
];
|
|
65
66
|
function detect(agentDir = piAgentDir()) {
|
|
66
67
|
return HOSTS.map((host) => {
|
|
67
|
-
const pkgDir = host.id === 'omp' ? ompPackageDir() : piPackageDir(agentDir);
|
|
68
|
+
const pkgDir = host.id === 'omp' ? ompPackageDir() : host.id === 'pi' ? piPackageDir(agentDir) : null;
|
|
68
69
|
const tree = hostExtensionsDir(host.id, agentDir);
|
|
69
|
-
// A tree wins: it is what the installer writes, and it carries no
|
|
70
|
-
// package.json, so its version is ours.
|
|
70
|
+
// A tree wins: it is what the installer writes, and it carries no package.json, so its version is ours.
|
|
71
71
|
const via = missingTreeFiles(host.id, agentDir).length === 0
|
|
72
72
|
? 'tree'
|
|
73
|
-
: existsSync(pkgDir) ? 'package' : null;
|
|
73
|
+
: pkgDir && existsSync(pkgDir) ? 'package' : null;
|
|
74
74
|
return {
|
|
75
75
|
...host,
|
|
76
76
|
installed: via !== null,
|
|
77
77
|
via,
|
|
78
78
|
declared: host.id === 'omp'
|
|
79
|
-
? (existsSync(pkgDir) ? `plugin ${PACKAGE_NAME}` : null)
|
|
80
|
-
:
|
|
81
|
-
|
|
79
|
+
? (pkgDir && existsSync(pkgDir) ? `plugin ${PACKAGE_NAME}` : null)
|
|
80
|
+
: host.id === 'pi'
|
|
81
|
+
? piTersioSource(agentDir)
|
|
82
|
+
: existsSync(tree) ? 'plugin tree' : null,
|
|
83
|
+
version: via === 'tree' ? PACKAGE_VERSION : via === 'package' && pkgDir ? packageVersion(pkgDir) : null,
|
|
82
84
|
dir: via === 'tree' ? tree : via === 'package' ? pkgDir : null,
|
|
83
85
|
};
|
|
84
86
|
});
|
|
@@ -99,16 +101,15 @@ export function hostHint(host) {
|
|
|
99
101
|
return host.via === 'tree' ? 'extension tree' : 'plugin package';
|
|
100
102
|
return host.installCmd;
|
|
101
103
|
}
|
|
102
|
-
// `--host omp|pi` from raw argv; undefined when absent. Throws on an unknown
|
|
103
|
-
// value so the caller can exit(1) with the message.
|
|
104
|
+
// `--host omp|pi` from raw argv; undefined when absent. Throws on an unknown value so the caller can exit(1) with the message.
|
|
104
105
|
export function parseHostArg(argv) {
|
|
105
106
|
const i = argv.indexOf('--host');
|
|
106
107
|
const raw = (i === -1 ? undefined : argv[i + 1]) ?? argv.find((a) => a.startsWith('--host='))?.slice('--host='.length);
|
|
107
108
|
if (raw === undefined)
|
|
108
109
|
return undefined;
|
|
109
110
|
const value = raw.trim().toLowerCase();
|
|
110
|
-
if (value !== 'omp' && value !== 'pi')
|
|
111
|
-
throw new Error(`Invalid --host: ${raw}. Valid: omp, pi`);
|
|
111
|
+
if (value !== 'omp' && value !== 'pi' && value !== 'opencode')
|
|
112
|
+
throw new Error(`Invalid --host: ${raw}. Valid: omp, pi, opencode`);
|
|
112
113
|
return value;
|
|
113
114
|
}
|
|
114
115
|
export { HOME, detect as detectHosts };
|
package/cli/install.js
CHANGED
|
@@ -3,7 +3,7 @@ import { promises as fs } from 'node:fs';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { BUN_BIN_DIR, COMBO_PRESET_MODES, IS_WINDOWS, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, args, applyUpdate, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, ponytailDefaultFlag, profileFlagsGiven, rtkDefaultFlag, verbose, yes, dashboardExport, dashboardPort, debug, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
|
|
6
|
+
import { BUN_BIN_DIR, COMBO_PRESET_MODES, HOME, IS_WINDOWS, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, args, allowUnverified, applyUpdate, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, ponytailDefaultFlag, profileFlagsGiven, rtkDefaultFlag, verbose, yes, dashboardExport, dashboardPort, debug, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
|
|
7
7
|
import { askInteractiveChoice, askInteractiveConfirm, closeRL, execNetwork, tty, withInteractiveSpinner, withInteractiveTask, sayTagged } from './interactive.js';
|
|
8
8
|
import { printWelcome } from './banner.js';
|
|
9
9
|
import { checkForUpdate, runLatestUpdate } from './update.js';
|
|
@@ -12,12 +12,12 @@ import { runDoctor } from './doctor.js';
|
|
|
12
12
|
import { runReset } from './reset.js';
|
|
13
13
|
import { runUsage } from './usage.js';
|
|
14
14
|
import { runDashboard } from './dashboard.js';
|
|
15
|
-
import { wireRtkOmp } from './rtk-wiring.js';
|
|
15
|
+
import { wireRtkOmp, wireRtkOpencode, wireRtkPi } from './rtk-wiring.js';
|
|
16
16
|
import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, findHoistedPackage, httpsGet, httpsDownload, parseChecksum, piAgentDir, readTextIfExists, resolveRtkBinary, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
|
|
17
|
-
import { storedProfile, writePluginSettings } from './profile.js';
|
|
17
|
+
import { formatCliStatus, storedProfile, storedProfileSync, writePluginSettings } from './profile.js';
|
|
18
18
|
import { runSettings } from './settings.js';
|
|
19
19
|
import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
|
|
20
|
-
import { filesUnder, sourcePath } from './manifest.js';
|
|
20
|
+
import { OPENCODE_SERVER_SHIM, filesUnder, sourcePath } from './manifest.js';
|
|
21
21
|
import { detectHosts, hostHint, hostLabel, parseHostArg, piTersioSource } from './hosts.js';
|
|
22
22
|
async function stepPonytail(pluginsDir, options) {
|
|
23
23
|
if (!options.quiet)
|
|
@@ -163,19 +163,23 @@ async function downloadRtkChecksums(release) {
|
|
|
163
163
|
return null;
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
166
|
+
// Fails closed: a missing checksum is worse than a failed install, since an unverifiable RTK binary runs with the user's privileges. `--allow-unverified` opts out.
|
|
167
|
+
async function verifyRtkArchive(archivePath, assetName, checksumsText) {
|
|
168
|
+
const unverified = (reason) => {
|
|
169
|
+
if (allowUnverified) {
|
|
170
|
+
sayTagged(` [warn] ${reason} — installing anyway (--allow-unverified)`);
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
sayTagged(` [fail] ${reason}`);
|
|
174
|
+
sayTagged(' [hint] RTK not installed. Retry with --allow-unverified to accept an unverified binary.');
|
|
175
|
+
return false;
|
|
176
|
+
};
|
|
177
|
+
if (!checksumsText)
|
|
178
|
+
return unverified(`No checksums.txt published for ${assetName}`);
|
|
172
179
|
const expected = parseChecksum(checksumsText, assetName);
|
|
180
|
+
if (!expected)
|
|
181
|
+
return unverified(`checksums.txt has no entry for ${assetName}`);
|
|
173
182
|
const actual = await sha256File(archivePath);
|
|
174
|
-
if (!expected) {
|
|
175
|
-
if (!options.quiet)
|
|
176
|
-
sayTagged(` [warn] checksums.txt missing entry for ${assetName} — skipping verification`);
|
|
177
|
-
return true;
|
|
178
|
-
}
|
|
179
183
|
if (actual === expected) {
|
|
180
184
|
debug(`Checksum verified for ${assetName}`);
|
|
181
185
|
return true;
|
|
@@ -225,34 +229,35 @@ async function extractRtkArchive(archivePath, extractDir) {
|
|
|
225
229
|
sayTagged(` [fail] Unknown archive format: ${archivePath}`);
|
|
226
230
|
return false;
|
|
227
231
|
}
|
|
228
|
-
// The binary is machine-wide
|
|
229
|
-
// finds one present only rebinds. `tersio update` still refreshes it.
|
|
232
|
+
// The binary is machine-wide; hosts without a hook only rebind. `tersio update` still refreshes it.
|
|
230
233
|
async function stepRtk(binDir, options, target = 'omp') {
|
|
231
234
|
const binDest = path.join(binDir, RTK_BINARY_NAME);
|
|
232
235
|
const found = resolveRtkBinary();
|
|
236
|
+
const hostName = target === 'omp' ? 'OMP' : target === 'opencode' ? 'OpenCode' : 'pi';
|
|
233
237
|
const bind = async (binary) => {
|
|
234
|
-
if (target !== 'omp')
|
|
235
|
-
return;
|
|
236
238
|
if (!(await fileExists(binary))) {
|
|
237
|
-
console.log(
|
|
239
|
+
console.log(` [skip] no rtk binary to wire — install rtk, then run: rtk init -g ${target === 'opencode' ? '--opencode' : `--agent ${target}`}`);
|
|
238
240
|
return;
|
|
239
241
|
}
|
|
240
|
-
|
|
242
|
+
if (target === 'opencode')
|
|
243
|
+
await wireRtkOpencode(binary, options);
|
|
244
|
+
else if (target === 'pi')
|
|
245
|
+
await wireRtkPi(binary, options);
|
|
246
|
+
else
|
|
247
|
+
await wireRtkOmp(binary, options);
|
|
241
248
|
};
|
|
242
249
|
if (found && !applyUpdate) {
|
|
243
|
-
// The path only under --verbose: a plain run, and a dry run, stay free of
|
|
244
|
-
// real user paths.
|
|
250
|
+
// The path only under --verbose: a plain run, and a dry run, stay free of real user paths.
|
|
245
251
|
if (!options.quiet) {
|
|
246
252
|
const where = verbose ? ` (${found})` : '';
|
|
247
|
-
console.log(` RTK — already installed${where}
|
|
253
|
+
console.log(` RTK — already installed${where}, binding into ${hostName}`);
|
|
248
254
|
}
|
|
249
255
|
await bind(found);
|
|
250
256
|
return;
|
|
251
257
|
}
|
|
252
258
|
if (!options.quiet)
|
|
253
|
-
console.log(` RTK — download binary and
|
|
254
|
-
// A failed download must not skip wiring: a pre-existing binary serves the
|
|
255
|
-
// OMP hook just as well. Dry runs stay offline.
|
|
259
|
+
console.log(` RTK — download binary and wire into ${hostName}`);
|
|
260
|
+
// A failed download must not skip wiring: a pre-existing binary serves the host hook just as well. Dry runs stay offline.
|
|
256
261
|
if (options.dryRun) {
|
|
257
262
|
if (verbose && !options.quiet)
|
|
258
263
|
sayTagged(` [dry-run] would download rtk binary and install to ${binDest}`);
|
|
@@ -263,8 +268,7 @@ async function stepRtk(binDir, options, target = 'omp') {
|
|
|
263
268
|
const triple = resolveRtkTriple();
|
|
264
269
|
if (!triple)
|
|
265
270
|
return;
|
|
266
|
-
// One task with streamed sub-steps: release lookup, download, checksum,
|
|
267
|
-
// extract, install. Non-TTY keeps the plain lines each step already prints.
|
|
271
|
+
// One task with streamed sub-steps: release lookup, download, checksum, extract, install. Non-TTY keeps the plain lines each step already prints.
|
|
268
272
|
await withInteractiveTask('Installing RTK', async (update) => {
|
|
269
273
|
const release = await fetchJson(RTK_RELEASE_API);
|
|
270
274
|
const asset = findRtkAsset(release, triple);
|
|
@@ -281,7 +285,7 @@ async function stepRtk(binDir, options, target = 'omp') {
|
|
|
281
285
|
httpsDownload(asset.browser_download_url, archivePath),
|
|
282
286
|
]);
|
|
283
287
|
update('Verifying checksum');
|
|
284
|
-
if (!await verifyRtkArchive(archivePath, asset.name, checksumsText
|
|
288
|
+
if (!await verifyRtkArchive(archivePath, asset.name, checksumsText)) {
|
|
285
289
|
sayTagged(' [hint] RTK install skipped for this run');
|
|
286
290
|
return;
|
|
287
291
|
}
|
|
@@ -323,8 +327,7 @@ async function stepRtk(binDir, options, target = 'omp') {
|
|
|
323
327
|
// Wire rtk's tool_call extension: the binary alone never meters a session.
|
|
324
328
|
await bind(binDest);
|
|
325
329
|
}
|
|
326
|
-
// Copy sources into the target extension dir. First entry is required; the
|
|
327
|
-
// rest are optional companions.
|
|
330
|
+
// Copy sources into the target extension dir. First entry is required; the rest are optional companions.
|
|
328
331
|
async function copySources(extDir, files, skipLabel, options) {
|
|
329
332
|
const src = await readTextIfExists(files[0][0]);
|
|
330
333
|
if (!src) {
|
|
@@ -353,8 +356,7 @@ async function stepRtkSession(extDir, options) {
|
|
|
353
356
|
console.log(' RTK session — install session mode');
|
|
354
357
|
await copySources(extDir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
|
|
355
358
|
}
|
|
356
|
-
// One fetch serves the install; dry runs stay offline and preview the bundled
|
|
357
|
-
// rule's destination.
|
|
359
|
+
// One fetch serves the install; dry runs stay offline and preview the bundled rule's destination.
|
|
358
360
|
async function fetchCavemanRule(options) {
|
|
359
361
|
const bundled = await readTextIfExists(sourcePath('caveman-session/rule.md'));
|
|
360
362
|
if (options.dryRun)
|
|
@@ -403,11 +405,72 @@ async function stepCombo(extDir, options) {
|
|
|
403
405
|
console.log(' Combo — install preset switch');
|
|
404
406
|
await copySources(extDir, filesUnder('combo-toggle'), 'combo-toggle/index.ts', options);
|
|
405
407
|
}
|
|
408
|
+
// The tree ships no node_modules, so the full ruleset rides along as files.
|
|
409
|
+
const PONYTAIL_BUNDLE_FILES = [
|
|
410
|
+
'hooks/ponytail-instructions.js',
|
|
411
|
+
'hooks/ponytail-config.js',
|
|
412
|
+
'skills/ponytail/SKILL.md',
|
|
413
|
+
];
|
|
414
|
+
async function stepPonytailBundle(treeDir, options) {
|
|
415
|
+
const root = findHoistedPackage('@dietrichgebert/ponytail', path.dirname(fileURLToPath(import.meta.url)));
|
|
416
|
+
if (!root) {
|
|
417
|
+
if (!options.quiet)
|
|
418
|
+
console.log(' [skip] Ponytail package not found next to the CLI — OpenCode keeps the built-in text');
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (!options.quiet)
|
|
422
|
+
console.log(' Ponytail — bundle the full ruleset for OpenCode');
|
|
423
|
+
for (const rel of PONYTAIL_BUNDLE_FILES) {
|
|
424
|
+
const src = path.join(root, ...rel.split('/'));
|
|
425
|
+
let text;
|
|
426
|
+
try {
|
|
427
|
+
text = await fs.readFile(src, 'utf8');
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
sayTagged(` [fail] Ponytail bundle file missing: ${rel}`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
await writeIfChanged(path.join(treeDir, 'ponytail-bundle', ...rel.split('/')), text, options);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// OpenCode loads imported .ts plugins from the config plugins dir, so a plain tree copy + one config entry is enough.
|
|
437
|
+
async function stepOpencode(options) {
|
|
438
|
+
const dir = path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
|
|
439
|
+
if (!options.quiet)
|
|
440
|
+
console.log(` OpenCode — write the plugin tree (${dir})`);
|
|
441
|
+
await stepSharedSessionState(dir, options);
|
|
442
|
+
await copySources(dir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
|
|
443
|
+
await copySources(dir, filesUnder('caveman-session'), 'caveman-session/index.ts', options);
|
|
444
|
+
await stepCombo(dir, options);
|
|
445
|
+
await copySources(dir, filesUnder('tersio-commands'), 'tersio-commands/index.ts', options);
|
|
446
|
+
await stepUpdater(dir, options);
|
|
447
|
+
await copySources(dir, filesUnder('opencode'), 'opencode/server.ts', options);
|
|
448
|
+
await stepPonytailBundle(dir, options);
|
|
449
|
+
// The loader resolves <pluginDir>/server.ts, so mirror the entry at the tree root.
|
|
450
|
+
await writeIfChanged(path.join(dir, 'server.ts'), OPENCODE_SERVER_SHIM, options);
|
|
451
|
+
if (!options.dryRun)
|
|
452
|
+
await fs.rm(path.join(dir, 'opencode', 'index.ts'), { force: true }).catch(() => { });
|
|
453
|
+
const configPath = path.join(HOME, '.config', 'opencode', 'opencode.json');
|
|
454
|
+
let config = {};
|
|
455
|
+
try {
|
|
456
|
+
config = JSON.parse((await readTextIfExists(configPath)) ?? '{}');
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const plugins = Array.isArray(config.plugins) ? [...config.plugins] : [];
|
|
462
|
+
const withoutTersio = plugins.filter((p) => typeof p !== 'string' || !p.includes('/opencode/plugins/tersio'));
|
|
463
|
+
if (!withoutTersio.includes(dir))
|
|
464
|
+
withoutTersio.push(dir);
|
|
465
|
+
config.plugins = withoutTersio;
|
|
466
|
+
if (!options.dryRun)
|
|
467
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
468
|
+
await writeIfChanged(configPath, `${JSON.stringify(config, null, 2)}\n`, options);
|
|
469
|
+
}
|
|
406
470
|
async function resolveProfile(opts = {}) {
|
|
407
471
|
// Seed from the stored defaults so flag-less runs keep them.
|
|
408
472
|
const profile = await storedProfile();
|
|
409
|
-
// One numbered prompt for all three modes, for a real terminal user with no
|
|
410
|
-
// default flags. Never for --apply-update, and never for a script.
|
|
473
|
+
// One numbered prompt for all three modes, for a real terminal user with no default flags. Never for --apply-update, and never for a script.
|
|
411
474
|
if (tty() && !profileFlagsGiven && !applyUpdate) {
|
|
412
475
|
const choice = await askInteractiveChoice('Session-start defaults — Combo preset', [
|
|
413
476
|
{ value: 'off', label: 'off' },
|
|
@@ -458,7 +521,7 @@ async function runCommandMenu() {
|
|
|
458
521
|
console.log(`\n [update] staying on ${PACKAGE_VERSION} — run ` + '`tersio update`' + ` anytime`);
|
|
459
522
|
}
|
|
460
523
|
updatePromptDone = true;
|
|
461
|
-
const choice = await askInteractiveChoice(
|
|
524
|
+
const choice = await askInteractiveChoice(`Tersio — what next?\n${formatCliStatus(storedProfileSync())}`, [
|
|
462
525
|
{ value: 'install', label: 'Install add-ons', hint: 'user scope + combo defaults' },
|
|
463
526
|
{ value: 'update', label: 'Update', hint: 'CLI version check, then refresh add-ons (RTK, Caveman rule, Ponytail)' },
|
|
464
527
|
{ value: 'doctor', label: 'Doctor', hint: 'verify the installation' },
|
|
@@ -477,8 +540,7 @@ async function runCommandMenu() {
|
|
|
477
540
|
await runInstall();
|
|
478
541
|
break;
|
|
479
542
|
case 'update': {
|
|
480
|
-
// One bound flow: the version check already ran above, so report it
|
|
481
|
-
// and offer the refresh in the same breath — no second "update" quiz.
|
|
543
|
+
// One bound flow: the version check already ran above, so report it and offer the refresh in the same breath — no second "update" quiz.
|
|
482
544
|
if (typeof newer === 'string')
|
|
483
545
|
console.log(` tersio ${newer} available (installed ${PACKAGE_VERSION})`);
|
|
484
546
|
else
|
|
@@ -538,14 +600,11 @@ async function runCommandMenu() {
|
|
|
538
600
|
break;
|
|
539
601
|
}
|
|
540
602
|
}
|
|
541
|
-
// Which host this run targets. --host pins it; a TTY user picks; scripts,
|
|
542
|
-
// pipes, --yes, and --dry-run keep the pre-multi-host default of Oh My Pi.
|
|
603
|
+
// Which host this run targets. --host pins it; a TTY user picks; scripts, pipes, --yes, and --dry-run keep the pre-multi-host default of Oh My Pi.
|
|
543
604
|
let targetHost = 'omp';
|
|
544
|
-
// Hosts the menu advertises but cannot install yet. Listed disabled so the
|
|
545
|
-
// roadmap is visible without offering a target that does nothing.
|
|
605
|
+
// Hosts the menu advertises but cannot install yet. Listed disabled so the roadmap is visible without offering a target that does nothing.
|
|
546
606
|
const SOON_HOSTS = [
|
|
547
607
|
{ value: 'claude', label: 'Claude Code', hint: 'Coming soon' },
|
|
548
|
-
{ value: 'opencode', label: 'OpenCode', hint: 'Coming soon' },
|
|
549
608
|
{ value: 'codex', label: 'Codex', hint: 'Coming soon' },
|
|
550
609
|
];
|
|
551
610
|
async function chooseHost(title, only) {
|
|
@@ -568,8 +627,7 @@ async function chooseHost(title, only) {
|
|
|
568
627
|
function hostEntry(id) {
|
|
569
628
|
return detectHosts().find((host) => host.id === id);
|
|
570
629
|
}
|
|
571
|
-
// pi auto-discovers `<agent-dir>/extensions/**/index.ts`, so this route writes
|
|
572
|
-
// the same tree as OMP's and registers nothing.
|
|
630
|
+
// pi auto-discovers `<agent-dir>/extensions/**/index.ts`, so this route writes the same tree as OMP's and registers nothing.
|
|
573
631
|
async function stepPiLayer(options) {
|
|
574
632
|
const agentDir = piAgentDir();
|
|
575
633
|
const extDir = path.join(agentDir, 'extensions');
|
|
@@ -624,8 +682,7 @@ async function stepPonytailForPi(agentDir, options) {
|
|
|
624
682
|
}
|
|
625
683
|
}
|
|
626
684
|
async function runInstall() {
|
|
627
|
-
// Without this guard a picked command re-enters here with command === null
|
|
628
|
-
// and loops straight back into the picker.
|
|
685
|
+
// Without this guard a picked command re-enters here with command === null and loops straight back into the picker.
|
|
629
686
|
if (command === null && tty() && !yes && !updatePromptDone) {
|
|
630
687
|
await runCommandMenu();
|
|
631
688
|
return;
|
|
@@ -657,8 +714,7 @@ async function runInstall() {
|
|
|
657
714
|
if (tty() && !applyUpdate && command !== 'uninstall') {
|
|
658
715
|
const newer = await checkForUpdate();
|
|
659
716
|
if (typeof newer === 'string') {
|
|
660
|
-
// Update pending: offer it here instead of burying it above the install
|
|
661
|
-
// prompts. Yes runs the full update and stops.
|
|
717
|
+
// Update pending: offer it here instead of burying it above the install prompts. Yes runs the full update and stops.
|
|
662
718
|
if (command === null && !dryRun && !yes && !updatePromptDone) {
|
|
663
719
|
const answer = await askInteractiveConfirm(`tersio ${newer} is available (installed ${PACKAGE_VERSION}). Install it now?`);
|
|
664
720
|
if (answer.status === 'confirmed' && answer.value) {
|
|
@@ -686,7 +742,11 @@ async function runInstall() {
|
|
|
686
742
|
// Point of no return: disk writes start here; pipes stay unattended.
|
|
687
743
|
if (!yes && !dryRun && !applyUpdate && tty()) {
|
|
688
744
|
const target = hostEntry(targetHost);
|
|
689
|
-
const where = targetHost === 'pi'
|
|
745
|
+
const where = targetHost === 'pi'
|
|
746
|
+
? path.join(piAgentDir(), 'extensions')
|
|
747
|
+
: targetHost === 'opencode'
|
|
748
|
+
? path.join(HOME, '.config', 'opencode', 'plugins', 'tersio')
|
|
749
|
+
: path.join(OMP_AGENT_DIR, 'extensions');
|
|
690
750
|
console.log(`\nWill install into ${target.label}:`);
|
|
691
751
|
console.log(` ${where}`);
|
|
692
752
|
console.log(' caveman · rtk · ponytail session modes');
|
|
@@ -717,6 +777,17 @@ async function runInstall() {
|
|
|
717
777
|
sayTagged(` [fail] ${label}: ${shortError(e)}`);
|
|
718
778
|
}
|
|
719
779
|
};
|
|
780
|
+
if (targetHost === 'opencode') {
|
|
781
|
+
await capture('opencode tree', () => stepOpencode(installOptions));
|
|
782
|
+
await capture('rtk', () => stepRtk(BUN_BIN_DIR, installOptions, 'opencode'));
|
|
783
|
+
await capture('settings', () => writePluginSettings(profile, installOptions));
|
|
784
|
+
if (failures.length > 0)
|
|
785
|
+
console.log(`\nDone with ${failures.length} failure(s) — see [fail] lines above.`);
|
|
786
|
+
else
|
|
787
|
+
console.log('\nDone — restart OpenCode, then /combo balanced.');
|
|
788
|
+
closeRL();
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
720
791
|
if (targetHost === 'pi') {
|
|
721
792
|
await capture('pi tree', () => stepPiLayer(installOptions));
|
|
722
793
|
await capture('rtk', () => stepRtk(BUN_BIN_DIR, installOptions, 'pi'));
|
|
@@ -750,4 +821,4 @@ async function runInstall() {
|
|
|
750
821
|
}
|
|
751
822
|
closeRL();
|
|
752
823
|
}
|
|
753
|
-
export { runInstall };
|
|
824
|
+
export { runInstall, verifyRtkArchive };
|
package/cli/interactive.js
CHANGED
|
@@ -26,13 +26,11 @@ function tty() {
|
|
|
26
26
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
27
27
|
}
|
|
28
28
|
let spinnerDepth = 0;
|
|
29
|
-
// Run async work under a TTY-only timer spinner. The spinner is cleared before
|
|
30
|
-
// the caller prints its normal result line, preserving locked output shapes.
|
|
29
|
+
// Run async work under a TTY-only timer spinner. The spinner is cleared before the caller prints its normal result line, preserving locked output shapes.
|
|
31
30
|
async function withInteractiveSpinner(message, work) {
|
|
32
31
|
if (!tty() || spinnerDepth > 0)
|
|
33
32
|
return work(() => { });
|
|
34
|
-
// Clack manages stdin itself. Close the legacy question interface before its
|
|
35
|
-
// first use; non-interactive callers never reach this branch.
|
|
33
|
+
// Clack manages stdin itself. Close the legacy question interface before its first use; non-interactive callers never reach this branch.
|
|
36
34
|
closeRL();
|
|
37
35
|
const active = clackSpinner({ indicator: 'timer' });
|
|
38
36
|
active.start(message);
|
|
@@ -49,26 +47,33 @@ async function askInteractiveChoice(message, options, initialValue) {
|
|
|
49
47
|
if (!tty())
|
|
50
48
|
return { status: 'unavailable' };
|
|
51
49
|
closeRL();
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
try {
|
|
51
|
+
const choice = await clackSelect({ message, options, initialValue });
|
|
52
|
+
if (typeof choice !== 'string') {
|
|
53
|
+
clackCancel('Aborted.');
|
|
54
|
+
return { status: 'cancelled' };
|
|
55
|
+
}
|
|
56
|
+
return { status: 'selected', value: choice };
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
56
59
|
}
|
|
57
|
-
return { status: 'selected', value: choice };
|
|
58
60
|
}
|
|
59
61
|
async function askInteractiveConfirm(message, initialValue = true) {
|
|
60
62
|
if (!tty())
|
|
61
63
|
return { status: 'unavailable' };
|
|
62
64
|
closeRL();
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
try {
|
|
66
|
+
const answer = await clackConfirm({ message, initialValue });
|
|
67
|
+
if (typeof answer !== 'boolean') {
|
|
68
|
+
clackCancel('Aborted.');
|
|
69
|
+
return { status: 'cancelled' };
|
|
70
|
+
}
|
|
71
|
+
return { status: 'confirmed', value: answer };
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
67
74
|
}
|
|
68
|
-
return { status: 'confirmed', value: answer };
|
|
69
75
|
}
|
|
70
|
-
// Confirm a destructive run: a Clack dialog at a terminal, a plain prompt in a
|
|
71
|
-
// pipe or script. False means the user declined or aborted.
|
|
76
|
+
// Confirm a destructive run: a Clack dialog at a terminal, a plain prompt in a pipe or script. False means the user declined or aborted.
|
|
72
77
|
async function confirmDestructive(message) {
|
|
73
78
|
if (tty()) {
|
|
74
79
|
closeRL();
|
|
@@ -93,14 +98,17 @@ async function confirmDestructive(message) {
|
|
|
93
98
|
}
|
|
94
99
|
return true;
|
|
95
100
|
}
|
|
96
|
-
// Run collecting work under one TTY-only Clack task. Callers print after the
|
|
97
|
-
// task completes, keeping normal output out of the spinner animation.
|
|
101
|
+
// Run collecting work under one TTY-only Clack task. Callers print after the task completes, keeping normal output out of the spinner animation.
|
|
98
102
|
async function runInteractivePhase(title, collect) {
|
|
99
103
|
if (!tty())
|
|
100
104
|
return collect();
|
|
101
105
|
closeRL();
|
|
102
106
|
let result;
|
|
103
|
-
|
|
107
|
+
try {
|
|
108
|
+
await clackTasks([{ title, task: async () => { result = await collect(); } }]);
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
}
|
|
104
112
|
return result;
|
|
105
113
|
}
|
|
106
114
|
const TAG_KIND = {
|
|
@@ -131,6 +139,8 @@ async function withInteractiveTask(title, work) {
|
|
|
131
139
|
spin.error(shortSpinnerError(e));
|
|
132
140
|
throw e;
|
|
133
141
|
}
|
|
142
|
+
finally {
|
|
143
|
+
}
|
|
134
144
|
return result;
|
|
135
145
|
}
|
|
136
146
|
function shortSpinnerError(e) {
|