@oddessentials/agent-guild 0.14.0 → 0.15.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/README.md CHANGED
@@ -34,6 +34,10 @@ agent-guild
34
34
  The page opens in your browser. Pick a provider, press **New**, and you are
35
35
  in a live terminal.
36
36
 
37
+ Want to look around first? The [interactive web demo](https://oddessentials.github.io/agent-guild/)
38
+ uses simulated sessions and usage data in your browser; it never runs commands
39
+ or connects to a local session manager.
40
+
37
41
  **You need** Node.js 22 or newer on Windows 10 1809+, macOS 11+ or Linux
38
42
  (x64 or arm64), and each coding tool you want to use, signed in on its own.
39
43
  Agent Guild installs the tools for you but never handles your sign-in. No
@@ -21,10 +21,10 @@
21
21
  "install": "curl -fsSL https://claude.ai/install.sh | bash",
22
22
  "npmNote": "npm installs the same native build as Anthropic's installer.",
23
23
  "channels": {
24
- "native": { "paths": ["~/.local/bin/claude", "~/.local/share/claude"], "update": ["update"], "uninstall": "rm -f ~/.local/bin/claude && rm -rf ~/.local/share/claude" },
24
+ "native": { "paths": ["~/.local/bin/claude", "~/.local/share/claude"], "update": ["update"], "remove": ["~/.local/bin/claude", "~/.local/share/claude"] },
25
25
  "brew": { "names": ["claude-code", "claude-code@latest"] },
26
26
  "winget": { "id": "Anthropic.ClaudeCode" },
27
- "legacy": { "paths": ["~/.claude/local"], "guidance": "This copy comes from an older Claude Code installer. Remove ~/.claude/local and reinstall with the current installer.", "uninstall": "rm -rf ~/.claude/local" }
27
+ "legacy": { "paths": ["~/.claude/local"], "guidance": "This copy comes from an older Claude Code installer. Remove ~/.claude/local and reinstall with the current installer.", "remove": ["~/.claude/local"] }
28
28
  },
29
29
  "docs": "https://code.claude.com/docs/en/setup",
30
30
  "usageUrl": "https://claude.ai/settings/usage",
@@ -33,9 +33,9 @@
33
33
  "win32": {
34
34
  "install": "irm https://claude.ai/install.ps1 | iex",
35
35
  "channels": {
36
- "native": { "paths": ["~/.local/bin/claude", "~/.local/share/claude"], "update": ["update"], "uninstall": "Remove-Item -Path \"$env:USERPROFILE\\.local\\bin\\claude.exe\" -Force; Remove-Item -Path \"$env:USERPROFILE\\.local\\share\\claude\" -Recurse -Force" },
36
+ "native": { "paths": ["~/.local/bin/claude", "~/.local/share/claude"], "update": ["update"], "remove": ["~/.local/bin/claude.exe", "~/.local/share/claude"] },
37
37
  "winget": { "id": "Anthropic.ClaudeCode" },
38
- "legacy": { "paths": ["~/.claude/local"], "guidance": "This copy comes from an older Claude Code installer. Remove ~/.claude/local and reinstall with the current installer.", "uninstall": "Remove-Item -Recurse -Force \"$env:USERPROFILE\\.claude\\local\"" }
38
+ "legacy": { "paths": ["~/.claude/local"], "guidance": "This copy comes from an older Claude Code installer. Remove ~/.claude/local and reinstall with the current installer.", "remove": ["~/.claude/local"] }
39
39
  }
40
40
  }
41
41
  },
@@ -59,7 +59,7 @@
59
59
  "install": "curl -fsSL https://chatgpt.com/codex/install.sh | sh",
60
60
  "npmNote": "npm installs the native Codex binary.",
61
61
  "channels": {
62
- "native": { "paths": ["~/.local/bin/codex", "~/.codex/packages/standalone"], "update": ["update"] },
62
+ "native": { "paths": ["~/.local/bin/codex", "~/.codex/packages/standalone"], "update": ["update"], "remove": ["~/.local/bin/codex", "~/.local/bin/codex-code-mode-host", "~/.codex/packages/standalone"] },
63
63
  "brew": { "names": ["codex"] },
64
64
  "winget": { "id": "OpenAI.Codex" }
65
65
  },
@@ -70,7 +70,7 @@
70
70
  "win32": {
71
71
  "install": "powershell -ExecutionPolicy ByPass -c \"irm https://chatgpt.com/codex/install.ps1 | iex\"",
72
72
  "channels": {
73
- "native": { "paths": ["~/AppData/Local/Programs/OpenAI/Codex/bin", "~/.codex/packages/standalone"], "update": ["update"] },
73
+ "native": { "paths": ["~/AppData/Local/Programs/OpenAI/Codex/bin", "~/.codex/packages/standalone"], "update": ["update"], "remove": ["~/AppData/Local/Programs/OpenAI/Codex/bin", "~/.codex/packages/standalone"] },
74
74
  "winget": { "id": "OpenAI.Codex" }
75
75
  }
76
76
  }
@@ -90,7 +90,8 @@
90
90
  "monogram": "G",
91
91
  "install": "curl -fsSL https://antigravity.google/cli/install.sh | bash",
92
92
  "channels": {
93
- "native": { "paths": ["~/.local/bin/agy"], "update": ["update"], "uninstall": "rm -f ~/.local/bin/agy" }
93
+ "native": { "paths": ["~/.local/bin/agy"], "update": ["update"], "remove": ["~/.local/bin/agy"] },
94
+ "brew": { "names": ["antigravity-cli"], "autoUpdates": true }
94
95
  },
95
96
  "docs": "https://antigravity.google/docs/cli/install",
96
97
  "billingUrl": "https://one.google.com/settings",
@@ -98,7 +99,7 @@
98
99
  "win32": {
99
100
  "install": "irm https://antigravity.google/cli/install.ps1 | iex",
100
101
  "channels": {
101
- "native": { "paths": ["~/AppData/Local/agy/bin"], "update": ["update"], "uninstall": "Remove-Item -Path \"$env:LOCALAPPDATA\\agy\" -Recurse -Force" }
102
+ "native": { "paths": ["~/AppData/Local/agy/bin"], "update": ["update"], "remove": ["~/AppData/Local/agy"] }
102
103
  }
103
104
  }
104
105
  },
@@ -121,7 +122,8 @@
121
122
  "install": "curl -fsSL https://x.ai/cli/install.sh | bash",
122
123
  "npmNote": "npm installs the native grok binary into ~/.grok/bin.",
123
124
  "channels": {
124
- "native": { "paths": ["~/.grok/bin", "~/.grok/downloads"], "update": ["update"], "sharedWithNpm": true }
125
+ "native": { "paths": ["~/.grok/bin", "~/.grok/downloads"], "update": ["update"], "remove": ["~/.grok/bin", "~/.grok/downloads", "~/.grok/completions"], "links": ["~/.local/bin/grok", "~/.local/bin/agent", "/usr/local/bin/grok", "/usr/local/bin/agent"], "sharedWithNpm": true },
126
+ "brew": { "names": ["grok-build"] }
125
127
  },
126
128
  "docs": "https://docs.x.ai/build/overview",
127
129
  "usageUrl": "https://grok.com/?_s=usage",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,6 +31,8 @@
31
31
  "manager": "node bin/agent-guild.mjs start",
32
32
  "stop": "node bin/agent-guild.mjs stop",
33
33
  "status": "node bin/agent-guild.mjs status",
34
+ "demo:build": "node docs/demo/build.mjs",
35
+ "demo:check": "node docs/demo/check.mjs",
34
36
  "test": "node --test --test-concurrency=1"
35
37
  },
36
38
  "dependencies": {
@@ -35,6 +35,12 @@ export function homeDir(env, platform) {
35
35
  return (platform === 'win32' ? env.USERPROFILE : env.HOME) || os.homedir();
36
36
  }
37
37
 
38
+ export function homeRelative(p, env, platform) {
39
+ const m = pathModule(platform);
40
+ const home = homeDir(env, platform);
41
+ return isInside(p, home, platform) ? `~${m.sep}${m.relative(home, p)}` : p;
42
+ }
43
+
38
44
  export function expandHome(p, env, platform) {
39
45
  const m = pathModule(platform);
40
46
  if (p === '~') return homeDir(env, platform);
@@ -150,17 +156,6 @@ export function formatCommand(file, args) {
150
156
  return [file, ...args].map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(' ');
151
157
  }
152
158
 
153
- function shellQuote(arg, platform) {
154
- if (platform === 'win32') return /^[\w\\/.:=+-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, "''")}'`;
155
- return /^[\w/.:=,+@%-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, "'\\''")}'`;
156
- }
157
-
158
- export function shellCommand(file, args, platform) {
159
- const command = shellQuote(file, platform);
160
- const line = [command, ...args.map((arg) => shellQuote(arg, platform))].join(' ');
161
- return platform === 'win32' && command !== file ? `& ${line}` : line;
162
- }
163
-
164
159
  export function classifyInstall({
165
160
  resolvedPath, provider, env = process.env, platform = process.platform, fsx = defaultFsx, npmOnPath = null, wingetOnPath = null,
166
161
  }) {
@@ -180,8 +175,9 @@ export function classifyInstall({
180
175
 
181
176
  const brew = brewOwner({ realPath, names: channels.brew?.names || [], platform, fsx });
182
177
  if (brew) {
183
- const owned = { brewPrefix: brew.prefix, token: brew.token, cask: brew.cask };
178
+ const owned = { brewPrefix: brew.prefix, token: brew.token, cask: brew.cask, brewFile: brew.brew };
184
179
  if (!brew.brew) return result('brew', { ...owned, guidance: `Installed by Homebrew under ${brew.prefix}, but brew was not found at ${brew.prefix}/bin/brew.` });
180
+ if (channels.brew.autoUpdates) return result('brew', { ...owned, guidance: `${provider.tool} updates itself; Homebrew does not upgrade it.` });
185
181
  return result('brew', { ...owned, update: { file: brew.brew, args: brew.cask ? ['upgrade', '--cask', brew.token] : ['upgrade', brew.token] } });
186
182
  }
187
183
 
@@ -227,18 +223,48 @@ export function installationKey(install, provider, platform, fsx = defaultFsx) {
227
223
  }
228
224
  }
229
225
 
230
- export function removalCommand(install, provider, platform) {
226
+ function ownedPaths(entries, env, platform) {
227
+ const m = pathModule(platform);
228
+ const home = homeDir(env, platform);
229
+ return (entries || []).map((entry) => expandHome(entry, env, platform))
230
+ .filter((p) => m.isAbsolute(p) && isInside(p, home, platform) && m.relative(home, p).split(/[\\/]+/).length >= 2)
231
+ .map((p) => m.normalize(p));
232
+ }
233
+
234
+ // Links may sit outside home, e.g. /usr/local/bin: the runner deletes one only
235
+ // when it is a symlink into the removed paths, which stay inside home.
236
+ function linkPaths(entries, env, platform) {
237
+ const m = pathModule(platform);
238
+ return (entries || []).map((entry) => expandHome(entry, env, platform))
239
+ .filter((p) => (platform === 'win32' ? /^(?:[a-z]:[\\/]|[\\/]{2})/i.test(p) : m.isAbsolute(p)))
240
+ .map((p) => m.normalize(p));
241
+ }
242
+
243
+ function removalFor(channel, env, platform) {
244
+ const remove = ownedPaths(channel?.remove, env, platform);
245
+ return remove.length > 0 ? { remove, links: linkPaths(channel.links, env, platform) } : null;
246
+ }
247
+
248
+ export function uninstallPlan(install, provider, env, platform, fsx = defaultFsx) {
249
+ const channels = provider.channels || {};
250
+ const plan = (run, removal = null, launcher = null) => ({ run, remove: removal?.remove || [], links: removal?.links || [], launcher });
231
251
  switch (install.channel) {
232
- case 'npm':
233
- return install.update ? shellCommand(install.update.file, ['uninstall', '-g', '--prefix', install.prefix, provider.package], platform) : null;
252
+ case 'npm': {
253
+ if (!install.update) return null;
254
+ const run = { file: install.update.file, args: ['uninstall', '-g', '--prefix', install.prefix, provider.package] };
255
+ const removal = channels.native?.sharedWithNpm ? removalFor(channels.native, env, platform) : null;
256
+ const launcher = removal ? knownLaunchers({ provider, command: provider.command, env, platform, fsx })[0] : null;
257
+ return plan(run, removal, launcher);
258
+ }
234
259
  case 'brew':
235
- return install.update ? shellCommand(install.update.file, install.cask ? ['uninstall', '--cask', install.token] : ['uninstall', install.token], platform) : null;
260
+ return install.brewFile ? plan({ file: install.brewFile, args: install.cask ? ['uninstall', '--cask', install.token] : ['uninstall', install.token] }) : null;
236
261
  case 'winget':
237
- return shellCommand('winget', ['uninstall', '--id', install.wingetId, '--exact'], platform);
262
+ return install.update ? plan({ file: install.update.file, args: ['uninstall', '--id', install.wingetId, '--exact'] }) : null;
238
263
  case 'native':
239
- return provider.channels?.native?.uninstall || null;
240
- case 'legacy':
241
- return provider.channels?.legacy?.uninstall || null;
264
+ case 'legacy': {
265
+ const removal = removalFor(channels[install.channel], env, platform);
266
+ return removal ? plan(null, removal, install.resolvedPath) : null;
267
+ }
242
268
  default:
243
269
  return null;
244
270
  }
@@ -267,7 +293,13 @@ export function listInstallations({
267
293
  const add = (file, isOnPath) => {
268
294
  const install = classifyInstall({ resolvedPath: file, provider, env, platform, fsx, npmOnPath, wingetOnPath });
269
295
  const key = installationKey(install, provider, platform, fsx);
270
- if (!installs.has(key)) installs.set(key, { ...install, key, onPath: isOnPath, removeCommand: removalCommand(install, provider, platform) });
296
+ const existing = installs.get(key);
297
+ if (!existing) installs.set(key, { ...install, key, onPath: isOnPath, uninstall: uninstallPlan(install, provider, env, platform, fsx) });
298
+ // A shared native copy also needs its npm owner removed, whichever comes first on PATH.
299
+ else if (install.channel === 'npm' && !existing.uninstall?.run) {
300
+ existing.uninstall = uninstallPlan(install, provider, env, platform, fsx);
301
+ if (!existing.uninstall) existing.guidance = install.guidance;
302
+ }
271
303
  };
272
304
  for (const file of onPath) add(file, true);
273
305
  for (const file of known) add(file, false);
@@ -11,8 +11,9 @@ import { EventEmitter } from 'node:events';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { resolveCommand, resolveAllCommands, pathKey, buildSpawnSpec, runSpec } from './command-resolver.mjs';
13
13
  import { compareVersions, probeVersion, fetchManifest, latestVersion, DEFAULT_NPM_REGISTRY } from './versions.mjs';
14
- import { CHANNEL_LABELS, classifyInstall, expandHome, formatCommand, knownLaunchers, listInstallations, platformDependency, updateHelpAccepted } from './install-channels.mjs';
14
+ import { CHANNEL_LABELS, classifyInstall, expandHome, formatCommand, homeRelative, knownLaunchers, listInstallations, platformDependency, updateHelpAccepted } from './install-channels.mjs';
15
15
  import { weavePaths } from './shell-env.mjs';
16
+ import { RUNNER, encodePlan } from './uninstall.mjs';
16
17
  import { paths } from './config.mjs';
17
18
 
18
19
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -81,17 +82,18 @@ function normalizeChannels(raw) {
81
82
  out.native = {
82
83
  paths: stringList(raw.native.paths),
83
84
  update: stringList(raw.native.update),
84
- uninstall: raw.native.uninstall ? String(raw.native.uninstall) : null,
85
+ remove: stringList(raw.native.remove),
86
+ links: stringList(raw.native.links),
85
87
  sharedWithNpm: raw.native.sharedWithNpm === true,
86
88
  };
87
89
  }
88
- if (raw.brew && typeof raw.brew === 'object') out.brew = { names: stringList(raw.brew.names) };
90
+ if (raw.brew && typeof raw.brew === 'object') out.brew = { names: stringList(raw.brew.names), autoUpdates: raw.brew.autoUpdates === true };
89
91
  if (raw.winget && typeof raw.winget === 'object' && raw.winget.id) out.winget = { id: String(raw.winget.id) };
90
92
  if (raw.legacy && typeof raw.legacy === 'object') {
91
93
  out.legacy = {
92
94
  paths: stringList(raw.legacy.paths),
93
95
  guidance: raw.legacy.guidance ? String(raw.legacy.guidance) : null,
94
- uninstall: raw.legacy.uninstall ? String(raw.legacy.uninstall) : null,
96
+ remove: stringList(raw.legacy.remove),
95
97
  };
96
98
  }
97
99
  return out;
@@ -225,6 +227,12 @@ export function loadProviders({ userFile, platform = process.platform } = {}) {
225
227
  return { providers, warnings };
226
228
  }
227
229
 
230
+ // Why a copy cannot be removed here. An unrecognised copy's own guidance is about updating it.
231
+ function removalGuidance(provider, install, where) {
232
+ if (install.channel !== 'unknown' && install.guidance) return install.guidance;
233
+ return `Agent Guild does not know how ${provider.tool} at ${where} was installed. Remove it the way you installed it.`;
234
+ }
235
+
228
236
  export class ProviderRegistry extends EventEmitter {
229
237
  /**
230
238
  * @param {object} opts
@@ -408,7 +416,7 @@ export class ProviderRegistry extends EventEmitter {
408
416
  if (changed) this.emit('updated');
409
417
  }
410
418
 
411
- async finishInstall(id, { exitCode = null, kind = 'update' } = {}) {
419
+ async finishInstall(id, { exitCode = null, kind = 'update', path: removed = null } = {}) {
412
420
  const provider = this.get(id);
413
421
  if (!provider) return;
414
422
  const before = this.versions.get(id)?.installed ?? null;
@@ -418,6 +426,7 @@ export class ProviderRegistry extends EventEmitter {
418
426
  if (!entry) return;
419
427
  let outcome;
420
428
  if (exitCode !== 0) outcome = 'failed';
429
+ else if (kind === 'uninstall') outcome = this.installsFor(provider).some((i) => i.resolvedPath === removed) ? 'remaining' : 'removed';
421
430
  else if (kind === 'install') outcome = this.resolve(provider) ? 'installed' : 'missing';
422
431
  else if (entry.installed === null) outcome = 'done';
423
432
  else outcome = entry.installed !== before ? 'updated' : 'unchanged';
@@ -509,13 +518,13 @@ export class ProviderRegistry extends EventEmitter {
509
518
  installWarnings(provider, installs) {
510
519
  const label = (i) => [CHANNEL_LABELS[i.channel], i.version && `v${i.version}`].filter(Boolean).join(' ');
511
520
  const active = installs.find((i) => i.active);
512
- if (!active) return installs.map((i) => `A copy of ${provider.tool} exists at ${i.path}, but its folder is not on PATH.`);
521
+ if (!active) return installs.map((i) => `A copy of ${provider.tool} exists at ${i.displayPath}, but its folder is not on PATH.`);
513
522
  const warnings = [];
514
523
  if (installs.length > 1) {
515
- warnings.push(`${installs.length} copies of ${provider.tool} are installed. The one in use is ${label(active)} at ${active.path}.`);
524
+ warnings.push(`${installs.length} copies of ${provider.tool} are installed. The one in use is ${label(active)} at ${active.displayPath}.`);
516
525
  }
517
526
  const newer = installs.find((i) => i.newer);
518
- if (newer) warnings.push(`An older copy comes first on PATH: ${label(active)} is in use while ${label(newer)} is installed at ${newer.path}.`);
527
+ if (newer) warnings.push(`An older copy comes first on PATH: ${label(active)} is in use while ${label(newer)} is installed at ${newer.displayPath}.`);
519
528
  return warnings;
520
529
  }
521
530
 
@@ -587,17 +596,23 @@ export class ProviderRegistry extends EventEmitter {
587
596
  const latest = versions?.latest ?? null;
588
597
  const channel = this.channelFor(provider, resolvedPath);
589
598
  const update = this.updateFor(provider, channel);
599
+ const shown = (file) => homeRelative(file, { ...this.env, ...provider.env }, this.platform);
590
600
  const installs = this.installsFor(provider, resolvedPath).map((install) => {
591
601
  const active = install.resolvedPath === resolvedPath;
592
602
  const copy = active ? { version: installed, status: versions?.versionStatus ?? null } : versions?.copies?.[install.resolvedPath];
593
603
  return {
594
604
  path: install.resolvedPath,
605
+ displayPath: shown(install.resolvedPath),
595
606
  channel: install.channel,
596
607
  version: copy?.version ?? null,
597
608
  versionStatus: copy?.status ?? null,
598
609
  active,
599
610
  onPath: install.onPath,
600
- removeCommand: install.removeCommand,
611
+ uninstall: install.uninstall && {
612
+ command: install.uninstall.run ? formatCommand(install.uninstall.run.file, install.uninstall.run.args) : null,
613
+ remove: install.uninstall.remove.map(shown),
614
+ },
615
+ uninstallGuidance: install.uninstall ? null : removalGuidance(provider, install, shown(install.resolvedPath)),
601
616
  };
602
617
  });
603
618
  const inUse = installs.find((i) => i.active)?.version;
@@ -683,6 +698,18 @@ export class ProviderRegistry extends EventEmitter {
683
698
  return { spec: buildSpawnSpec(update.file, args, this.env, this.platform), channel: channel.channel };
684
699
  }
685
700
 
701
+ uninstallSpec(provider, copyPath) {
702
+ const install = this.listInstalls(provider).find((i) => i.resolvedPath === copyPath);
703
+ if (!install) throw refusal(404, 'unknown_copy', `${provider.tool} has no copy at ${copyPath}`);
704
+ if (!install.uninstall) {
705
+ throw refusal(400, 'not_removable', removalGuidance(provider, install, copyPath));
706
+ }
707
+ return {
708
+ spec: buildSpawnSpec(process.execPath, [RUNNER, encodePlan(install.uninstall)], this.env, this.platform),
709
+ channel: install.channel,
710
+ };
711
+ }
712
+
686
713
  async installSpec(provider) {
687
714
  if (!provider.package) {
688
715
  const err = new Error(`${provider.tool} has no npm package configured; install it by hand: ${provider.install || provider.docs || 'see its documentation'}`);
@@ -290,6 +290,12 @@ export function createManagerServer({
290
290
  const session = await manager.install(installMatch[1], { force: body.force === true });
291
291
  return sendJson(res, 201, { session: session.toJSON() });
292
292
  }
293
+ const uninstallMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/uninstall$/);
294
+ if (uninstallMatch && method === 'POST') {
295
+ const body = await readJsonBody(req);
296
+ const session = manager.uninstall(uninstallMatch[1], body.path, { force: body.force === true });
297
+ return sendJson(res, 201, { session: session.toJSON() });
298
+ }
293
299
  if (route === '/sessions' && method === 'GET') {
294
300
  return sendJson(res, 200, { sessions: manager.list() });
295
301
  }
@@ -104,6 +104,10 @@ export class SessionManager extends EventEmitter {
104
104
  const workDir = this.resolveCwd(cwd);
105
105
  const signIn = this.registry.account(provider, account);
106
106
  const hooks = this.sessionHooks ? await this.sessionHooks.launch(provider) : { args: [], reporting: null };
107
+ // Checked after the await, so an install that started meanwhile is seen.
108
+ if (this.installing.has(provider.id) || this.installsRunningFor(provider.id) > 0) {
109
+ throw httpError(409, `${provider.tool} is being installed, updated or removed; start it once that finishes`, 'install_in_progress');
110
+ }
107
111
  const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId, hooks.args);
108
112
  this.prepareAccount(provider, signIn, { hooksSupplied: hooks.args.length > 0 });
109
113
  const sessionName = cleanName(name) || (provider.accounts.length > 1 ? `${provider.tool} · ${signIn.label}` : null);
@@ -132,21 +136,7 @@ export class SessionManager extends EventEmitter {
132
136
  * set, because replacing a tool under a running process can break it.
133
137
  */
134
138
  async install(providerId, { force = false } = {}) {
135
- const provider = this.registry.get(String(providerId || ''));
136
- if (!provider) throw httpError(404, `unknown provider "${providerId}"`, 'unknown_provider');
137
- if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
138
- const guard = () => {
139
- const running = this.runningFor(provider.id);
140
- if (running > 0 && !force) {
141
- const err = httpError(409, `${running} ${provider.tool} session(s) are running; updating the tool now may break them`, 'provider_in_use');
142
- err.running = running;
143
- throw err;
144
- }
145
- };
146
- guard();
147
- if (this.installing.has(provider.id) || this.installsRunningFor(provider.id) > 0) {
148
- throw httpError(409, `${provider.tool} is already being installed or updated`, 'install_in_progress');
149
- }
139
+ const { provider, guard } = this._installGuard(providerId, force, 'updating the tool now may break them');
150
140
  this.installing.add(provider.id);
151
141
  try {
152
142
  if (this.registry.resolve(provider)) {
@@ -163,9 +153,37 @@ export class SessionManager extends EventEmitter {
163
153
  }
164
154
  }
165
155
 
156
+ uninstall(providerId, copyPath, { force = false } = {}) {
157
+ if (typeof copyPath !== 'string' || !copyPath) throw httpError(400, 'path must name the copy to remove', 'bad_request');
158
+ const { provider, guard } = this._installGuard(providerId, force, 'removing the tool now may break them');
159
+ const { spec, channel } = this.registry.uninstallSpec(provider, copyPath);
160
+ guard();
161
+ const name = `Uninstall ${provider.tool} (${CHANNEL_LABELS[channel]})`;
162
+ return this._spawn({ provider, spawnSpec: spec, cwd: os.homedir(), name, task: 'install', installKind: 'uninstall', installPath: copyPath });
163
+ }
164
+
165
+ _installGuard(providerId, force, risk) {
166
+ const provider = this.registry.get(String(providerId || ''));
167
+ if (!provider) throw httpError(404, `unknown provider "${providerId}"`, 'unknown_provider');
168
+ if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
169
+ const guard = () => {
170
+ const running = this.runningFor(provider.id);
171
+ if (running > 0 && !force) {
172
+ const err = httpError(409, `${running} ${provider.tool} session(s) are running; ${risk}`, 'provider_in_use');
173
+ err.running = running;
174
+ throw err;
175
+ }
176
+ };
177
+ guard();
178
+ if (this.installing.has(provider.id) || this.installsRunningFor(provider.id) > 0) {
179
+ throw httpError(409, `${provider.tool} is already being installed, updated or removed`, 'install_in_progress');
180
+ }
181
+ return { provider, guard };
182
+ }
183
+
166
184
  installsRunningFor(providerId) {
167
185
  let n = 0;
168
- for (const s of this.sessions.values()) if (s.status === 'running' && s.task === 'install' && s.provider.id === providerId) n++;
186
+ for (const s of [...this.sessions.values(), ...this.exiting]) if (s.status === 'running' && s.task === 'install' && s.provider.id === providerId) n++;
169
187
  return n;
170
188
  }
171
189
 
@@ -214,7 +232,7 @@ export class SessionManager extends EventEmitter {
214
232
 
215
233
  runningFor(providerId) {
216
234
  let n = 0;
217
- for (const s of this.sessions.values()) if (s.status === 'running' && s.task === null && s.provider.id === providerId) n++;
235
+ for (const s of [...this.sessions.values(), ...this.exiting]) if (s.status === 'running' && s.task === null && s.provider.id === providerId) n++;
218
236
  return n;
219
237
  }
220
238
 
@@ -226,7 +244,7 @@ export class SessionManager extends EventEmitter {
226
244
  }
227
245
 
228
246
  _spawn({
229
- provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null, account = null,
247
+ provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null, installPath = null, account = null,
230
248
  extraEnv = null, dropEnv = null, clone = null, reporting = null,
231
249
  }) {
232
250
  if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
@@ -285,7 +303,7 @@ export class SessionManager extends EventEmitter {
285
303
  session.on('warning', (msg) => console.warn(`[session ${id}] ${msg}`));
286
304
  if (task === 'install') {
287
305
  session.on('exit', () => {
288
- this.registry.finishInstall(provider.id, { exitCode: session.exitCode, kind: installKind }).catch(() => {});
306
+ this.registry.finishInstall(provider.id, { exitCode: session.exitCode, kind: installKind, path: installPath }).catch(() => {});
289
307
  });
290
308
  }
291
309
  this.sessions.set(id, session);
@@ -0,0 +1,148 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { buildSpawnSpec } from './command-resolver.mjs';
6
+ import { formatCommand } from './install-channels.mjs';
7
+
8
+ export const RUNNER = fileURLToPath(import.meta.url);
9
+
10
+ export function encodePlan(plan) {
11
+ return Buffer.from(JSON.stringify(plan)).toString('base64url');
12
+ }
13
+
14
+ function comparable(p, platform) {
15
+ const m = platform === 'win32' ? path.win32 : path.posix;
16
+ const out = m.normalize(p.replace(/^\\\\\?\\/, ''));
17
+ return platform === 'win32' ? out.toLowerCase() : out;
18
+ }
19
+
20
+ function inside(file, dir, platform) {
21
+ const m = platform === 'win32' ? path.win32 : path.posix;
22
+ const f = comparable(file, platform);
23
+ const d = comparable(dir, platform);
24
+ return f === d || f.startsWith(d + m.sep);
25
+ }
26
+
27
+ function within(file, dirs, platform) {
28
+ return dirs.some((dir) => inside(file, dir, platform));
29
+ }
30
+
31
+ function linkTarget(file) {
32
+ try {
33
+ return fs.lstatSync(file).isSymbolicLink() ? path.resolve(path.dirname(file), fs.readlinkSync(file).replace(/^\\\\\?\\/, '')) : null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function exists(file) {
40
+ try {
41
+ fs.lstatSync(file);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ function firstLink(file) {
49
+ const steps = [];
50
+ for (let p = file; ; p = path.dirname(p)) {
51
+ steps.unshift(p);
52
+ if (path.dirname(p) === p) break;
53
+ }
54
+ return steps.find((p) => linkTarget(p) !== null) ?? null;
55
+ }
56
+
57
+ function launchChain(launcher) {
58
+ const chain = [];
59
+ let file = launcher;
60
+ for (let hops = 0; hops < 40 && exists(file); hops++) {
61
+ chain.push(file);
62
+ const link = firstLink(file);
63
+ if (!link) break;
64
+ chain.push(link);
65
+ file = path.join(linkTarget(link), path.relative(link, file));
66
+ }
67
+ return chain;
68
+ }
69
+
70
+ function removeFile(file) {
71
+ fs.rmSync(file, { recursive: true, force: true, maxRetries: 3 });
72
+ }
73
+
74
+ export function runPlan(
75
+ { run = null, remove = [], links = [], launcher = null },
76
+ { env = process.env, platform = process.platform, log = console.log, rm = removeFile } = {},
77
+ ) {
78
+ if (run) {
79
+ log(`> ${formatCommand(run.file, run.args)}`);
80
+ const spec = buildSpawnSpec(run.file, run.args, env, platform);
81
+ const verbatim = typeof spec.args === 'string';
82
+ const result = spawnSync(spec.file, verbatim ? [spec.args] : spec.args, { stdio: 'inherit', env, windowsVerbatimArguments: verbatim });
83
+ if (result.error) {
84
+ log(`Could not run ${run.file}: ${result.error.message}`);
85
+ return 1;
86
+ }
87
+ if (result.status !== 0) return result.status ?? 1;
88
+ }
89
+ const chain = launcher ? launchChain(launcher) : [];
90
+ const holdsChain = (file) => chain.some((kept) => inside(kept, file, platform));
91
+ const inChain = (file) => chain.some((kept) => comparable(kept, platform) === comparable(file, platform));
92
+ const del = (file) => {
93
+ try {
94
+ rm(file);
95
+ } catch (err) {
96
+ throw Object.assign(err, { file });
97
+ }
98
+ };
99
+ const prune = (dir) => {
100
+ for (const name of fs.readdirSync(dir)) {
101
+ const file = path.join(dir, name);
102
+ if (!holdsChain(file)) del(file);
103
+ else if (!inChain(file) && linkTarget(file) === null) prune(file);
104
+ }
105
+ };
106
+ const owned = [];
107
+ const deferred = [];
108
+ try {
109
+ for (const file of [...remove, ...links]) {
110
+ const target = linkTarget(file);
111
+ if (target === null && !exists(file)) continue;
112
+ if (target !== null && !within(target, remove, platform)) {
113
+ log(`Kept ${file}: it links to ${target}, outside this installation.`);
114
+ continue;
115
+ }
116
+ if (target === null && links.includes(file)) {
117
+ log(`Kept ${file}: it is not a link to this installation.`);
118
+ continue;
119
+ }
120
+ owned.push(file);
121
+ if (!holdsChain(file)) {
122
+ del(file);
123
+ log(`Removed ${file}`);
124
+ continue;
125
+ }
126
+ deferred.push(file);
127
+ if (target === null && fs.lstatSync(file).isDirectory()) prune(file);
128
+ }
129
+ for (const file of [...new Set([...chain].reverse())]) {
130
+ if (within(file, owned, platform) && exists(file)) del(file);
131
+ }
132
+ for (const file of deferred.sort((a, b) => b.length - a.length)) {
133
+ if (exists(file)) del(file);
134
+ log(`Removed ${file}`);
135
+ }
136
+ } catch (err) {
137
+ const code = err.code === 'ENOTDIR' && err.syscall === 'scandir' ? 'EPERM' : err.code;
138
+ const reason = { EBUSY: 'it is in use', EPERM: 'it is in use or protected', EACCES: 'permission was denied' }[code] || err.message;
139
+ log(`Could not remove ${err.path || err.file}: ${reason}. Stopped there. Close any program using it, then uninstall again.`);
140
+ return 1;
141
+ }
142
+ return 0;
143
+ }
144
+
145
+ if (process.argv[1] && path.resolve(process.argv[1]) === RUNNER) {
146
+ const plan = JSON.parse(Buffer.from(process.argv[2] || '', 'base64url').toString('utf8') || '{}');
147
+ process.exitCode = runPlan(plan);
148
+ }
package/web/app.js CHANGED
@@ -556,7 +556,7 @@ function renderProviders() {
556
556
  update.title = provider.updateCommand ? `Run "${provider.updateCommand}" in a session` : '';
557
557
  update.addEventListener('click', () => installProvider(provider, node));
558
558
  renderHint(hint, provider);
559
- renderCopies(node.querySelector('.copies'), provider);
559
+ renderCopies(node.querySelector('.copies'), provider, node);
560
560
  renderVendorLinks(node, provider);
561
561
  renderAccounts(node, provider);
562
562
  renderUsage(node, provider);
@@ -572,16 +572,17 @@ function renderProviders() {
572
572
 
573
573
  const openCopies = new Set();
574
574
 
575
- function renderCopies(box, provider) {
575
+ function renderCopies(box, provider, card) {
576
576
  const installs = provider.installs || [];
577
577
  const warnings = provider.warnings || [];
578
- box.hidden = warnings.length === 0;
578
+ box.hidden = warnings.length === 0 && !installs.some((i) => i.uninstall);
579
579
  if (box.hidden) return;
580
+ box.classList.toggle('warned', warnings.length > 0);
580
581
  const inUse = installs.some((i) => i.active);
581
582
  const older = installs.some((i) => i.newer);
582
583
  box.querySelector('summary').textContent = !inUse
583
584
  ? 'A copy exists off PATH'
584
- : `${installs.length} copies installed${older ? ' · older copy in use' : ''}`;
585
+ : installs.length === 1 ? 'Installation' : `${installs.length} copies installed${older ? ' · older copy in use' : ''}`;
585
586
  box.open = openCopies.has(provider.id);
586
587
  box.addEventListener('toggle', () => (box.open ? openCopies.add(provider.id) : openCopies.delete(provider.id)));
587
588
  const line = (className, ...content) => {
@@ -596,14 +597,25 @@ function renderCopies(box, provider) {
596
597
  return item;
597
598
  }), ...installs.map((install) => {
598
599
  const item = document.createElement('li');
599
- const name = [CHANNEL_LABELS[install.channel] || install.channel, install.version && `v${install.version}`, install.active ? 'in use' : 'not in use'];
600
- item.append(line('copy-name', name.filter(Boolean).join(' · ')), line('copy-path', install.path));
601
- if (install.removeCommand) {
602
- const code = document.createElement('code');
603
- code.textContent = install.removeCommand;
604
- item.append(line('copy-remove', 'To remove it: ', code));
600
+ const channel = CHANNEL_LABELS[install.channel] || install.channel;
601
+ const name = [channel, install.version && `v${install.version}`, installs.length > 1 && (install.active ? 'in use' : 'not in use')];
602
+ const head = line('copy-head', line('copy-name', name.filter(Boolean).join(' · ')));
603
+ const where = line('copy-path', install.displayPath);
604
+ where.title = install.path;
605
+ item.append(head, where);
606
+ if (install.uninstall) {
607
+ const button = document.createElement('button');
608
+ button.type = 'button';
609
+ button.className = 'btn danger copy-uninstall';
610
+ button.textContent = 'Uninstall';
611
+ button.setAttribute('aria-label', `Uninstall the ${channel} copy of ${provider.tool} at ${install.displayPath}`);
612
+ button.title = install.uninstall.command
613
+ ? [`Runs ${install.uninstall.command}`, ...install.uninstall.remove.map((p) => `then deletes ${p}`)].join('\n')
614
+ : ['Deletes', ...install.uninstall.remove].join('\n');
615
+ button.addEventListener('click', () => uninstallCopy(provider, card, install));
616
+ head.append(button);
605
617
  } else {
606
- item.append(line('copy-remove', 'No removal command is known for this copy.'));
618
+ item.append(line('copy-remove', install.uninstallGuidance));
607
619
  }
608
620
  return item;
609
621
  }));
@@ -1643,9 +1655,10 @@ function installNote(provider) {
1643
1655
  const last = provider.lastInstall;
1644
1656
  if (!last) return '';
1645
1657
  if (last.outcome === 'failed') {
1646
- const what = last.kind === 'install' ? 'Install' : 'Update';
1658
+ const what = { install: 'Install', uninstall: 'Uninstall' }[last.kind] || 'Update';
1647
1659
  return last.exitCode === null ? `${what} failed` : `${what} failed (exit ${last.exitCode})`;
1648
1660
  }
1661
+ if (last.kind === 'uninstall') return last.outcome === 'remaining' ? 'Uninstalled copy is still present' : '';
1649
1662
  if (last.verification === 'failed') return `Installation completed, but ${provider.tool} verification failed`;
1650
1663
  if (last.outcome === 'missing') return 'Installed, but not found on PATH';
1651
1664
  if (last.outcome === 'unchanged') return 'No version change after update';
@@ -1671,10 +1684,20 @@ function providerState(provider) {
1671
1684
  return parts.join(' · ');
1672
1685
  }
1673
1686
 
1674
- async function installProvider(provider, card, { force = false } = {}) {
1687
+ function uninstallCopy(provider, card, install) {
1688
+ const what = install.uninstall.command
1689
+ ? [`Runs: ${install.uninstall.command}`, ...install.uninstall.remove.map((p) => `Then deletes: ${p}`)]
1690
+ : install.uninstall.remove.map((p) => `Deletes: ${p}`);
1691
+ if (!confirm([`Uninstall ${provider.tool} from ${install.displayPath}?`, '', ...what, '', 'Your sign-in, settings and history are kept.'].join('\n'))) return;
1692
+ return installProvider(provider, card, { path: install.path });
1693
+ }
1694
+
1695
+ async function installProvider(provider, card, { force = false, path = null } = {}) {
1675
1696
  card.classList.add('busy');
1676
1697
  try {
1677
- const { session } = await api('POST', `/providers/${provider.id}/install`, { force });
1698
+ const { session } = path
1699
+ ? await api('POST', `/providers/${provider.id}/uninstall`, { path, force })
1700
+ : await api('POST', `/providers/${provider.id}/install`, { force });
1678
1701
  upsertSession(session);
1679
1702
  openPanel(session.id);
1680
1703
  } catch (err) {
@@ -1683,8 +1706,9 @@ async function installProvider(provider, card, { force = false } = {}) {
1683
1706
  card.classList.remove('busy');
1684
1707
  const n = err.running;
1685
1708
  const what = `${n} ${provider.tool} session${n === 1 ? ' is' : 's are'} running`;
1686
- if (confirm(`${what}. Updating ${provider.tool} while it runs can break ${n === 1 ? 'that session' : 'those sessions'}. Update anyway?`)) {
1687
- return installProvider(provider, card, { force: true });
1709
+ const [doing, verb] = path ? ['Removing', 'Uninstall'] : ['Updating', 'Update'];
1710
+ if (confirm(`${what}. ${doing} ${provider.tool} while it runs can break ${n === 1 ? 'that session' : 'those sessions'}. ${verb} anyway?`)) {
1711
+ return installProvider(provider, card, { force: true, path });
1688
1712
  }
1689
1713
  return;
1690
1714
  }
package/web/index.html CHANGED
@@ -272,6 +272,10 @@
272
272
  <a class="console-link usage-link" target="_blank" rel="noopener noreferrer">Usage</a>
273
273
  <a class="console-link billing-link" target="_blank" rel="noopener noreferrer">Billing</a>
274
274
  </div>
275
+ <details class="copies" hidden>
276
+ <summary></summary>
277
+ <ul></ul>
278
+ </details>
275
279
  <div class="provider-actions">
276
280
  <button class="btn primary new" type="button">New</button>
277
281
  <button class="btn existing" type="button">Existing…</button>
@@ -279,10 +283,6 @@
279
283
  <button class="btn update" type="button"></button>
280
284
  <span class="hint"></span>
281
285
  </div>
282
- <details class="copies" hidden>
283
- <summary></summary>
284
- <ul></ul>
285
- </details>
286
286
  </article>
287
287
  </template>
288
288
 
package/web/styles.css CHANGED
@@ -209,14 +209,17 @@ main { max-width: 1200px; margin: 0 auto; padding: 26px 16px 56px; display: grid
209
209
  .provider-actions { margin-top: auto; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
210
210
  .provider .hint { font-size: 0.78rem; color: var(--muted); overflow-wrap: anywhere; }
211
211
  .provider .copies { font-size: 0.78rem; color: var(--muted); overflow-wrap: anywhere; }
212
- .provider .copies summary { cursor: pointer; color: var(--warn); list-style: none; }
212
+ .provider .copies summary { cursor: pointer; list-style: none; }
213
+ .provider .copies.warned summary { color: var(--warn); }
213
214
  .provider .copies summary::-webkit-details-marker { display: none; }
214
215
  .provider .copies summary::before { margin: 0 0.3em 0 0; transition: transform 0.15s var(--ease); }
215
216
  .provider .copies[open] summary::before { transform: rotate(90deg); }
216
217
  .provider .copies ul { margin: 6px 0 0; padding: 0; list-style: none; display: grid; gap: 8px; }
217
218
  .provider .copies li span { display: block; }
218
- .provider .copies .copy-name { color: var(--text); }
219
- .provider .copies code { font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; color: var(--text); }
219
+ .provider .copies .copy-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 24px; }
220
+ .provider .copies .copy-name { color: var(--text); min-width: 0; }
221
+ .provider .copies .copy-path { font-family: var(--font-mono); font-size: 0.72rem; }
222
+ .provider .copies .copy-uninstall { flex: none; padding: 2px 10px; font-size: 0.75rem; }
220
223
  .provider.busy { opacity: 0.6; pointer-events: none; }
221
224
 
222
225
  .provider-icon {