@krtclcdy/tersio 2.23.0 → 2.24.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 (55) hide show
  1. package/README.md +58 -45
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/common.js +16 -20
  5. package/cli/currency.js +8 -12
  6. package/cli/dashboard.js +49 -37
  7. package/cli/doctor-fix.js +28 -17
  8. package/cli/doctor.js +52 -41
  9. package/cli/hosts.js +118 -0
  10. package/cli/install.js +180 -117
  11. package/cli/interactive.js +27 -1
  12. package/cli/layer.js +24 -0
  13. package/cli/manifest.js +36 -0
  14. package/cli/omp-layer.js +51 -0
  15. package/cli/pi-layer.js +37 -0
  16. package/cli/pi-wiring.js +92 -0
  17. package/cli/profile.js +44 -46
  18. package/cli/reset.js +3 -26
  19. package/cli/rtk-wiring.js +10 -15
  20. package/cli/uninstall.js +135 -44
  21. package/cli/update.js +12 -19
  22. package/dashboard/dist/index.html +16 -16
  23. package/extensions/ai-addons-updater/index.js +31 -24
  24. package/extensions/ai-addons-updater/index.ts +32 -32
  25. package/extensions/caveman-session/index.js +6 -6
  26. package/extensions/caveman-session/index.ts +6 -6
  27. package/extensions/combo-toggle/index.js +42 -40
  28. package/extensions/combo-toggle/index.ts +36 -47
  29. package/extensions/lib/utils.js +36 -9
  30. package/extensions/lib/utils.ts +37 -9
  31. package/extensions/rtk-session/index.js +9 -9
  32. package/extensions/rtk-session/index.ts +9 -14
  33. package/extensions/shared/carbon.js +5 -8
  34. package/extensions/shared/carbon.ts +5 -8
  35. package/extensions/shared/host.js +68 -0
  36. package/extensions/shared/host.ts +104 -0
  37. package/extensions/shared/omp-prompt.js +12 -0
  38. package/extensions/shared/plugin-settings.js +43 -30
  39. package/extensions/shared/plugin-settings.ts +45 -26
  40. package/extensions/shared/pricing.js +26 -15
  41. package/extensions/shared/pricing.ts +28 -16
  42. package/extensions/shared/rtk-gain.js +11 -22
  43. package/extensions/shared/rtk-gain.ts +11 -22
  44. package/extensions/shared/session-state.js +21 -25
  45. package/extensions/shared/session-state.ts +22 -26
  46. package/extensions/shared/types.ts +10 -3
  47. package/extensions/shared/usage-ledger.js +24 -36
  48. package/extensions/shared/usage-ledger.ts +29 -50
  49. package/extensions/shared/usage-store.js +14 -12
  50. package/extensions/shared/usage-store.ts +16 -14
  51. package/extensions/tersio-commands/index.js +2 -1
  52. package/extensions/tersio-commands/index.ts +2 -1
  53. package/package.json +4 -2
  54. package/tersio.js +4 -4
  55. package/extensions/shared/mode-reinforcement.js +0 -26
@@ -0,0 +1,37 @@
1
+ // cli/pi-layer.ts — the Pi extension layer as data: the five extension directories plus shared/ and lib/.
2
+ import path from 'node:path';
3
+ import { layerDirs, layerEntries, reportLayer } from './layer.js';
4
+ const INSTALLED_EXTENSIONS = [
5
+ ['caveman-session', 'caveman session mode'],
6
+ ['rtk-session', 'rtk session mode'],
7
+ ['ai-addons-updater', 'add-on updater'],
8
+ ['combo-toggle', 'combo preset switch'],
9
+ ['tersio-commands', '/tersio command'],
10
+ ];
11
+ // Imported by the extension directories but never loaded by Pi itself, so they stay out of the install list and only...
12
+ const MODULE_DIRECTORIES = [
13
+ ['shared', 'Pi types, session bridge, usage and pricing store'],
14
+ ['lib', 'shared installer and updater helpers'],
15
+ ];
16
+ export const PI_EXTENSION_DIRS = layerDirs(INSTALLED_EXTENSIONS);
17
+ export const PI_MODULE_DIRS = layerDirs(MODULE_DIRECTORIES);
18
+ export function piLayer(home) {
19
+ const relocated = process.env.PI_CODING_AGENT_DIR;
20
+ const base = relocated && relocated.trim() !== '' ? relocated : path.join(home, '.pi', 'agent');
21
+ const extDir = path.join(base, 'extensions');
22
+ return {
23
+ extDir,
24
+ rtkExtension: path.join(extDir, 'rtk.ts'),
25
+ installed: layerEntries(extDir, INSTALLED_EXTENSIONS),
26
+ modules: layerEntries(extDir, MODULE_DIRECTORIES),
27
+ };
28
+ }
29
+ // Every directory the layer owns. Removal walks this list and nothing else.
30
+ export function piExtensionTargets(layer) {
31
+ return [...layer.installed, ...layer.modules].map((entry) => entry.path);
32
+ }
33
+ // Reports the layer without needing a platform rtk binary name.
34
+ export function reportPiLayer(home, exists) {
35
+ const { present, missing } = reportLayer(piLayer(home).extDir, [INSTALLED_EXTENSIONS, MODULE_DIRECTORIES], exists);
36
+ return { extensions: present, missing, installed: present.length > 0 };
37
+ }
@@ -0,0 +1,92 @@
1
+ // Install and remove Tersio's Pi extension tree: Pi loads ~/.pi/agent/extensions/<dir>/index.ts through jiti, no build...
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { readTextIfExists } from '../extensions/lib/utils.js';
5
+ import { rewritePortImports } from './layer.js';
6
+ import { piExtensionTargets, piLayer } from './pi-layer.js';
7
+ // `$HOME`-relative form of a path, for output that must stay readable.
8
+ function rel(target, home) {
9
+ if (!home)
10
+ return target;
11
+ const prefix = home.endsWith(path.sep) ? home : `${home}${path.sep}`;
12
+ return target.startsWith(prefix) ? `~/${target.slice(prefix.length).split(path.sep).join('/')}` : target;
13
+ }
14
+ export function piExtensionDir(home) {
15
+ return piLayer(home).extDir;
16
+ }
17
+ // Writes only when the content differs, leaving a `.bak` of what it replaced.
18
+ async function writeIfChanged(target, content, options) {
19
+ const current = await readTextIfExists(target);
20
+ if (current === content)
21
+ return false;
22
+ if (options.dryRun)
23
+ return true;
24
+ await fs.mkdir(path.dirname(target), { recursive: true });
25
+ if (current !== null)
26
+ await fs.copyFile(target, `${target}.bak`).catch(() => { });
27
+ await fs.writeFile(target, content, 'utf8');
28
+ return true;
29
+ }
30
+ async function exists(target) {
31
+ try {
32
+ await fs.stat(target);
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ // Returns the paths it changed, so a no-op re-run is not counted as work.
40
+ export async function installPiTersio(home, tree, options = {}) {
41
+ const extDir = piExtensionDir(home);
42
+ const written = [];
43
+ for (const [from, to] of tree.sources) {
44
+ const content = await readTextIfExists(from);
45
+ if (content === null) {
46
+ // Named by its path under the extension dir, never by the repo source.
47
+ if (!options.quiet) {
48
+ console.log(` [fail] Pi extension source missing: ${to}`);
49
+ console.log(' [hint] Reinstall tersio — extensions/pi ships with the CLI');
50
+ }
51
+ continue;
52
+ }
53
+ if (await writeIfChanged(path.join(extDir, to), rewritePortImports(content), options))
54
+ written.push(to);
55
+ }
56
+ for (const [dir, rule] of tree.rules) {
57
+ if (rule === null)
58
+ continue;
59
+ const target = path.join(dir, 'rule.md');
60
+ if (await writeIfChanged(path.join(extDir, target), rule, options))
61
+ written.push(target);
62
+ }
63
+ if (written.length > 0 && !options.quiet) {
64
+ console.log(` ${options.dryRun ? '[dry-run] would write' : '[write]'} Pi — ${written.length} file(s) under ${rel(extDir, home)}`);
65
+ }
66
+ return written;
67
+ }
68
+ // Removes the Pi tree: every directory the layer owns, the `.bak` copies, and the flat module a pre-tree install wrote at...
69
+ export async function removePiTersio(home, options = {}) {
70
+ let removed = false;
71
+ for (const target of [...piExtensionTargets(piLayer(home)), path.join(piExtensionDir(home), 'tersio.ts')]) {
72
+ if (!(await exists(target)))
73
+ continue;
74
+ if (!options.quiet) {
75
+ console.log(` ${options.dryRun ? '[dry-run] would remove' : '[rm]'} ${rel(target, home)}`);
76
+ }
77
+ if (!options.dryRun) {
78
+ await fs.rm(target, { recursive: true, force: true });
79
+ await fs.rm(`${target}.bak`, { force: true, recursive: true });
80
+ }
81
+ removed = true;
82
+ }
83
+ return removed;
84
+ }
85
+ // True when any module of the Pi tree is present.
86
+ export async function piTersioInstalled(home) {
87
+ for (const entry of piLayer(home).installed) {
88
+ if (await exists(path.join(entry.path, 'index.ts')))
89
+ return true;
90
+ }
91
+ return false;
92
+ }
package/cli/profile.js CHANGED
@@ -1,12 +1,14 @@
1
1
  // cli/profile.ts — session-start defaults profile (combo/caveman/rtk/ponytail)
2
2
  // plus the display-currency default for usage/dashboard reports.
3
- // Single source for reading/writing the omp plugin settings lock entry.
4
- // Extracted from cli/install.ts so both install and settings share it.
5
- import { promises as fs } from 'node:fs';
3
+ // Stored in ~/.tersio/settings.json, which every host reads. Extracted from
4
+ // cli/install.ts so both install and settings share it.
5
+ import { existsSync, promises as fs } from 'node:fs';
6
6
  import path from 'node:path';
7
- import { CAVEMAN_DEFAULTS, COMBO_PRESET_MODES, OMP_PLUGINS_DIR, PACKAGE_NAME, PONYTAIL_DEFAULTS, verbose, } from './common.js';
7
+ import { CAVEMAN_DEFAULTS, COMBO_PRESET_MODES, PACKAGE_NAME, PONYTAIL_DEFAULTS, verbose, } from './common.js';
8
+ import os from 'node:os';
8
9
  import { DEFAULT_CURRENCY, isCurrencyCode } from './currency.js';
9
10
  import { readTextIfExists } from '../extensions/lib/utils.js';
11
+ import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
10
12
  function defaultProfile() {
11
13
  return {
12
14
  comboDefault: 'off',
@@ -16,29 +18,16 @@ function defaultProfile() {
16
18
  currency: DEFAULT_CURRENCY,
17
19
  };
18
20
  }
19
- // Stored profile from the live lock file: update/reinstall runs without flags
20
- // or prompts must preserve the user's choice, never reset it to off.
21
+ // Stored profile: ~/.tersio/settings.json. An install that predates that file
22
+ // kept the same values as OMP plugin settings, so seed from there once and
23
+ // never lose a user's choice.
21
24
  async function storedProfile() {
22
25
  const base = defaultProfile();
23
- const raw = await readTextIfExists(path.join(OMP_PLUGINS_DIR, 'omp-plugins.lock.json'));
24
- if (!raw)
25
- return base;
26
- let stored;
27
- try {
28
- const parsed = JSON.parse(raw);
29
- if (!parsed || typeof parsed !== 'object' || !('settings' in parsed))
30
- return base;
31
- const settings = parsed.settings;
32
- if (!settings || typeof settings !== 'object' || !(PACKAGE_NAME in settings))
33
- return base;
34
- const entry = settings[PACKAGE_NAME];
35
- if (!entry || typeof entry !== 'object')
36
- return base;
37
- stored = entry;
38
- }
39
- catch {
26
+ const stored = (await readTextIfExists(tersioSettingsFile())) !== null
27
+ ? parseStored(await readTextIfExists(tersioSettingsFile()))
28
+ : parseStored(await readTextIfExists(legacyOmpLockPath()));
29
+ if (!stored)
40
30
  return base;
41
- }
42
31
  if (typeof stored.comboDefault === 'string' && stored.comboDefault in COMBO_PRESET_MODES)
43
32
  base.comboDefault = stored.comboDefault;
44
33
  if (typeof stored.cavemanDefault === 'string' && CAVEMAN_DEFAULTS.has(stored.cavemanDefault))
@@ -52,25 +41,34 @@ async function storedProfile() {
52
41
  }
53
42
  return base;
54
43
  }
55
- // Persist the profile as omp plugin settings so `omp plugin config get`
56
- // reflects the choice and the extensions pick it up on session start.
57
- async function writePluginSettings(profile, options) {
58
- const pluginsDir = OMP_PLUGINS_DIR;
59
- const lockPath = path.join(pluginsDir, 'omp-plugins.lock.json');
60
- let config = {};
61
- const existing = await readTextIfExists(lockPath);
62
- if (existing) {
63
- try {
64
- config = JSON.parse(existing);
65
- }
66
- catch {
67
- config = {};
68
- }
44
+ function legacyOmpLockPath() {
45
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
46
+ return [
47
+ path.join(os.homedir(), '.omp', 'plugins', 'omp-plugins.lock.json'),
48
+ path.join(configHome, 'omp', 'plugins', 'omp-plugins.lock.json'),
49
+ ].find((p) => existsSync(p)) ?? path.join(os.homedir(), '.omp', 'plugins', 'omp-plugins.lock.json');
50
+ }
51
+ function parseStored(raw) {
52
+ if (!raw)
53
+ return null;
54
+ try {
55
+ const parsed = JSON.parse(raw);
56
+ if (!parsed || typeof parsed !== 'object')
57
+ return null;
58
+ const direct = parsed;
59
+ const nested = direct.settings?.[PACKAGE_NAME];
60
+ return (nested ?? direct);
69
61
  }
70
- config.plugins = config.plugins || {};
71
- config.settings = config.settings || {};
72
- config.settings[PACKAGE_NAME] = {
73
- ...(config.settings[PACKAGE_NAME] || {}),
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ // Persist the profile where every host reads it. `omp plugin config get` no
67
+ // longer reports it; `tersio settings` does.
68
+ async function writePluginSettings(profile, options) {
69
+ const file = tersioSettingsFile();
70
+ const values = {
71
+ ...(parseStored(await readTextIfExists(file)) ?? {}),
74
72
  comboDefault: profile.comboDefault,
75
73
  comboSetupComplete: true,
76
74
  cavemanDefault: profile.cavemanDefault,
@@ -80,12 +78,12 @@ async function writePluginSettings(profile, options) {
80
78
  };
81
79
  if (options.dryRun) {
82
80
  if (verbose && !options.quiet)
83
- console.log(` [dry-run] would write plugin settings (${PACKAGE_NAME}) to ${lockPath}`);
81
+ console.log(` [dry-run] would write session defaults to ${file}`);
84
82
  return;
85
83
  }
86
- await fs.mkdir(pluginsDir, { recursive: true });
87
- await fs.writeFile(lockPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
88
- console.log(` [write] Plugin settings in ${lockPath}`);
84
+ await fs.mkdir(path.dirname(file), { recursive: true });
85
+ await fs.writeFile(file, `${JSON.stringify(values, null, 2)}\n`, 'utf8');
86
+ console.log(` [write] Session defaults in ${file}`);
89
87
  }
90
88
  function formatProfile(profile) {
91
89
  return `combo=${profile.comboDefault} (caveman=${profile.cavemanDefault} · rtk=${profile.rtkDefault ? 'on' : 'off'} · ponytail=${profile.ponytailDefault}) · currency=${profile.currency}`;
package/cli/reset.js CHANGED
@@ -3,9 +3,8 @@
3
3
  // derived view. Session transcripts (~/.omp/agent/sessions) and the RTK
4
4
  // database are host/tool-owned and are never touched; the watermark only
5
5
  // changes what tersio shows.
6
- import { cancel as clackCancel, confirm as clackConfirm } from '@clack/prompts';
7
6
  import { dryRun, yes } from './common.js';
8
- import { ask, closeRL, tty } from './interactive.js';
7
+ import { confirmDestructive } from './interactive.js';
9
8
  import { clearUsageLedger, importSessionTokens, ledgerPath, markReset, readUsage, sessionsDir } from '../extensions/shared/usage-ledger.js';
10
9
  import { clearUsageDb, usageDbPath } from '../extensions/shared/usage-store.js';
11
10
  import { readRtkGain, rtkDbPath } from '../extensions/shared/rtk-gain.js';
@@ -25,30 +24,8 @@ async function runReset() {
25
24
  console.log('Nothing to reset — no statistics recorded.');
26
25
  return true;
27
26
  }
28
- if (!yes) {
29
- if (tty()) {
30
- closeRL();
31
- const confirmedChoice = await clackConfirm({ message: 'Clear tersio statistics?', initialValue: false });
32
- if (typeof confirmedChoice !== 'boolean') {
33
- clackCancel('Aborted.');
34
- closeRL();
35
- return false;
36
- }
37
- if (!confirmedChoice) {
38
- console.log('Aborted.');
39
- closeRL();
40
- return false;
41
- }
42
- }
43
- else {
44
- const answer = await ask('\nProceed? [y/N]: ');
45
- if (!answer.toLowerCase().startsWith('y')) {
46
- console.log('Aborted.');
47
- closeRL();
48
- return false;
49
- }
50
- }
51
- }
27
+ if (!yes && !(await confirmDestructive('Clear tersio statistics?')))
28
+ return false;
52
29
  const cleared = clearUsageLedger();
53
30
  const dbCleared = clearUsageDb();
54
31
  const ts = markReset();
package/cli/rtk-wiring.js CHANGED
@@ -1,12 +1,9 @@
1
1
  // cli/rtk-wiring.ts — wire the installed rtk binary into OMP.
2
2
  // `rtk init -g --agent omp` writes rtk's tool_call extension to
3
- // ~/.omp/agent/extensions/rtk.ts, and we register that path in config.yml
4
- // (OMP only loads listed extensions — rtk init does not register it).
5
- // Once loaded, OMP rewrites bash commands to rtk before execution, so every
6
- // rewritten call lands in rtk's history.db and shows metered savings.
7
- // Fail-open: a failed wire only downgrades to manual `rtk` prefixing.
8
- // Deliberately free of cli/common.ts imports (argv side effects) so tests
9
- // can load it directly.
3
+ // ~/.omp/agent/extensions/rtk.ts but does not register it, and OMP loads only
4
+ // listed extensions, so we add the path to config.yml. Every rewritten bash
5
+ // call then lands in rtk's history.db. Fail-open: a failed wire only downgrades
6
+ // to manual `rtk` prefixing. No cli/common.ts import (argv side effects).
10
7
  import { promises as fs } from 'node:fs';
11
8
  import os from 'node:os';
12
9
  import path from 'node:path';
@@ -34,8 +31,7 @@ function execFileP(cmd, args, timeout) {
34
31
  });
35
32
  });
36
33
  }
37
- // Idempotent: rtk rewrites its extension file on every init run, so
38
- // reinstalling tersio refreshes the wiring for free.
34
+ // Idempotent: rtk rewrites its file on every init run.
39
35
  export async function wireRtkOmp(rtkBin, options = {}) {
40
36
  if (!options.dryRun)
41
37
  debugWire(options, 'Wiring rtk → OMP (bash tool_call rewrite)…');
@@ -47,14 +43,13 @@ export async function wireRtkOmp(rtkBin, options = {}) {
47
43
  await ensureRtkInConfig(options);
48
44
  return true;
49
45
  }
50
- // The rtk.ts path rtk init writes (mirrors OMP_AGENT_DIR in cli/common.ts
51
- // without importing it — see header note on argv side effects).
46
+ // The rtk.ts path rtk init writes (OMP_AGENT_DIR inlined; see header note).
52
47
  function rtkExtensionPath() {
53
48
  const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
54
49
  return path.join(home, '.omp', 'agent', 'extensions', 'rtk.ts');
55
50
  }
56
- // OMP loads only listed extensions: append rtk.ts after the existing entries
57
- // (or create the key) so a fresh wire takes effect on next OMP start.
51
+ // Append rtk.ts after the existing entries (or create the key) so the wire
52
+ // takes effect on the next OMP start.
58
53
  export async function ensureRtkInConfig(options) {
59
54
  const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
60
55
  const configPath = path.join(home, '.omp', 'agent', 'config.yml');
@@ -98,8 +93,8 @@ async function runRtkInit(rtkBin, options) {
98
93
  return true;
99
94
  }
100
95
  catch (first) {
101
- // A freshly written binary can lose its first exec to macOS Gatekeeper
102
- // verification; give it one settle-and-retry before reporting failure.
96
+ // A fresh binary can lose its first exec to macOS Gatekeeper, so settle and
97
+ // retry once before reporting failure.
103
98
  await new Promise((resolve) => setTimeout(resolve, 2000));
104
99
  try {
105
100
  await execFileP(rtkBin, ['init', '-g', '--agent', 'omp'], 30000);
package/cli/uninstall.js CHANGED
@@ -1,12 +1,81 @@
1
1
  // cli/uninstall.ts — remove managed extensions, plugins, and binaries.
2
2
  import { promises as fs } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { cancel as clackCancel, confirm as clackConfirm } from '@clack/prompts';
5
- import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, RTK_BINARY_NAME, dryRun, keepPonytail, removePonytail, removeRtk, yes, debug, writeConfigLines, } from './common.js';
6
- import { ask, closeRL, tty } from './interactive.js';
7
- import { readTextIfExists } from '../extensions/lib/utils.js';
8
- // Read-modify-write a JSON file. mutate returns true when it changed
9
- // something; no change (or unreadable file) means no output at all.
4
+ import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, RTK_BINARY_NAME, args, dryRun, keepPonytail, removePonytail, removeRtk, yes, debug, writeConfigLines, } from './common.js';
5
+ import { askInteractiveChoice, closeRL, confirmDestructive, tty } from './interactive.js';
6
+ import { detectHosts, hostHint, hostLabel, parseHostArg } from './hosts.js';
7
+ import { findHoistedPackage, piAgentDir, readTextIfExists } from '../extensions/lib/utils.js';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
10
+ // The pi tree mirrors the OMP one. Ponytail and rtk stay: a package and a
11
+ // shared binary are not ours to delete.
12
+ const PONYTAIL_PKG = '@dietrichgebert/ponytail';
13
+ const PI_TREE_DIRS = [
14
+ 'caveman-session',
15
+ 'rtk-session',
16
+ 'ai-addons-updater',
17
+ 'combo-toggle',
18
+ 'tersio-commands',
19
+ 'shared',
20
+ 'lib',
21
+ ];
22
+ // One `pi remove`, for both packages the installer adds there.
23
+ async function piRemove(spec, shouldDryRun) {
24
+ if (shouldDryRun) {
25
+ console.log(` [dry-run] would run: pi remove ${spec}`);
26
+ return;
27
+ }
28
+ try {
29
+ const { execFile } = await import('node:child_process');
30
+ await new Promise((resolve, reject) => {
31
+ execFile('pi', ['remove', spec], { timeout: 120000 }, (err, _out, errOut) => {
32
+ if (err)
33
+ reject(new Error(String(errOut).trim() || err.message));
34
+ else
35
+ resolve();
36
+ });
37
+ });
38
+ console.log(` [ok] removed the pi package ${spec}`);
39
+ }
40
+ catch (e) {
41
+ console.log(` [fail] pi remove: ${e.message}`);
42
+ console.log(` [hint] Manual: pi remove ${spec}`);
43
+ }
44
+ }
45
+ // Only the skill directories the installer copied, never pi's own skills dir.
46
+ async function removeCopiedPonytailSkills(shouldDryRun) {
47
+ const source = findHoistedPackage(PONYTAIL_PKG, path.dirname(fileURLToPath(import.meta.url)), 'skills');
48
+ if (!source)
49
+ return;
50
+ for (const entry of await fs.readdir(source, { withFileTypes: true })) {
51
+ if (entry.isDirectory())
52
+ await removeUninstallTarget(path.join(piAgentDir(), 'skills', entry.name), shouldDryRun);
53
+ }
54
+ }
55
+ // pi owns its package dir, so removal is `pi remove`; the tree and the defaults
56
+ // file are ours. Ponytail goes too unless --keep-ponytail, matching the OMP
57
+ // route, so neither host keeps a package we installed on its behalf.
58
+ async function removePiLayer(host, shouldDryRun, shouldRemovePonytail) {
59
+ const targets = PI_TREE_DIRS.map((dir) => path.join(piAgentDir(), 'extensions', dir));
60
+ if (!host.installed && !host.declared) {
61
+ console.log(` [skip] ${host.label} — nothing installed (${host.installCmd})`);
62
+ return false;
63
+ }
64
+ if (host.declared)
65
+ await piRemove(`npm:${PACKAGE_NAME}`, shouldDryRun);
66
+ if (shouldRemovePonytail) {
67
+ await piRemove(`npm:${PONYTAIL_PKG}`, shouldDryRun);
68
+ await removeCopiedPonytailSkills(shouldDryRun);
69
+ }
70
+ await Promise.all(targets.map((t) => removeUninstallTarget(t, shouldDryRun)));
71
+ await clearSessionDefaults(shouldDryRun);
72
+ return true;
73
+ }
74
+ // ~/.tersio/settings.json holds nothing but Tersio's values, so it goes whole.
75
+ async function clearSessionDefaults(shouldDryRun) {
76
+ await removeUninstallTarget(tersioSettingsFile(), shouldDryRun, false);
77
+ }
78
+ // Read-modify-write a JSON file; mutate returning false prints nothing.
10
79
  async function updateJsonFile(filePath, mutate, dryRunNote, writeNote, dryRun) {
11
80
  const raw = await readTextIfExists(filePath);
12
81
  if (!raw)
@@ -56,11 +125,52 @@ async function removeUninstallTarget(target, shouldDryRun, recursive = true) {
56
125
  async function runUninstall(options = {}) {
57
126
  const shouldDryRun = options.dryRun ?? dryRun;
58
127
  const confirmed = (options.yes ?? yes) || shouldDryRun;
59
- // Ponytail is bundled with Tersio's presets, so a full uninstall removes
60
- // its copy too; --keep-ponytail opts out and reinstall always preserves it.
128
+ // Ponytail ships with Tersio, so a full uninstall removes it.
61
129
  const shouldRemovePonytail = options.removePonytail ?? (removePonytail || !keepPonytail);
62
130
  const shouldRemoveRtk = options.removeRtk ?? removeRtk;
63
131
  console.log('\n=== Tersio Uninstall ===\n');
132
+ // A TTY user picks the host; only hosts with tersio on them are offered.
133
+ const installed = detectHosts().filter((host) => host.installed);
134
+ let host = options.host ?? 'omp';
135
+ try {
136
+ host = parseHostArg(args) ?? host;
137
+ }
138
+ catch (e) {
139
+ console.error(`[fail] ${e.message}`);
140
+ process.exit(1);
141
+ }
142
+ if (!options.host && tty() && !confirmed) {
143
+ if (installed.length === 0) {
144
+ console.log('Tersio is not installed for any agent on this machine. Nothing to remove.');
145
+ closeRL();
146
+ return false;
147
+ }
148
+ if (installed.length > 1) {
149
+ const choice = await askInteractiveChoice('Uninstall Tersio from which agent?', installed.map((h) => ({
150
+ value: h.id,
151
+ label: hostLabel(h),
152
+ hint: hostHint(h),
153
+ })), host);
154
+ if (choice.status !== 'selected') {
155
+ closeRL();
156
+ return false;
157
+ }
158
+ host = choice.value;
159
+ }
160
+ else {
161
+ host = installed[0].id;
162
+ }
163
+ }
164
+ const selected = detectHosts().find((h) => h.id === host);
165
+ if (host === 'pi') {
166
+ const removed = await removePiLayer(selected, shouldDryRun, shouldRemovePonytail);
167
+ if (removed)
168
+ console.log('\nDone. Restart pi for changes to take effect.');
169
+ closeRL();
170
+ return removed;
171
+ }
172
+ if (!selected.installed)
173
+ console.log(` [note] ${selected.label} has no tersio install; removing what is left behind.`);
64
174
  const extDir = path.join(OMP_AGENT_DIR, 'extensions');
65
175
  const configPath = path.join(OMP_AGENT_DIR, 'config.yml');
66
176
  const rtkBin = path.join(BUN_BIN_DIR, RTK_BINARY_NAME);
@@ -73,10 +183,9 @@ async function runUninstall(options = {}) {
73
183
  'combo-toggle',
74
184
  'tersio-commands',
75
185
  'shared',
76
- // Only consumed by ai-addons-updater (removed above); otherwise orphaned.
186
+ // Only the updater reads it, and it runs before this.
77
187
  'lib',
78
- // Legacy always-on combo helper; imports shared/session-state.js, so it
79
- // breaks with a module-not-found warning once the shared dir is removed.
188
+ // Legacy: imports session-state.js, so it warns once that module is gone.
80
189
  'aaa-combo-boot',
81
190
  ].map((dir) => path.join(extDir, dir));
82
191
  console.log('Will remove:');
@@ -90,33 +199,11 @@ async function runUninstall(options = {}) {
90
199
  console.log(` ${rtkBin}`);
91
200
  console.log(` ${path.join(extDir, 'rtk.ts')} (rtk OMP wiring)`);
92
201
  }
93
- if (!confirmed) {
94
- if (tty()) {
95
- closeRL();
96
- const confirmedChoice = await clackConfirm({ message: 'Remove the listed Tersio files?', initialValue: false });
97
- if (typeof confirmedChoice !== 'boolean') {
98
- clackCancel('Aborted.');
99
- closeRL();
100
- return false;
101
- }
102
- if (!confirmedChoice) {
103
- console.log('Aborted.');
104
- closeRL();
105
- return false;
106
- }
107
- }
108
- else {
109
- const answer = await ask('\nProceed? [y/N]: ');
110
- if (!answer.toLowerCase().startsWith('y')) {
111
- console.log('Aborted.');
112
- closeRL();
113
- return false;
114
- }
115
- }
116
- }
202
+ if (!confirmed && !(await confirmDestructive('Remove the listed Tersio files?')))
203
+ return false;
117
204
  // Remove extension directories (independent paths, so concurrently).
118
205
  await Promise.all(targets.map((t) => removeUninstallTarget(t, shouldDryRun)));
119
- // Remove Combo and mode-reinforcement registrations; Ponytail only when requested.
206
+ // Remove Combo, mode-reinforcement, and rtk registrations; Ponytail on request.
120
207
  const configRaw = await readTextIfExists(configPath);
121
208
  if (configRaw) {
122
209
  let lines = configRaw.split('\n');
@@ -126,6 +213,11 @@ async function runUninstall(options = {}) {
126
213
  return false;
127
214
  if (shouldRemovePonytail && l.includes('ponytail') && l.includes('pi-extension'))
128
215
  return false;
216
+ // Our rtk wiring, absolute or relative. Leaving it listed would keep
217
+ // OMP loading a hook for a package that is no longer installed.
218
+ const entry = l.trim().replace(/^-\s*/, '').replace(/^['"]|['"]$/g, '');
219
+ if (entry.endsWith(`extensions${path.sep}rtk.ts`) || entry.endsWith('extensions/rtk.ts'))
220
+ return false;
129
221
  return true;
130
222
  });
131
223
  if (lines.length !== before) {
@@ -135,8 +227,7 @@ async function runUninstall(options = {}) {
135
227
  await writeConfigLines(configPath, lines, ` [write] Updated config.yml (removed ${before - lines.length} entries)`);
136
228
  }
137
229
  }
138
- // Remove the bundled Ponytail copy (dep entry exists only on
139
- // pre-bundle installs); the config.yml entry was filtered above.
230
+ // Remove the bundled Ponytail copy; its config.yml entry is already filtered.
140
231
  if (shouldRemovePonytail) {
141
232
  const pluginsPkgPath = path.join(pluginsDir, 'package.json');
142
233
  await updateJsonFile(pluginsPkgPath, (data) => dropKey(data.dependencies, '@dietrichgebert/ponytail'), `would remove @dietrichgebert/ponytail from ${pluginsPkgPath}`, 'Removed @dietrichgebert/ponytail from plugins/package.json', shouldDryRun);
@@ -163,13 +254,13 @@ async function runUninstall(options = {}) {
163
254
  if ((await readTextIfExists(path.join(selfPluginDir, 'package.json'))) !== null) {
164
255
  await removeUninstallTarget(selfPluginDir, shouldDryRun);
165
256
  }
166
- // Remove RTK binary if requested. rtk.ts is rtk-owned but installed by our
167
- // wiring step; with the binary gone it would pass through harmlessly, so
168
- // only drop it on a full rtk removal.
169
- if (shouldRemoveRtk) {
257
+ // Our rtk wiring goes with the rest: leaving the file would keep OMP
258
+ // rewriting bash calls to a package this uninstall just removed. The binary
259
+ // stays unless --remove-rtk, since it is a shared tool on PATH.
260
+ await removeUninstallTarget(path.join(extDir, 'rtk.ts'), shouldDryRun);
261
+ if (shouldRemoveRtk)
170
262
  await removeUninstallTarget(rtkBin, shouldDryRun, false);
171
- await removeUninstallTarget(path.join(extDir, 'rtk.ts'), shouldDryRun);
172
- }
263
+ await clearSessionDefaults(shouldDryRun);
173
264
  console.log('\nDone. Restart OMP for changes to take effect.');
174
265
  return true;
175
266
  }