@oddessentials/agent-guild 0.13.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
@@ -47,7 +51,7 @@ Grok Build and a plain shell each get a card. A tool that is missing shows
47
51
  vendor's install command for a tool not published on npm. An installed
48
52
  tool shows its version and offers **Update** when a newer one is out, using
49
53
  the same installer that put it there: npm, Homebrew, WinGet or the vendor's
50
- own.
54
+ own. The cloud icon on each card opens the vendor's web app.
51
55
 
52
56
  **Real terminals that outlive the page.** Every session is a full interactive
53
57
  terminal: type instructions, answer prompts, watch output. Run as many as you
@@ -21,20 +21,21 @@
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",
31
31
  "billingUrl": "https://claude.ai/settings/billing",
32
+ "cloudUrl": "https://claude.ai/code",
32
33
  "win32": {
33
34
  "install": "irm https://claude.ai/install.ps1 | iex",
34
35
  "channels": {
35
- "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"] },
36
37
  "winget": { "id": "Anthropic.ClaudeCode" },
37
- "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"] }
38
39
  }
39
40
  }
40
41
  },
@@ -58,17 +59,18 @@
58
59
  "install": "curl -fsSL https://chatgpt.com/codex/install.sh | sh",
59
60
  "npmNote": "npm installs the native Codex binary.",
60
61
  "channels": {
61
- "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"] },
62
63
  "brew": { "names": ["codex"] },
63
64
  "winget": { "id": "OpenAI.Codex" }
64
65
  },
65
66
  "docs": "https://github.com/openai/codex",
66
67
  "usageUrl": "https://chatgpt.com/codex/settings/usage",
67
68
  "billingUrl": "https://chatgpt.com/#settings/Billing",
69
+ "cloudUrl": "https://chatgpt.com/codex",
68
70
  "win32": {
69
71
  "install": "powershell -ExecutionPolicy ByPass -c \"irm https://chatgpt.com/codex/install.ps1 | iex\"",
70
72
  "channels": {
71
- "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"] },
72
74
  "winget": { "id": "OpenAI.Codex" }
73
75
  }
74
76
  }
@@ -88,14 +90,16 @@
88
90
  "monogram": "G",
89
91
  "install": "curl -fsSL https://antigravity.google/cli/install.sh | bash",
90
92
  "channels": {
91
- "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 }
92
95
  },
93
96
  "docs": "https://antigravity.google/docs/cli/install",
94
97
  "billingUrl": "https://one.google.com/settings",
98
+ "cloudUrl": "https://gemini.google.com/",
95
99
  "win32": {
96
100
  "install": "irm https://antigravity.google/cli/install.ps1 | iex",
97
101
  "channels": {
98
- "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"] }
99
103
  }
100
104
  }
101
105
  },
@@ -118,11 +122,13 @@
118
122
  "install": "curl -fsSL https://x.ai/cli/install.sh | bash",
119
123
  "npmNote": "npm installs the native grok binary into ~/.grok/bin.",
120
124
  "channels": {
121
- "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"] }
122
127
  },
123
128
  "docs": "https://docs.x.ai/build/overview",
124
129
  "usageUrl": "https://grok.com/?_s=usage",
125
130
  "billingUrl": "https://grok.com/?_s=billing",
131
+ "cloudUrl": "https://grok.com/",
126
132
  "win32": {
127
133
  "install": "irm https://x.ai/cli/install.ps1 | iex"
128
134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.13.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;
@@ -182,6 +184,7 @@ function normalize(raw, platform, warnings) {
182
184
  docs: String(merged.docs || ''),
183
185
  usageUrl: httpsUrl(merged.usageUrl, 'usageUrl', merged.id, warnings),
184
186
  billingUrl: httpsUrl(merged.billingUrl, 'billingUrl', merged.id, warnings),
187
+ cloudUrl: httpsUrl(merged.cloudUrl, 'cloudUrl', merged.id, warnings),
185
188
  enabled: merged.enabled !== false,
186
189
  };
187
190
  }
@@ -224,6 +227,12 @@ export function loadProviders({ userFile, platform = process.platform } = {}) {
224
227
  return { providers, warnings };
225
228
  }
226
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
+
227
236
  export class ProviderRegistry extends EventEmitter {
228
237
  /**
229
238
  * @param {object} opts
@@ -407,7 +416,7 @@ export class ProviderRegistry extends EventEmitter {
407
416
  if (changed) this.emit('updated');
408
417
  }
409
418
 
410
- async finishInstall(id, { exitCode = null, kind = 'update' } = {}) {
419
+ async finishInstall(id, { exitCode = null, kind = 'update', path: removed = null } = {}) {
411
420
  const provider = this.get(id);
412
421
  if (!provider) return;
413
422
  const before = this.versions.get(id)?.installed ?? null;
@@ -417,6 +426,7 @@ export class ProviderRegistry extends EventEmitter {
417
426
  if (!entry) return;
418
427
  let outcome;
419
428
  if (exitCode !== 0) outcome = 'failed';
429
+ else if (kind === 'uninstall') outcome = this.installsFor(provider).some((i) => i.resolvedPath === removed) ? 'remaining' : 'removed';
420
430
  else if (kind === 'install') outcome = this.resolve(provider) ? 'installed' : 'missing';
421
431
  else if (entry.installed === null) outcome = 'done';
422
432
  else outcome = entry.installed !== before ? 'updated' : 'unchanged';
@@ -508,13 +518,13 @@ export class ProviderRegistry extends EventEmitter {
508
518
  installWarnings(provider, installs) {
509
519
  const label = (i) => [CHANNEL_LABELS[i.channel], i.version && `v${i.version}`].filter(Boolean).join(' ');
510
520
  const active = installs.find((i) => i.active);
511
- 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.`);
512
522
  const warnings = [];
513
523
  if (installs.length > 1) {
514
- 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}.`);
515
525
  }
516
526
  const newer = installs.find((i) => i.newer);
517
- 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}.`);
518
528
  return warnings;
519
529
  }
520
530
 
@@ -586,17 +596,23 @@ export class ProviderRegistry extends EventEmitter {
586
596
  const latest = versions?.latest ?? null;
587
597
  const channel = this.channelFor(provider, resolvedPath);
588
598
  const update = this.updateFor(provider, channel);
599
+ const shown = (file) => homeRelative(file, { ...this.env, ...provider.env }, this.platform);
589
600
  const installs = this.installsFor(provider, resolvedPath).map((install) => {
590
601
  const active = install.resolvedPath === resolvedPath;
591
602
  const copy = active ? { version: installed, status: versions?.versionStatus ?? null } : versions?.copies?.[install.resolvedPath];
592
603
  return {
593
604
  path: install.resolvedPath,
605
+ displayPath: shown(install.resolvedPath),
594
606
  channel: install.channel,
595
607
  version: copy?.version ?? null,
596
608
  versionStatus: copy?.status ?? null,
597
609
  active,
598
610
  onPath: install.onPath,
599
- 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)),
600
616
  };
601
617
  });
602
618
  const inUse = installs.find((i) => i.active)?.version;
@@ -637,6 +653,7 @@ export class ProviderRegistry extends EventEmitter {
637
653
  docs: provider.docs,
638
654
  usageUrl: provider.usageUrl,
639
655
  billingUrl: provider.billingUrl,
656
+ cloudUrl: provider.cloudUrl,
640
657
  available: Boolean(resolvedPath),
641
658
  resolvedPath,
642
659
  };
@@ -681,6 +698,18 @@ export class ProviderRegistry extends EventEmitter {
681
698
  return { spec: buildSpawnSpec(update.file, args, this.env, this.platform), channel: channel.channel };
682
699
  }
683
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
+
684
713
  async installSpec(provider) {
685
714
  if (!provider.package) {
686
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,8 +556,8 @@ 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);
560
- renderConsoleLinks(node, provider);
559
+ renderCopies(node.querySelector('.copies'), provider, node);
560
+ renderVendorLinks(node, provider);
561
561
  renderAccounts(node, provider);
562
562
  renderUsage(node, provider);
563
563
  renderReportingSetup(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
  }));
@@ -627,19 +639,22 @@ function renderHint(hint, provider) {
627
639
  hint.append(' ', link);
628
640
  }
629
641
 
630
- function renderConsoleLinks(card, provider) {
631
- let any = false;
632
- for (const [selector, url, what] of [['.usage-link', provider.usageUrl, 'usage'], ['.billing-link', provider.billingUrl, 'billing']]) {
633
- const link = card.querySelector(selector);
634
- const href = httpsHref(url);
635
- link.hidden = !href;
636
- if (!href) { link.removeAttribute('href'); continue; }
637
- any = true;
638
- link.href = href;
639
- link.title = `${provider.vendor} ${what} console: ${href}`;
640
- link.setAttribute('aria-label', `${provider.vendor} ${what} console (opens in a new tab)`);
641
- }
642
- card.querySelector('.provider-links').hidden = !any;
642
+ function renderVendorLinks(card, provider) {
643
+ const usage = setVendorLink(card.querySelector('.usage-link'), provider.usageUrl, `${provider.vendor} usage console`);
644
+ const billing = setVendorLink(card.querySelector('.billing-link'), provider.billingUrl, `${provider.vendor} billing console`);
645
+ card.querySelector('.provider-links').hidden = !usage && !billing;
646
+ setVendorLink(card.querySelector('.cloud-link'), provider.cloudUrl, `${provider.vendor} web app`);
647
+ }
648
+
649
+ /** Point a link at an https URL, or hide it. Returns whether it is shown. */
650
+ function setVendorLink(link, url, label) {
651
+ const href = httpsHref(url);
652
+ link.hidden = !href;
653
+ if (!href) { link.removeAttribute('href'); return false; }
654
+ link.href = href;
655
+ link.title = `${label}: ${href}`;
656
+ link.setAttribute('aria-label', `${label} (opens in a new tab)`);
657
+ return true;
643
658
  }
644
659
 
645
660
  function renderTier(card, provider, usage) {
@@ -1640,9 +1655,10 @@ function installNote(provider) {
1640
1655
  const last = provider.lastInstall;
1641
1656
  if (!last) return '';
1642
1657
  if (last.outcome === 'failed') {
1643
- const what = last.kind === 'install' ? 'Install' : 'Update';
1658
+ const what = { install: 'Install', uninstall: 'Uninstall' }[last.kind] || 'Update';
1644
1659
  return last.exitCode === null ? `${what} failed` : `${what} failed (exit ${last.exitCode})`;
1645
1660
  }
1661
+ if (last.kind === 'uninstall') return last.outcome === 'remaining' ? 'Uninstalled copy is still present' : '';
1646
1662
  if (last.verification === 'failed') return `Installation completed, but ${provider.tool} verification failed`;
1647
1663
  if (last.outcome === 'missing') return 'Installed, but not found on PATH';
1648
1664
  if (last.outcome === 'unchanged') return 'No version change after update';
@@ -1668,10 +1684,20 @@ function providerState(provider) {
1668
1684
  return parts.join(' · ');
1669
1685
  }
1670
1686
 
1671
- 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 } = {}) {
1672
1696
  card.classList.add('busy');
1673
1697
  try {
1674
- 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 });
1675
1701
  upsertSession(session);
1676
1702
  openPanel(session.id);
1677
1703
  } catch (err) {
@@ -1680,8 +1706,9 @@ async function installProvider(provider, card, { force = false } = {}) {
1680
1706
  card.classList.remove('busy');
1681
1707
  const n = err.running;
1682
1708
  const what = `${n} ${provider.tool} session${n === 1 ? ' is' : 's are'} running`;
1683
- if (confirm(`${what}. Updating ${provider.tool} while it runs can break ${n === 1 ? 'that session' : 'those sessions'}. Update anyway?`)) {
1684
- 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 });
1685
1712
  }
1686
1713
  return;
1687
1714
  }
package/web/index.html CHANGED
@@ -259,6 +259,7 @@
259
259
  <span class="tool"></span>
260
260
  <span class="state"></span>
261
261
  </span>
262
+ <a class="cloud-link" target="_blank" rel="noopener noreferrer" hidden></a>
262
263
  </div>
263
264
  <div class="accounts" role="tablist" aria-label="Accounts" hidden></div>
264
265
  <div class="usage"></div>
@@ -271,6 +272,10 @@
271
272
  <a class="console-link usage-link" target="_blank" rel="noopener noreferrer">Usage</a>
272
273
  <a class="console-link billing-link" target="_blank" rel="noopener noreferrer">Billing</a>
273
274
  </div>
275
+ <details class="copies" hidden>
276
+ <summary></summary>
277
+ <ul></ul>
278
+ </details>
274
279
  <div class="provider-actions">
275
280
  <button class="btn primary new" type="button">New</button>
276
281
  <button class="btn existing" type="button">Existing…</button>
@@ -278,10 +283,6 @@
278
283
  <button class="btn update" type="button"></button>
279
284
  <span class="hint"></span>
280
285
  </div>
281
- <details class="copies" hidden>
282
- <summary></summary>
283
- <ul></ul>
284
- </details>
285
286
  </article>
286
287
  </template>
287
288
 
package/web/styles.css CHANGED
@@ -23,7 +23,8 @@
23
23
  --icon-sun: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='4.2'/%3E%3Cpath d='M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6'/%3E%3C/svg%3E");
24
24
  --icon-moon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M19.5 14.6A8 8 0 0 1 9.4 4.5a8 8 0 1 0 10.1 10.1z'/%3E%3C/svg%3E");
25
25
  --icon-external: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 17L17 7M9 7h8v8'/%3E%3C/svg%3E");
26
- --icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 5.5l6.5 6.5L9 18.5'/%3E%3C/svg%3E");
26
+ --icon-cloud: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M7 18.5h10.5a4 4 0 0 0 .6-7.96A6 6 0 0 0 6.5 9.6 4.5 4.5 0 0 0 7 18.5z'/%3E%3C/svg%3E");
27
+ --icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 5.5l6.5 6.5L9 18.5'/%3E%3C/svg%3E");
27
28
  --icon-github: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z'/%3E%3C/svg%3E");
28
29
  --icon-info: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 10.8v6.2' stroke-width='2.4'/%3E%3Ccircle cx='12' cy='7.3' r='1.2' fill='black' stroke='none'/%3E%3C/svg%3E");
29
30
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
@@ -201,17 +202,24 @@ main { max-width: 1200px; margin: 0 auto; padding: 26px 16px 56px; display: grid
201
202
  .console-link { color: var(--accent); text-decoration: none; }
202
203
  .console-link:hover { text-decoration: underline; }
203
204
  .console-link:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 3px; }
205
+ .cloud-link { display: block; flex: none; align-self: flex-start; width: 32px; height: 32px; margin: -4px -6px 0 0; border-radius: 8px; color: var(--muted); }
206
+ .cloud-link::before { content: ""; display: block; width: 100%; height: 100%; background: currentColor; mask: var(--icon-cloud) center / 20px no-repeat; }
207
+ .cloud-link:hover { color: var(--accent); }
208
+ .cloud-link:focus-visible { color: var(--accent); outline: 2px solid var(--focus); outline-offset: 0; }
204
209
  .provider-actions { margin-top: auto; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
205
210
  .provider .hint { font-size: 0.78rem; color: var(--muted); overflow-wrap: anywhere; }
206
211
  .provider .copies { font-size: 0.78rem; color: var(--muted); overflow-wrap: anywhere; }
207
- .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); }
208
214
  .provider .copies summary::-webkit-details-marker { display: none; }
209
215
  .provider .copies summary::before { margin: 0 0.3em 0 0; transition: transform 0.15s var(--ease); }
210
216
  .provider .copies[open] summary::before { transform: rotate(90deg); }
211
217
  .provider .copies ul { margin: 6px 0 0; padding: 0; list-style: none; display: grid; gap: 8px; }
212
218
  .provider .copies li span { display: block; }
213
- .provider .copies .copy-name { color: var(--text); }
214
- .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; }
215
223
  .provider.busy { opacity: 0.6; pointer-events: none; }
216
224
 
217
225
  .provider-icon {