@krtclcdy/tersio 2.4.1 → 2.5.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/cli/install.js ADDED
@@ -0,0 +1,610 @@
1
+ // cli/install.ts — install/reinstall flow and all setup steps.
2
+ import { promises as fs } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { BUN_BIN_DIR, COMBO_PRESET_MODES, EXTENSIONS_KEY_RE, HOME, IS_WINDOWS, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, SCOPE_MAP, applyUpdate, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, install, ponytailDefaultFlag, profileFlagsGiven, reinstall, rtkDefaultFlag, scopeFlag, verbose, yes, debug, ensureExtensionAfterConfigEntry, ensureExtensionInConfig, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
7
+ import { ask, askInteractiveChoice, closeRL, execNetwork, tty, withInteractiveSpinner, } from './interactive.js';
8
+ import { checkForUpdate } from './update.js';
9
+ import { runUninstall } from './uninstall.js';
10
+ import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
11
+ // Paths to extension source files (relative to this script)
12
+ const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
13
+ const SHARED_SESSION_STATE = path.join(EXT_DIR, 'shared', 'session-state.js');
14
+ const CAVEMAN_INDEX = path.join(EXT_DIR, 'caveman-session', 'index.js');
15
+ const RTK_SESSION_INDEX = path.join(EXT_DIR, 'rtk-session', 'index.js');
16
+ const UPDATER_INDEX = path.join(EXT_DIR, 'ai-addons-updater', 'index.js');
17
+ const COMBO_TOGGLE_INDEX = path.join(EXT_DIR, 'combo-toggle', 'index.js');
18
+ const MODE_REINFORCEMENT_INDEX = path.join(EXT_DIR, 'shared', 'mode-reinforcement.js');
19
+ const SHARED_TYPES = path.join(EXT_DIR, 'shared', 'types.js');
20
+ const LIB_UTILS = path.join(EXT_DIR, 'lib', 'utils.js');
21
+ const SHARED_PLUGIN_SETTINGS = path.join(EXT_DIR, 'shared', 'plugin-settings.js');
22
+ const PONYTAIL_GITHUB_SPEC = 'github:DietrichGebert/ponytail';
23
+ const PONYTAIL_NPM_SPEC = '@dietrichgebert/ponytail@latest';
24
+ async function stepPonytail(pluginsDir, userDir, options) {
25
+ console.log('\n[1/7] Installing Ponytail plugin...');
26
+ await fs.mkdir(pluginsDir, { recursive: true });
27
+ const pkgPath = path.join(pluginsDir, 'package.json');
28
+ const pkg = await readPluginsPackage(pkgPath);
29
+ pkg.dependencies['@dietrichgebert/ponytail'] = PONYTAIL_GITHUB_SPEC;
30
+ if (options.dryRun) {
31
+ console.log(` [dry-run] would write ${pkgPath}`);
32
+ }
33
+ else {
34
+ await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
35
+ console.log(' [write] package.json');
36
+ }
37
+ const ponytailExtPath = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
38
+ const probeExt = async () => (await readTextIfExists(ponytailExtPath)) !== null;
39
+ // Fast path: extension already present and no refresh asked — skip network.
40
+ let ponytailExtExists = !options.reinstall && !options.dryRun ? await probeExt() : false;
41
+ if (ponytailExtExists) {
42
+ debug('Ponytail pi-extension already installed; skipping network refresh');
43
+ }
44
+ else if (options.dryRun) {
45
+ // Dry runs preview the wiring below without touching the network.
46
+ console.log(` [dry-run] would run: omp plugin install ${PONYTAIL_GITHUB_SPEC}`);
47
+ if (options.reinstall) {
48
+ console.log(` [dry-run] would run: npm install ${PONYTAIL_NPM_SPEC} --save --no-audit --no-fund`);
49
+ }
50
+ ponytailExtExists = true;
51
+ }
52
+ else {
53
+ // Try omp plugin install first
54
+ try {
55
+ await execNetwork('Installing Ponytail plugin', OMP_BIN, ['plugin', 'install', PONYTAIL_GITHUB_SPEC], { cwd: pluginsDir });
56
+ console.log(' [ok] omp plugin install ran');
57
+ }
58
+ catch (e) {
59
+ console.log(` [warn] omp plugin install failed: ${e.message}`);
60
+ }
61
+ if (options.reinstall) {
62
+ try {
63
+ await execNetwork('Refreshing Ponytail package', 'npm', ['install', PONYTAIL_NPM_SPEC, '--save', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 120000 });
64
+ console.log(' [ok] Ponytail refreshed');
65
+ }
66
+ catch (e) {
67
+ console.log(` [fail] Could not refresh ponytail: ${e.message}`);
68
+ console.log(` [hint] Manual: cd ~/.omp/plugins && npm install ${PONYTAIL_NPM_SPEC} --save --no-audit --no-fund`);
69
+ }
70
+ }
71
+ // Verify the pi-extension/index.js actually exists
72
+ ponytailExtExists = await probeExt();
73
+ // Fallback: try bun install or npm install
74
+ if (!ponytailExtExists) {
75
+ console.log(' [info] pi-extension/index.js not found after omp plugin install — trying npm/bun install...');
76
+ try {
77
+ await execNetwork('Installing Ponytail dependencies', 'npm', ['install'], { cwd: pluginsDir, timeout: 120000 });
78
+ console.log(' [ok] npm install completed');
79
+ }
80
+ catch {
81
+ try {
82
+ await execNetwork('Installing Ponytail dependencies', 'bun', ['install'], { cwd: pluginsDir, timeout: 120000 });
83
+ console.log(' [ok] bun install completed');
84
+ }
85
+ catch (e2) {
86
+ console.log(` [fail] Could not install ponytail: ${e2.message}`);
87
+ console.log(' [hint] Manual: cd ~/.omp/plugins && npm install');
88
+ }
89
+ }
90
+ ponytailExtExists = await probeExt();
91
+ }
92
+ // Last-resort fallback: git clone the repo into node_modules
93
+ if (!ponytailExtExists) {
94
+ console.log(' [info] npm/bun did not produce pi-extension — trying git clone...');
95
+ try {
96
+ const dest = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail');
97
+ await fs.mkdir(path.dirname(dest), { recursive: true });
98
+ await execNetwork('Cloning Ponytail repository', 'git', ['clone', '--depth', '1', 'https://github.com/DietrichGebert/ponytail.git', dest], { timeout: 180000 });
99
+ console.log(' [ok] git clone completed');
100
+ ponytailExtExists = await probeExt();
101
+ }
102
+ catch (e3) {
103
+ console.log(` [fail] git clone failed: ${e3.message}`);
104
+ console.log(' [hint] Install git or check network: https://github.com/DietrichGebert/ponytail');
105
+ }
106
+ }
107
+ }
108
+ if (!ponytailExtExists) {
109
+ console.log(' [skip] Ponytail pi-extension/index.js still not found — skill-only mode');
110
+ console.log(' [hint] The /ponytail command won\'t work, but ponytail skills will still load');
111
+ }
112
+ else if (!options.dryRun) {
113
+ // Wire extension into config.yml so /ponytail command loads
114
+ console.log(' [ok] Ponytail pi-extension found');
115
+ }
116
+ // Still set Ponytail config defaults even without the extension command
117
+ const configPath = path.join(userDir, 'config.yml');
118
+ await ensureExtensionInConfig(configPath, ponytailExtPath, 'ponytail', options);
119
+ await ensurePonytailConfigValue('defaultMode', 'off', options);
120
+ // hideStatus=true keeps the upstream ponytail bar hidden; combo owns the bar.
121
+ await ensurePonytailConfigValue('hideStatus', true, options);
122
+ }
123
+ // Registers this package in ~/.omp/plugins so OMP lists it on the
124
+ // Settings → Plugins page (OMP enumerates plugins/package.json dependencies).
125
+ // Returns true when the package is verified in plugins/node_modules.
126
+ async function stepSelfPlugin(pluginsDir, options) {
127
+ console.log('\n[2/7] Registering tersio as OMP plugin...');
128
+ const pkgPath = path.join(pluginsDir, 'package.json');
129
+ const pkg = await readPluginsPackage(pkgPath);
130
+ pkg.dependencies[PACKAGE_NAME] = `^${PACKAGE_VERSION}`;
131
+ for (const legacy of ['oh-my-pi-token-saver', 'tersio-omp']) {
132
+ if (legacy in pkg.dependencies) {
133
+ delete pkg.dependencies[legacy];
134
+ if (!options.dryRun)
135
+ console.log(` [migrate] dropped legacy ${legacy} dependency`);
136
+ }
137
+ }
138
+ if (options.dryRun) {
139
+ console.log(` [dry-run] would add ${PACKAGE_NAME}@^${PACKAGE_VERSION} to ${pkgPath}`);
140
+ console.log(` [dry-run] would run: npm install --no-audit --no-fund (in ${pluginsDir})`);
141
+ return false;
142
+ }
143
+ // pkg.dependencies[PACKAGE_NAME] was assigned above, so only reinstall skips this fast path.
144
+ if (!options.reinstall) {
145
+ const installedPkgRaw = await readTextIfExists(path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'package.json'));
146
+ if (parseJsonObject(installedPkgRaw)?.version === PACKAGE_VERSION) {
147
+ debug(`${PACKAGE_NAME} already installed at v${PACKAGE_VERSION}; skipping npm install`);
148
+ console.log(' [ok] Listed in OMP Settings → Plugins as tersio');
149
+ return true;
150
+ }
151
+ }
152
+ await fs.mkdir(pluginsDir, { recursive: true });
153
+ await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
154
+ try {
155
+ await execNetwork('Installing Tersio plugin dependencies', 'npm', ['install', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 180000 });
156
+ }
157
+ catch {
158
+ try {
159
+ await execNetwork('Installing Tersio plugin dependencies', 'bun', ['install'], { cwd: pluginsDir, timeout: 180000 });
160
+ }
161
+ catch (e) {
162
+ console.log(` [fail] Could not install ${PACKAGE_NAME} into plugins dir: ${e.message}`);
163
+ console.log(` [hint] Manual: cd ~/.omp/plugins && npm install ${PACKAGE_NAME}@^${PACKAGE_VERSION} --save --no-audit --no-fund`);
164
+ return false;
165
+ }
166
+ }
167
+ const installedPkg = path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'package.json');
168
+ if ((await readTextIfExists(installedPkg)) === null) {
169
+ console.log(` [warn] ${PACKAGE_NAME} not found in plugins/node_modules after install`);
170
+ return false;
171
+ }
172
+ console.log(' [ok] Listed in OMP Settings → Plugins as tersio');
173
+ return true;
174
+ }
175
+ function resolveRtkTriple() {
176
+ const triple = rtkPlatformSpec()?.triple;
177
+ if (triple)
178
+ return triple;
179
+ console.log(` [fail] Unsupported platform: ${process.platform}/${process.arch}`);
180
+ console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
181
+ return null;
182
+ }
183
+ function findRtkAsset(release, triple) {
184
+ const assets = release.assets || [];
185
+ const asset = assets.find((a) => a.name === `rtk-${triple}.zip` || a.name === `rtk-${triple}.tar.gz`);
186
+ if (asset)
187
+ return asset;
188
+ console.log(` [fail] No rtk-${triple}.<zip|tar.gz> in release ${release.tag_name}`);
189
+ console.log(` [hint] Available: ${assets.map((a) => a.name).filter((n) => n.startsWith('rtk-')).join(', ')}`);
190
+ return null;
191
+ }
192
+ async function downloadRtkChecksums(release) {
193
+ const checksumsAsset = (release.assets || []).find((a) => a.name === 'checksums.txt');
194
+ if (!checksumsAsset)
195
+ return null;
196
+ try {
197
+ return await httpsGet(checksumsAsset.browser_download_url);
198
+ }
199
+ catch (e) {
200
+ debug(`Could not download checksums.txt: ${e.message}`);
201
+ return null;
202
+ }
203
+ }
204
+ async function verifyRtkArchive(archivePath, assetName, checksumsText) {
205
+ if (!checksumsText) {
206
+ console.log(' [warn] No checksums.txt available — skipping verification');
207
+ return true;
208
+ }
209
+ const expected = parseChecksum(checksumsText, assetName);
210
+ const actual = await sha256File(archivePath);
211
+ if (!expected) {
212
+ console.log(` [warn] checksums.txt missing entry for ${assetName} — skipping verification`);
213
+ return true;
214
+ }
215
+ if (actual === expected) {
216
+ console.log(` [ok] Checksum verified for ${assetName}`);
217
+ return true;
218
+ }
219
+ console.log(` [fail] Checksum mismatch for ${assetName}`);
220
+ console.log(` [fail] Expected: ${expected}`);
221
+ console.log(` [fail] Got: ${actual}`);
222
+ // Caller finally removes the temp dir.
223
+ return false;
224
+ }
225
+ function shortError(e) {
226
+ return ((e.stderr) || e.message || '').trim().slice(0, 200);
227
+ }
228
+ async function extractRtkArchive(archivePath, extractDir) {
229
+ await fs.mkdir(extractDir, { recursive: true });
230
+ if (archivePath.endsWith('.zip')) {
231
+ if (IS_WINDOWS) {
232
+ await execP('powershell', ['Expand-Archive', '-Path', archivePath, '-DestinationPath', extractDir, '-Force'], { timeout: 60000 });
233
+ }
234
+ else {
235
+ await execP('unzip', [archivePath, '-d', extractDir], { timeout: 60000 });
236
+ }
237
+ return true;
238
+ }
239
+ if (archivePath.endsWith('.tar.gz') || archivePath.endsWith('.tgz')) {
240
+ try {
241
+ await execP('tar', ['xzf', archivePath, '-C', extractDir], { timeout: 60000 });
242
+ debug('tar xzf ok');
243
+ }
244
+ catch (e) {
245
+ debug(`tar xzf failed: ${shortError(e)}`);
246
+ console.log(` [fail] tar could not extract ${path.basename(archivePath)}`);
247
+ console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
248
+ return false;
249
+ }
250
+ return true;
251
+ }
252
+ console.log(` [fail] Unknown archive format: ${archivePath}`);
253
+ return false;
254
+ }
255
+ async function stepRtk(binDir, options) {
256
+ console.log('\n[3/7] Installing RTK binary...');
257
+ try {
258
+ const release = await withInteractiveSpinner('Finding latest RTK release', () => fetchJson(RTK_RELEASE_API));
259
+ const triple = resolveRtkTriple();
260
+ if (!triple)
261
+ return;
262
+ const asset = findRtkAsset(release, triple);
263
+ if (!asset)
264
+ return;
265
+ const binDest = path.join(binDir, RTK_BINARY_NAME);
266
+ if (options.dryRun) {
267
+ console.log(` [dry-run] would download ${asset.name} from release ${release.tag_name}`);
268
+ console.log(' [dry-run] would verify checksum against checksums.txt');
269
+ console.log(` [dry-run] would extract and install to ${binDest}`);
270
+ return;
271
+ }
272
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'omp-rtk-'));
273
+ try {
274
+ const archivePath = path.join(tmpDir, asset.name);
275
+ const checksumsText = await withInteractiveSpinner('Downloading RTK binary', async (update) => {
276
+ const [downloadedChecksums] = await Promise.all([
277
+ downloadRtkChecksums(release),
278
+ httpsDownload(asset.browser_download_url, archivePath),
279
+ ]);
280
+ update('Verifying RTK checksum');
281
+ return downloadedChecksums;
282
+ });
283
+ if (!await verifyRtkArchive(archivePath, asset.name, checksumsText))
284
+ return;
285
+ const extractDir = path.join(tmpDir, 'extracted');
286
+ if (!await extractRtkArchive(archivePath, extractDir))
287
+ return;
288
+ const found = await findFile(extractDir, RTK_BINARY_NAME);
289
+ if (!found) {
290
+ console.log(` [fail] Could not find ${RTK_BINARY_NAME} in extracted archive`);
291
+ return;
292
+ }
293
+ await fs.mkdir(path.dirname(binDest), { recursive: true });
294
+ await fs.copyFile(binDest, `${binDest}.bak`).catch(() => { });
295
+ await fs.copyFile(found, binDest);
296
+ console.log(` [write] ${binDest}`);
297
+ if (!IS_WINDOWS) {
298
+ await fs.chmod(binDest, 0o755);
299
+ debug(`chmod 755 ${binDest}`);
300
+ }
301
+ try {
302
+ const v = (await execP(binDest, ['--version'], { timeout: 10000, shell: false })).stdout.trim();
303
+ console.log(` [ok] ${binDest} → ${v}`);
304
+ }
305
+ catch {
306
+ console.log(` [hint] Verify manually: ${binDest} --version`);
307
+ }
308
+ }
309
+ finally {
310
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
311
+ }
312
+ }
313
+ catch (e) {
314
+ console.log(` [fail] RTK: ${e.message}`);
315
+ console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
316
+ }
317
+ }
318
+ // Copy repo source files into the target extension dir. First entry is
319
+ // required (skip label on missing); rest are optional companions.
320
+ async function copySources(extDir, files, skipLabel, options) {
321
+ const src = await readTextIfExists(files[0][0]);
322
+ if (!src) {
323
+ console.log(` [skip] ${skipLabel} not found in repo`);
324
+ return false;
325
+ }
326
+ await writeIfChanged(path.join(extDir, files[0][1]), src, options);
327
+ const rest = files.slice(1);
328
+ const extras = await Promise.all(rest.map(([from]) => readTextIfExists(from)));
329
+ for (let i = 0; i < rest.length; i++) {
330
+ const extra = extras[i];
331
+ if (extra)
332
+ await writeIfChanged(path.join(extDir, rest[i][1]), extra, options);
333
+ }
334
+ return true;
335
+ }
336
+ async function stepSharedSessionState(extDir, options) {
337
+ await copySources(extDir, [
338
+ [SHARED_SESSION_STATE, path.join('shared', 'session-state.js')],
339
+ [SHARED_TYPES, path.join('shared', 'types.js')],
340
+ [LIB_UTILS, path.join('lib', 'utils.js')],
341
+ [SHARED_PLUGIN_SETTINGS, path.join('shared', 'plugin-settings.js')],
342
+ ], 'shared/session-state.js', options);
343
+ }
344
+ async function stepModeReinforcement(extDir, ponytailExtPath, options) {
345
+ console.log('\n[7/7] Installing mode reinforcement extension...');
346
+ const dest = path.join(extDir, 'shared', 'mode-reinforcement.js');
347
+ if (!await copySources(extDir, [[MODE_REINFORCEMENT_INDEX, path.join('shared', 'mode-reinforcement.js')]], 'shared/mode-reinforcement.js', options))
348
+ return;
349
+ await ensureExtensionAfterConfigEntry(path.join(path.dirname(extDir), 'config.yml'), dest, ponytailExtPath, 'mode reinforcement', options);
350
+ }
351
+ async function stepRtkSession(extDir, options) {
352
+ console.log('\n[4/7] Installing RTK session extension...');
353
+ await copySources(extDir, [[RTK_SESSION_INDEX, path.join('rtk-session', 'index.js')]], 'rtk-session/index.js', options);
354
+ }
355
+ // One rule fetch serves user- and project-level installs; dry runs stay offline
356
+ // and fall back to the bundled rule to preview its destination.
357
+ async function fetchCavemanRule(options) {
358
+ if (options.dryRun)
359
+ return (await readTextIfExists(path.join(path.dirname(CAVEMAN_INDEX), 'rule.md'))) || '';
360
+ try {
361
+ return await withInteractiveSpinner('Fetching Caveman rule', () => httpsGet(CAVEMAN_REMOTE_RULE));
362
+ }
363
+ catch (e) {
364
+ console.log(` [warn] Could not fetch caveman rule: ${e.message}`);
365
+ return null;
366
+ }
367
+ }
368
+ async function stepCaveman(extDir, rule, options) {
369
+ console.log('\n[5/7] Installing Caveman session extension...');
370
+ const cavemanDir = path.join(extDir, 'caveman-session');
371
+ if (!options.dryRun)
372
+ await fs.mkdir(cavemanDir, { recursive: true });
373
+ const ruleDest = path.join(cavemanDir, 'rule.md');
374
+ if (rule === null && (await readTextIfExists(ruleDest)) !== null) {
375
+ console.log(' [info] Keeping existing rule.md');
376
+ }
377
+ else if (rule === null) {
378
+ console.log(' [skip] Caveman rule.md unavailable');
379
+ console.log(` [hint] Manual: ${CAVEMAN_REMOTE_RULE}`);
380
+ }
381
+ else {
382
+ await writeIfChanged(ruleDest, rule, options);
383
+ }
384
+ await copySources(extDir, [[CAVEMAN_INDEX, path.join('caveman-session', 'index.js')]], 'caveman-session/index.js', options);
385
+ }
386
+ async function stepUpdater(extDir, options) {
387
+ await copySources(extDir, [[UPDATER_INDEX, path.join('ai-addons-updater', 'index.js')]], 'ai-addons-updater/index.js', options);
388
+ }
389
+ async function stepCombo(extDir, options) {
390
+ console.log('\n[6/7] Installing Combo toggle extension...');
391
+ const dest = path.join(extDir, 'combo-toggle', 'index.js');
392
+ if (!await copySources(extDir, [[COMBO_TOGGLE_INDEX, path.join('combo-toggle', 'index.js')]], 'combo-toggle/index.js', options))
393
+ return;
394
+ // Auto-register combo in config.yml
395
+ const configPath = path.join(path.dirname(extDir), 'config.yml');
396
+ await ensureExtensionInConfig(configPath, dest, 'combo', options);
397
+ }
398
+ function defaultProfile() {
399
+ return {
400
+ comboDefault: 'off',
401
+ cavemanDefault: 'off',
402
+ rtkDefault: false,
403
+ ponytailDefault: 'off',
404
+ };
405
+ }
406
+ // Determine install scope: reinstall > flag > non-interactive default > prompt.
407
+ async function resolveScope() {
408
+ if (reinstall) {
409
+ console.log(' Scope: user (reinstall)');
410
+ return '1';
411
+ }
412
+ if (scopeFlag) {
413
+ const scope = SCOPE_MAP[scopeFlag];
414
+ if (!scope) {
415
+ console.log(` [fail] Invalid --scope: ${scopeFlag}. Use: user, project, both`);
416
+ closeRL();
417
+ process.exit(1);
418
+ }
419
+ console.log(` Scope: ${scopeFlag}`);
420
+ return scope;
421
+ }
422
+ if (install || yes) {
423
+ console.log(` Scope: user (${install ? 'install default' : '--scope omitted, defaulting to user with --yes'})`);
424
+ return '1';
425
+ }
426
+ if (tty()) {
427
+ const choice = await askInteractiveChoice('Install scope', [
428
+ { value: '1', label: 'User-level', hint: 'all OMP sessions' },
429
+ { value: '2', label: 'Project-level', hint: 'this repo only' },
430
+ { value: '3', label: 'Both' },
431
+ ], '1');
432
+ if (choice.status === 'selected')
433
+ return choice.value;
434
+ closeRL();
435
+ process.exit(130);
436
+ }
437
+ console.log('\nInstall scope:');
438
+ console.log(' 1) User-level (all OMP sessions)');
439
+ console.log(' 2) Project-level (this repo only)');
440
+ console.log(' 3) Both');
441
+ while (true) {
442
+ const answer = (await ask('\nChoose [1-3] (default 1): ')).trim() || '1';
443
+ if (answer === '1' || answer === '2' || answer === '3')
444
+ return answer;
445
+ console.log(` [fail] Invalid scope: ${answer}. Choose 1, 2, or 3.`);
446
+ }
447
+ }
448
+ async function resolveProfile() {
449
+ const profile = defaultProfile();
450
+ // Single interactive prompt: the Combo preset implies all three modes.
451
+ // Numbered menu, same pattern as the scope prompt — no typing preset names.
452
+ // Only for a real user at a terminal, only when no default flags were
453
+ // given, and never for --apply-update runs.
454
+ if (tty() && !profileFlagsGiven && !applyUpdate && (install || reinstall)) {
455
+ const choice = await askInteractiveChoice('Session-start defaults — Combo preset', [
456
+ { value: 'off', label: 'off' },
457
+ { value: 'medium', label: 'medium', hint: 'caveman=lite, rtk=on, ponytail=lite' },
458
+ { value: 'balanced', label: 'balanced', hint: 'caveman=full, rtk=on, ponytail=full' },
459
+ { value: 'max', label: 'max', hint: 'caveman=ultra, rtk=on, ponytail=ultra' },
460
+ ], 'off');
461
+ if (choice.status === 'selected')
462
+ profile.comboDefault = choice.value;
463
+ else {
464
+ closeRL();
465
+ process.exit(130);
466
+ }
467
+ }
468
+ // Explicit flags always win over the Combo preset.
469
+ if (comboDefaultFlag !== undefined)
470
+ profile.comboDefault = comboDefaultFlag;
471
+ const preset = COMBO_PRESET_MODES[profile.comboDefault] ?? COMBO_PRESET_MODES.off;
472
+ profile.cavemanDefault = preset.caveman;
473
+ profile.rtkDefault = preset.rtk;
474
+ profile.ponytailDefault = preset.ponytail;
475
+ if (cavemanDefaultFlag !== undefined)
476
+ profile.cavemanDefault = cavemanDefaultFlag;
477
+ if (rtkDefaultFlag !== undefined)
478
+ profile.rtkDefault = rtkDefaultFlag === 'on';
479
+ if (ponytailDefaultFlag !== undefined)
480
+ profile.ponytailDefault = ponytailDefaultFlag;
481
+ console.log(` Profile: combo default=${profile.comboDefault} (caveman=${profile.cavemanDefault} · rtk=${profile.rtkDefault ? 'on' : 'off'} · ponytail=${profile.ponytailDefault})`);
482
+ return profile;
483
+ }
484
+ // ponytail: post-install guard. List entries under `extensions: null` break
485
+ // OMP launch, so fail loudly here instead of at the next OMP start.
486
+ async function validateConfigExtensions(agentDir, options) {
487
+ if (options.dryRun)
488
+ return;
489
+ const raw = await readTextIfExists(path.join(agentDir, 'config.yml'));
490
+ if (!raw)
491
+ return;
492
+ const lines = raw.split('\n');
493
+ const keyIdx = lines.findIndex((l) => EXTENSIONS_KEY_RE.test(l));
494
+ if (keyIdx === -1)
495
+ return;
496
+ const scalarNull = /^\s*extensions\s*:\s*(null|~|\[\s*\])\s*$/i.test(lines[keyIdx]);
497
+ const hasEntries = lines.slice(keyIdx + 1).some((l) => /^\s*-\s+\S/.test(l));
498
+ if (scalarNull && hasEntries) {
499
+ console.log(' [fail] config.yml lists extensions under `extensions: null` — OMP will fail to launch.');
500
+ console.log(' [hint] Replace the `extensions: null` line with `extensions:`, then reinstall.');
501
+ process.exitCode = 1;
502
+ }
503
+ else {
504
+ console.log(' [ok] config.yml extensions key valid');
505
+ }
506
+ }
507
+ // Persist the profile as omp plugin settings so `omp plugin config get`
508
+ // reflects the choice and the extensions pick it up on session start.
509
+ async function writePluginSettings(profile, options) {
510
+ const pluginsDir = OMP_PLUGINS_DIR;
511
+ const lockPath = path.join(pluginsDir, 'omp-plugins.lock.json');
512
+ let config = {};
513
+ const existing = await readTextIfExists(lockPath);
514
+ if (existing) {
515
+ try {
516
+ config = JSON.parse(existing);
517
+ }
518
+ catch {
519
+ config = {};
520
+ }
521
+ }
522
+ config.plugins = config.plugins || {};
523
+ config.settings = config.settings || {};
524
+ config.settings[PACKAGE_NAME] = {
525
+ ...(config.settings[PACKAGE_NAME] || {}),
526
+ comboDefault: profile.comboDefault,
527
+ cavemanDefault: profile.cavemanDefault,
528
+ rtkDefault: profile.rtkDefault,
529
+ ponytailDefault: profile.ponytailDefault,
530
+ };
531
+ if (options.dryRun) {
532
+ console.log(` [dry-run] would write plugin settings (${PACKAGE_NAME}) to ${lockPath}`);
533
+ return;
534
+ }
535
+ await fs.mkdir(pluginsDir, { recursive: true });
536
+ await fs.writeFile(lockPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
537
+ console.log(` [write] Plugin settings in ${lockPath}`);
538
+ }
539
+ async function runInstall() {
540
+ if (reinstall) {
541
+ await runUninstall({ yes: true, removePonytail: false, removeRtk: true });
542
+ }
543
+ if (dryRun)
544
+ console.log('[dry-run] No changes will be written.\n');
545
+ console.log(`=== Tersio v${PACKAGE_VERSION} ===`);
546
+ console.log(` Platform: ${process.platform}`);
547
+ console.log(` Arch: ${process.arch}`);
548
+ console.log(` Home: ${HOME}`);
549
+ // Remind humans a newer release exists; silent for scripts (no TTY) and for
550
+ // the apply-update payload, which is itself an update run.
551
+ if (tty() && !applyUpdate && command !== 'uninstall') {
552
+ const newer = await checkForUpdate();
553
+ if (newer)
554
+ console.log(`\n [update] tersio ${newer} available (installed ${PACKAGE_VERSION}) — run \`tersio update\``);
555
+ }
556
+ const scope = await resolveScope();
557
+ // Resolve session defaults: flags > interactive prompt > defaults.
558
+ const profile = await resolveProfile();
559
+ const userDir = OMP_AGENT_DIR;
560
+ const userExtDir = path.join(userDir, 'extensions');
561
+ const projectExtDir = path.join(process.cwd(), '.omp', 'extensions');
562
+ // apply-update is `tersio update`'s payload run: treat it like reinstall so
563
+ // the add-ons refresh too — Ponytail package via npm, self plugin, RTK
564
+ // binary (always re-downloaded), and the Caveman rule (always re-fetched).
565
+ const options = { dryRun, verbose, yes, scope, reinstall: reinstall || applyUpdate };
566
+ // Check prerequisites
567
+ console.log('\nPrerequisites:');
568
+ try {
569
+ const v = (await execP(OMP_BIN, ['--version'])).stdout.trim();
570
+ console.log(` [ok] omp ${v}`);
571
+ }
572
+ catch {
573
+ console.log(' [fail] omp not found — ensure it\'s installed');
574
+ }
575
+ const cavemanRule = await fetchCavemanRule(options);
576
+ if (scope === '1' || scope === '3') {
577
+ console.log('\n--- User-level install ---');
578
+ await stepSharedSessionState(userExtDir, options);
579
+ await stepPonytail(OMP_PLUGINS_DIR, userDir, options);
580
+ const selfPlugin = await stepSelfPlugin(OMP_PLUGINS_DIR, options);
581
+ const ponytailExtPath = path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
582
+ await stepRtk(BUN_BIN_DIR, options);
583
+ await stepRtkSession(userExtDir, options);
584
+ await stepCaveman(userExtDir, cavemanRule, options);
585
+ await stepCombo(userExtDir, options);
586
+ await stepModeReinforcement(userExtDir, ponytailExtPath, options);
587
+ await stepUpdater(userExtDir, options);
588
+ if (selfPlugin)
589
+ await writePluginSettings(profile, options);
590
+ await validateConfigExtensions(userDir, options);
591
+ }
592
+ if (scope === '2' || scope === '3') {
593
+ console.log('\n--- Project-level install ---');
594
+ await stepSharedSessionState(projectExtDir, options);
595
+ await stepRtkSession(projectExtDir, options);
596
+ await stepCaveman(projectExtDir, cavemanRule, options);
597
+ await stepUpdater(projectExtDir, options);
598
+ console.log(' [note] Ponytail, RTK binary, and Combo toggle require user-level (global) install');
599
+ }
600
+ console.log('\n=== Installation complete ===');
601
+ console.log('\nNext steps:');
602
+ console.log(' 1. Restart OMP');
603
+ console.log(' 2. /caveman full');
604
+ console.log(' 3. /rtk on');
605
+ console.log(' 4. /ponytail full');
606
+ console.log(' 5. /ai-addons check');
607
+ console.log(' 6. /combo medium (toggle all 3 at once — off by default)');
608
+ closeRL();
609
+ }
610
+ export { runInstall };
@@ -0,0 +1,72 @@
1
+ // cli/interactive.ts — TTY layer: readline, Clack spinners, selects, task phases.
2
+ import readline from 'node:readline';
3
+ import { cancel as clackCancel, select as clackSelect, spinner as clackSpinner, tasks as clackTasks } from '@clack/prompts';
4
+ import { execP } from './common.js';
5
+ const RL = readline.createInterface({ input: process.stdin, output: process.stdout });
6
+ let rlOpen = true;
7
+ function ask(q) {
8
+ return new Promise((resolve) => {
9
+ if (!rlOpen) {
10
+ resolve('');
11
+ return;
12
+ }
13
+ try {
14
+ RL.question(q, resolve);
15
+ }
16
+ catch {
17
+ resolve('');
18
+ }
19
+ });
20
+ }
21
+ function closeRL() { if (rlOpen) {
22
+ RL.close();
23
+ rlOpen = false;
24
+ } }
25
+ function tty() {
26
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
27
+ }
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.
31
+ async function withInteractiveSpinner(message, work) {
32
+ if (!tty() || spinnerDepth > 0)
33
+ return work(() => { });
34
+ // Clack manages stdin itself. Close the legacy question interface before its
35
+ // first use; non-interactive callers never reach this branch.
36
+ closeRL();
37
+ const active = clackSpinner({ indicator: 'timer' });
38
+ active.start(message);
39
+ spinnerDepth += 1;
40
+ try {
41
+ return await work((next) => { active.message(next); });
42
+ }
43
+ finally {
44
+ active.clear();
45
+ spinnerDepth -= 1;
46
+ }
47
+ }
48
+ async function askInteractiveChoice(message, options, initialValue) {
49
+ if (!tty())
50
+ return { status: 'unavailable' };
51
+ closeRL();
52
+ const choice = await clackSelect({ message, options, initialValue });
53
+ if (typeof choice !== 'string') {
54
+ clackCancel('Aborted.');
55
+ return { status: 'cancelled' };
56
+ }
57
+ return { status: 'selected', value: choice };
58
+ }
59
+ // Run collecting work under one TTY-only Clack task. Callers print after the
60
+ // task completes, keeping normal output out of the spinner animation.
61
+ async function runInteractivePhase(title, collect) {
62
+ if (!tty())
63
+ return collect();
64
+ closeRL();
65
+ let result;
66
+ await clackTasks([{ title, task: async () => { result = await collect(); } }]);
67
+ return result;
68
+ }
69
+ async function execNetwork(label, cmd, args, opts = {}) {
70
+ return withInteractiveSpinner(label, () => execP(cmd, args, opts));
71
+ }
72
+ export { ask, closeRL, tty, withInteractiveSpinner, execNetwork, askInteractiveChoice, runInteractivePhase, };