cloudhop 1.0.0 → 1.1.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 (3) hide show
  1. package/README.md +20 -1
  2. package/dist/cli.js +1730 -664
  3. package/package.json +5 -3
package/dist/cli.js CHANGED
@@ -41,62 +41,6 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
41
41
  mod
42
42
  ));
43
43
 
44
- // ../../node_modules/.pnpm/sisteransi@1.0.5/node_modules/sisteransi/src/index.js
45
- var require_src = __commonJS({
46
- "../../node_modules/.pnpm/sisteransi@1.0.5/node_modules/sisteransi/src/index.js"(exports, module) {
47
- "use strict";
48
- var ESC2 = "\x1B";
49
- var CSI2 = `${ESC2}[`;
50
- var beep = "\x07";
51
- var cursor3 = {
52
- to(x2, y2) {
53
- if (!y2) return `${CSI2}${x2 + 1}G`;
54
- return `${CSI2}${y2 + 1};${x2 + 1}H`;
55
- },
56
- move(x2, y2) {
57
- let ret = "";
58
- if (x2 < 0) ret += `${CSI2}${-x2}D`;
59
- else if (x2 > 0) ret += `${CSI2}${x2}C`;
60
- if (y2 < 0) ret += `${CSI2}${-y2}A`;
61
- else if (y2 > 0) ret += `${CSI2}${y2}B`;
62
- return ret;
63
- },
64
- up: (count = 1) => `${CSI2}${count}A`,
65
- down: (count = 1) => `${CSI2}${count}B`,
66
- forward: (count = 1) => `${CSI2}${count}C`,
67
- backward: (count = 1) => `${CSI2}${count}D`,
68
- nextLine: (count = 1) => `${CSI2}E`.repeat(count),
69
- prevLine: (count = 1) => `${CSI2}F`.repeat(count),
70
- left: `${CSI2}G`,
71
- hide: `${CSI2}?25l`,
72
- show: `${CSI2}?25h`,
73
- save: `${ESC2}7`,
74
- restore: `${ESC2}8`
75
- };
76
- var scroll = {
77
- up: (count = 1) => `${CSI2}S`.repeat(count),
78
- down: (count = 1) => `${CSI2}T`.repeat(count)
79
- };
80
- var erase3 = {
81
- screen: `${CSI2}2J`,
82
- up: (count = 1) => `${CSI2}1J`.repeat(count),
83
- down: (count = 1) => `${CSI2}J`.repeat(count),
84
- line: `${CSI2}2K`,
85
- lineEnd: `${CSI2}K`,
86
- lineStart: `${CSI2}1K`,
87
- lines(count) {
88
- let clear = "";
89
- for (let i2 = 0; i2 < count; i2++)
90
- clear += this.line + (i2 < count - 1 ? cursor3.up() : "");
91
- if (count)
92
- clear += cursor3.left;
93
- return clear;
94
- }
95
- };
96
- module.exports = { cursor: cursor3, scroll, erase: erase3, beep };
97
- }
98
- });
99
-
100
44
  // ../../node_modules/.pnpm/yaml@2.9.1/node_modules/yaml/dist/nodes/identity.js
101
45
  var require_identity = __commonJS({
102
46
  "../../node_modules/.pnpm/yaml@2.9.1/node_modules/yaml/dist/nodes/identity.js"(exports) {
@@ -1064,14 +1008,14 @@ var require_foldFlowLines = __commonJS({
1064
1008
  var FOLD_FLOW = "flow";
1065
1009
  var FOLD_BLOCK = "block";
1066
1010
  var FOLD_QUOTED = "quoted";
1067
- function foldFlowLines(text3, indent, mode = "flow", { indentAtStart, lineWidth = 80, minContentWidth = 20, onFold, onOverflow } = {}) {
1011
+ function foldFlowLines(text4, indent, mode = "flow", { indentAtStart, lineWidth = 80, minContentWidth = 20, onFold, onOverflow } = {}) {
1068
1012
  if (!lineWidth || lineWidth < 0)
1069
- return text3;
1013
+ return text4;
1070
1014
  if (lineWidth < minContentWidth)
1071
1015
  minContentWidth = 0;
1072
1016
  const endStep = Math.max(1 + minContentWidth, 1 + lineWidth - indent.length);
1073
- if (text3.length <= endStep)
1074
- return text3;
1017
+ if (text4.length <= endStep)
1018
+ return text4;
1075
1019
  const folds = [];
1076
1020
  const escapedFolds = {};
1077
1021
  let end = lineWidth - indent.length;
@@ -1088,14 +1032,14 @@ var require_foldFlowLines = __commonJS({
1088
1032
  let escStart = -1;
1089
1033
  let escEnd = -1;
1090
1034
  if (mode === FOLD_BLOCK) {
1091
- i2 = consumeMoreIndentedLines(text3, i2, indent.length);
1035
+ i2 = consumeMoreIndentedLines(text4, i2, indent.length);
1092
1036
  if (i2 !== -1)
1093
1037
  end = i2 + endStep;
1094
1038
  }
1095
- for (let ch; ch = text3[i2 += 1]; ) {
1039
+ for (let ch; ch = text4[i2 += 1]; ) {
1096
1040
  if (mode === FOLD_QUOTED && ch === "\\") {
1097
1041
  escStart = i2;
1098
- switch (text3[i2 + 1]) {
1042
+ switch (text4[i2 + 1]) {
1099
1043
  case "x":
1100
1044
  i2 += 3;
1101
1045
  break;
@@ -1112,12 +1056,12 @@ var require_foldFlowLines = __commonJS({
1112
1056
  }
1113
1057
  if (ch === "\n") {
1114
1058
  if (mode === FOLD_BLOCK)
1115
- i2 = consumeMoreIndentedLines(text3, i2, indent.length);
1059
+ i2 = consumeMoreIndentedLines(text4, i2, indent.length);
1116
1060
  end = i2 + indent.length + endStep;
1117
1061
  split = void 0;
1118
1062
  } else {
1119
1063
  if (ch === " " && prev && prev !== " " && prev !== "\n" && prev !== " ") {
1120
- const next = text3[i2 + 1];
1064
+ const next = text4[i2 + 1];
1121
1065
  if (next && next !== " " && next !== "\n" && next !== " ")
1122
1066
  split = i2;
1123
1067
  }
@@ -1129,12 +1073,12 @@ var require_foldFlowLines = __commonJS({
1129
1073
  } else if (mode === FOLD_QUOTED) {
1130
1074
  while (prev === " " || prev === " ") {
1131
1075
  prev = ch;
1132
- ch = text3[i2 += 1];
1076
+ ch = text4[i2 += 1];
1133
1077
  overflow = true;
1134
1078
  }
1135
1079
  const j2 = i2 > escEnd + 1 ? i2 - 2 : escStart - 1;
1136
1080
  if (escapedFolds[j2])
1137
- return text3;
1081
+ return text4;
1138
1082
  folds.push(j2);
1139
1083
  escapedFolds[j2] = true;
1140
1084
  end = j2 + endStep;
@@ -1149,39 +1093,39 @@ var require_foldFlowLines = __commonJS({
1149
1093
  if (overflow && onOverflow)
1150
1094
  onOverflow();
1151
1095
  if (folds.length === 0)
1152
- return text3;
1096
+ return text4;
1153
1097
  if (onFold)
1154
1098
  onFold();
1155
- let res = text3.slice(0, folds[0]);
1099
+ let res = text4.slice(0, folds[0]);
1156
1100
  for (let i3 = 0; i3 < folds.length; ++i3) {
1157
1101
  const fold = folds[i3];
1158
- const end2 = folds[i3 + 1] || text3.length;
1102
+ const end2 = folds[i3 + 1] || text4.length;
1159
1103
  if (fold === 0)
1160
1104
  res = `
1161
- ${indent}${text3.slice(0, end2)}`;
1105
+ ${indent}${text4.slice(0, end2)}`;
1162
1106
  else {
1163
1107
  if (mode === FOLD_QUOTED && escapedFolds[fold])
1164
- res += `${text3[fold]}\\`;
1108
+ res += `${text4[fold]}\\`;
1165
1109
  res += `
1166
- ${indent}${text3.slice(fold + 1, end2)}`;
1110
+ ${indent}${text4.slice(fold + 1, end2)}`;
1167
1111
  }
1168
1112
  }
1169
1113
  return res;
1170
1114
  }
1171
- function consumeMoreIndentedLines(text3, i2, indent) {
1115
+ function consumeMoreIndentedLines(text4, i2, indent) {
1172
1116
  let end = i2;
1173
1117
  let start = i2 + 1;
1174
- let ch = text3[start];
1118
+ let ch = text4[start];
1175
1119
  while (ch === " " || ch === " ") {
1176
1120
  if (i2 < start + indent) {
1177
- ch = text3[++i2];
1121
+ ch = text4[++i2];
1178
1122
  } else {
1179
1123
  do {
1180
- ch = text3[++i2];
1124
+ ch = text4[++i2];
1181
1125
  } while (ch && ch !== "\n");
1182
1126
  end = i2;
1183
1127
  start = i2 + 1;
1184
- ch = text3[start];
1128
+ ch = text4[start];
1185
1129
  }
1186
1130
  }
1187
1131
  return end;
@@ -7427,92 +7371,319 @@ var require_dist = __commonJS({
7427
7371
  }
7428
7372
  });
7429
7373
 
7430
- // ../core/src/domain/errors.ts
7431
- var CloudhopError = class extends Error {
7432
- constructor(code, message, hint) {
7433
- super(message);
7434
- this.code = code;
7435
- this.hint = hint;
7436
- this.name = "CloudhopError";
7374
+ // ../../node_modules/.pnpm/sisteransi@1.0.5/node_modules/sisteransi/src/index.js
7375
+ var require_src = __commonJS({
7376
+ "../../node_modules/.pnpm/sisteransi@1.0.5/node_modules/sisteransi/src/index.js"(exports, module) {
7377
+ "use strict";
7378
+ var ESC2 = "\x1B";
7379
+ var CSI2 = `${ESC2}[`;
7380
+ var beep = "\x07";
7381
+ var cursor3 = {
7382
+ to(x2, y2) {
7383
+ if (!y2) return `${CSI2}${x2 + 1}G`;
7384
+ return `${CSI2}${y2 + 1};${x2 + 1}H`;
7385
+ },
7386
+ move(x2, y2) {
7387
+ let ret = "";
7388
+ if (x2 < 0) ret += `${CSI2}${-x2}D`;
7389
+ else if (x2 > 0) ret += `${CSI2}${x2}C`;
7390
+ if (y2 < 0) ret += `${CSI2}${-y2}A`;
7391
+ else if (y2 > 0) ret += `${CSI2}${y2}B`;
7392
+ return ret;
7393
+ },
7394
+ up: (count = 1) => `${CSI2}${count}A`,
7395
+ down: (count = 1) => `${CSI2}${count}B`,
7396
+ forward: (count = 1) => `${CSI2}${count}C`,
7397
+ backward: (count = 1) => `${CSI2}${count}D`,
7398
+ nextLine: (count = 1) => `${CSI2}E`.repeat(count),
7399
+ prevLine: (count = 1) => `${CSI2}F`.repeat(count),
7400
+ left: `${CSI2}G`,
7401
+ hide: `${CSI2}?25l`,
7402
+ show: `${CSI2}?25h`,
7403
+ save: `${ESC2}7`,
7404
+ restore: `${ESC2}8`
7405
+ };
7406
+ var scroll = {
7407
+ up: (count = 1) => `${CSI2}S`.repeat(count),
7408
+ down: (count = 1) => `${CSI2}T`.repeat(count)
7409
+ };
7410
+ var erase3 = {
7411
+ screen: `${CSI2}2J`,
7412
+ up: (count = 1) => `${CSI2}1J`.repeat(count),
7413
+ down: (count = 1) => `${CSI2}J`.repeat(count),
7414
+ line: `${CSI2}2K`,
7415
+ lineEnd: `${CSI2}K`,
7416
+ lineStart: `${CSI2}1K`,
7417
+ lines(count) {
7418
+ let clear = "";
7419
+ for (let i2 = 0; i2 < count; i2++)
7420
+ clear += this.line + (i2 < count - 1 ? cursor3.up() : "");
7421
+ if (count)
7422
+ clear += cursor3.left;
7423
+ return clear;
7424
+ }
7425
+ };
7426
+ module.exports = { cursor: cursor3, scroll, erase: erase3, beep };
7437
7427
  }
7438
- code;
7439
- hint;
7440
- };
7428
+ });
7441
7429
 
7442
- // ../core/src/domain/naming.ts
7443
- var RESERVED_NAMES = /* @__PURE__ */ new Set([
7444
- "add",
7445
- "alias",
7446
- "login",
7447
- "region",
7448
- "setup",
7449
- "sync",
7450
- "ls",
7451
- "list",
7452
- "rm",
7453
- "remove",
7454
- "doctor",
7455
- "exec",
7456
- "provider",
7457
- "ui",
7458
- "help",
7459
- "version"
7460
- ]);
7461
- var NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,62}$/;
7462
- function assertValidName(kind, name) {
7463
- if (!NAME_PATTERN.test(name)) {
7464
- throw new CloudhopError(
7465
- "INVALID_NAME",
7466
- `Invalid ${kind} name "${name}".`,
7467
- 'Use lowercase letters, numbers, "-" or "_" (max 63 characters).'
7468
- );
7469
- }
7470
- if (kind === "alias" && RESERVED_NAMES.has(name)) {
7471
- throw new CloudhopError(
7472
- "INVALID_NAME",
7473
- `"${name}" is a cloudhop command.`,
7474
- "Pick another alias name."
7475
- );
7430
+ // ../adapters-node/src/node-browser-opener.ts
7431
+ import { spawn } from "child_process";
7432
+ var NodeBrowserOpener = class {
7433
+ async open(url) {
7434
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
7435
+ await new Promise((resolve) => {
7436
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
7437
+ child.on("error", () => resolve());
7438
+ child.on("spawn", () => {
7439
+ child.unref();
7440
+ resolve();
7441
+ });
7442
+ });
7476
7443
  }
7477
- }
7478
- function suggestAliasName(...parts) {
7479
- const slug = parts.join("-").toLowerCase().replace(/access$/i, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
7480
- return slug || "alias";
7481
- }
7444
+ };
7482
7445
 
7483
- // ../core/src/domain/version.ts
7484
- function compareVersions(a2, b3) {
7485
- const pa = a2.split(".").map((n3) => Number.parseInt(n3, 10) || 0);
7486
- const pb = b3.split(".").map((n3) => Number.parseInt(n3, 10) || 0);
7487
- for (let i2 = 0; i2 < Math.max(pa.length, pb.length); i2++) {
7488
- const diff = (pa[i2] ?? 0) - (pb[i2] ?? 0);
7489
- if (diff !== 0) return diff;
7446
+ // ../adapters-node/src/node-process-runner.ts
7447
+ import { spawn as spawn2 } from "child_process";
7448
+ var STDERR_TAIL_BYTES = 8 * 1024;
7449
+ function buildEnv(overrides) {
7450
+ const env = { ...process.env };
7451
+ for (const [key, value] of Object.entries(overrides ?? {})) {
7452
+ if (value === void 0) delete env[key];
7453
+ else env[key] = value;
7490
7454
  }
7491
- return 0;
7455
+ return env;
7492
7456
  }
7493
-
7494
- // ../core/src/application/cloudhop-app.ts
7495
- var CloudhopApp = class {
7496
- constructor(deps) {
7497
- this.deps = deps;
7498
- }
7499
- deps;
7500
- // ── Providers ────────────────────────────────────────────────────────────
7501
- availableProviders() {
7502
- return this.deps.providers;
7457
+ var useShell = process.platform === "win32";
7458
+ var NodeProcessRunner = class {
7459
+ run(command, args, options = {}) {
7460
+ return new Promise((resolve, reject) => {
7461
+ const child = spawn2(command, args, {
7462
+ env: buildEnv(options.env),
7463
+ stdio: ["inherit", "inherit", options.captureStderr ? "pipe" : "inherit"],
7464
+ shell: useShell
7465
+ });
7466
+ let tail = "";
7467
+ child.stderr?.on("data", (chunk) => {
7468
+ process.stderr.write(chunk);
7469
+ tail = (tail + chunk.toString()).slice(-STDERR_TAIL_BYTES);
7470
+ });
7471
+ const ignore = () => {
7472
+ };
7473
+ process.on("SIGINT", ignore);
7474
+ process.on("SIGTERM", ignore);
7475
+ const cleanup = () => {
7476
+ process.off("SIGINT", ignore);
7477
+ process.off("SIGTERM", ignore);
7478
+ };
7479
+ child.on("error", (error) => {
7480
+ cleanup();
7481
+ reject(error);
7482
+ });
7483
+ child.on("close", (code, signal) => {
7484
+ cleanup();
7485
+ resolve({ exitCode: code ?? (signal ? 128 + 2 : 1), stderrTail: tail });
7486
+ });
7487
+ });
7503
7488
  }
7504
- provider(id) {
7505
- const provider = this.deps.providers.find((p3) => p3.id === id);
7506
- if (!provider) {
7507
- throw new CloudhopError(
7508
- "PROVIDER_NOT_AVAILABLE",
7509
- `The ${id} provider is not available yet.`,
7510
- "Follow https://github.com/dayepesb/cloudhop for upcoming providers."
7511
- );
7512
- }
7513
- return provider;
7489
+ capture(command, args, options = {}) {
7490
+ return new Promise((resolve) => {
7491
+ const child = spawn2(command, args, { env: buildEnv(options.env), shell: useShell });
7492
+ child.stdin.end();
7493
+ let stdout2 = "";
7494
+ let stderr = "";
7495
+ child.stdout.on("data", (c3) => {
7496
+ stdout2 += c3.toString();
7497
+ });
7498
+ child.stderr.on("data", (c3) => {
7499
+ stderr += c3.toString();
7500
+ });
7501
+ child.on("error", (error) => resolve({ exitCode: 127, stdout: stdout2, stderr: error.message }));
7502
+ child.on("close", (code) => resolve({ exitCode: code ?? 1, stdout: stdout2, stderr }));
7503
+ });
7514
7504
  }
7515
- async config() {
7505
+ async probe(command) {
7506
+ const result = await this.capture(command, ["--version"]);
7507
+ if (result.exitCode !== 0) return { installed: false };
7508
+ const output = `${result.stdout} ${result.stderr}`;
7509
+ const version = output.match(/(\d+\.\d+\.\d+)/)?.[1];
7510
+ return { installed: true, ...version ? { version } : {} };
7511
+ }
7512
+ };
7513
+
7514
+ // ../adapters-node/src/system-tool-installer.ts
7515
+ var TOOLS = {
7516
+ aws: {
7517
+ manualUrl: "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html",
7518
+ recipes: {
7519
+ darwin: [{ via: "Homebrew", command: "brew", args: ["install", "awscli"] }],
7520
+ win32: [
7521
+ { via: "winget", command: "winget", args: ["install", "--exact", "--id", "Amazon.AWSCLI"] }
7522
+ ]
7523
+ }
7524
+ },
7525
+ az: {
7526
+ manualUrl: "https://learn.microsoft.com/cli/azure/install-azure-cli",
7527
+ recipes: {
7528
+ darwin: [{ via: "Homebrew", command: "brew", args: ["install", "azure-cli"] }],
7529
+ win32: [
7530
+ {
7531
+ via: "winget",
7532
+ command: "winget",
7533
+ args: ["install", "--exact", "--id", "Microsoft.AzureCLI"]
7534
+ }
7535
+ ]
7536
+ }
7537
+ },
7538
+ gcloud: {
7539
+ manualUrl: "https://cloud.google.com/sdk/docs/install",
7540
+ recipes: {
7541
+ darwin: [
7542
+ { via: "Homebrew", command: "brew", args: ["install", "--cask", "google-cloud-sdk"] }
7543
+ ],
7544
+ win32: [
7545
+ {
7546
+ via: "winget",
7547
+ command: "winget",
7548
+ args: ["install", "--exact", "--id", "Google.CloudSDK"]
7549
+ }
7550
+ ]
7551
+ }
7552
+ }
7553
+ };
7554
+ var SystemToolInstaller = class {
7555
+ constructor(runner, platform = process.platform) {
7556
+ this.runner = runner;
7557
+ this.platform = platform;
7558
+ }
7559
+ runner;
7560
+ platform;
7561
+ available = /* @__PURE__ */ new Map();
7562
+ recipeFor(command) {
7563
+ const tool = TOOLS[command];
7564
+ if (!tool) return void 0;
7565
+ const recipe = tool.recipes[this.platform]?.[0];
7566
+ return recipe ? { ...recipe, manualUrl: tool.manualUrl } : { via: "official installer", command: "", args: [], manualUrl: tool.manualUrl };
7567
+ }
7568
+ async install(recipe) {
7569
+ if (!recipe.command) return false;
7570
+ if (!await this.hasCommand(recipe.command)) return false;
7571
+ const { exitCode } = await this.runner.run(recipe.command, recipe.args);
7572
+ return exitCode === 0;
7573
+ }
7574
+ async hasCommand(command) {
7575
+ if (!this.available.has(command)) {
7576
+ this.available.set(command, (await this.runner.probe(command)).installed);
7577
+ }
7578
+ return this.available.get(command) ?? false;
7579
+ }
7580
+ };
7581
+
7582
+ // ../adapters-node/src/yaml-config-store.ts
7583
+ import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
7584
+ import { homedir } from "os";
7585
+ import { join } from "path";
7586
+
7587
+ // ../core/src/domain/errors.ts
7588
+ var CloudhopError = class extends Error {
7589
+ constructor(code, message, hint) {
7590
+ super(message);
7591
+ this.code = code;
7592
+ this.hint = hint;
7593
+ this.name = "CloudhopError";
7594
+ }
7595
+ code;
7596
+ hint;
7597
+ };
7598
+
7599
+ // ../core/src/domain/naming.ts
7600
+ var RESERVED_NAMES = /* @__PURE__ */ new Set([
7601
+ "add",
7602
+ "agent",
7603
+ "alias",
7604
+ "login",
7605
+ "mcp",
7606
+ "region",
7607
+ "setup",
7608
+ "status",
7609
+ "sync",
7610
+ "ls",
7611
+ "list",
7612
+ "rm",
7613
+ "remove",
7614
+ "doctor",
7615
+ "exec",
7616
+ "provider",
7617
+ "ui",
7618
+ "help",
7619
+ "version"
7620
+ ]);
7621
+ var NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,62}$/;
7622
+ function assertValidName(kind, name) {
7623
+ if (!NAME_PATTERN.test(name)) {
7624
+ throw new CloudhopError(
7625
+ "INVALID_NAME",
7626
+ `Invalid ${kind} name "${name}".`,
7627
+ 'Use lowercase letters, numbers, "-" or "_" (max 63 characters).'
7628
+ );
7629
+ }
7630
+ if (kind === "alias" && RESERVED_NAMES.has(name)) {
7631
+ throw new CloudhopError(
7632
+ "INVALID_NAME",
7633
+ `"${name}" is a cloudhop command.`,
7634
+ "Pick another alias name."
7635
+ );
7636
+ }
7637
+ }
7638
+ function suggestAliasName(...parts) {
7639
+ const slug = parts.join("-").toLowerCase().replace(/access$/i, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
7640
+ return slug || "alias";
7641
+ }
7642
+
7643
+ // ../core/src/domain/version.ts
7644
+ function compareVersions(a2, b3) {
7645
+ const pa = a2.split(".").map((n3) => Number.parseInt(n3, 10) || 0);
7646
+ const pb = b3.split(".").map((n3) => Number.parseInt(n3, 10) || 0);
7647
+ for (let i2 = 0; i2 < Math.max(pa.length, pb.length); i2++) {
7648
+ const diff = (pa[i2] ?? 0) - (pb[i2] ?? 0);
7649
+ if (diff !== 0) return diff;
7650
+ }
7651
+ return 0;
7652
+ }
7653
+
7654
+ // ../core/src/application/cloudhop-app.ts
7655
+ var loginRequired = (sourceId) => new CloudhopError(
7656
+ "LOGIN_REQUIRED",
7657
+ `The session of "${sourceId}" expired and needs a browser sign-in.`,
7658
+ `Run \`cloudhop login ${sourceId}\` (it opens the browser), then try again.`
7659
+ );
7660
+ var NEXT_BY_STATE = {
7661
+ valid: "ready",
7662
+ refreshable: "silent-renewal",
7663
+ expired: "browser-sign-in",
7664
+ unknown: "unknown"
7665
+ };
7666
+ var CloudhopApp = class {
7667
+ constructor(deps) {
7668
+ this.deps = deps;
7669
+ }
7670
+ deps;
7671
+ // ── Providers ────────────────────────────────────────────────────────────
7672
+ availableProviders() {
7673
+ return this.deps.providers;
7674
+ }
7675
+ provider(id) {
7676
+ const provider = this.deps.providers.find((p3) => p3.id === id);
7677
+ if (!provider) {
7678
+ throw new CloudhopError(
7679
+ "PROVIDER_NOT_AVAILABLE",
7680
+ `The ${id} provider is not available yet.`,
7681
+ "Follow https://github.com/dayepesb/cloudhop for upcoming providers."
7682
+ );
7683
+ }
7684
+ return provider;
7685
+ }
7686
+ async config() {
7516
7687
  return this.deps.config.load();
7517
7688
  }
7518
7689
  async setEnabledProviders(ids) {
@@ -7591,13 +7762,19 @@ var CloudhopApp = class {
7591
7762
  return this.syncSource(sourceId);
7592
7763
  }
7593
7764
  /** Makes sure the source has a usable session, renewing it silently or via login. */
7594
- async ensureSession(sourceId) {
7765
+ /**
7766
+ * Makes the source's session usable: renews it silently when possible, and
7767
+ * otherwise signs in through the browser, unless `interactive` is false, in
7768
+ * which case it fails with LOGIN_REQUIRED instead of opening a browser.
7769
+ */
7770
+ async ensureSession(sourceId, { interactive: interactive2 = true } = {}) {
7595
7771
  const config = await this.deps.config.load();
7596
7772
  const source = this.findSource(config, sourceId);
7597
7773
  const provider = this.provider(source.provider);
7598
7774
  const state = await provider.sessionState(source);
7599
7775
  if (state === "valid" || state === "unknown") return "none";
7600
7776
  if (state === "refreshable" && await provider.refreshSession(source)) return "refreshed";
7777
+ if (!interactive2) throw loginRequired(sourceId);
7601
7778
  await this.login(sourceId);
7602
7779
  return "logged-in";
7603
7780
  }
@@ -7661,6 +7838,56 @@ var CloudhopApp = class {
7661
7838
  const config = await this.deps.config.load();
7662
7839
  return Promise.all(config.aliases.map((a2) => this.resolve(config, a2)));
7663
7840
  }
7841
+ /** Sources with their session state and every alias, checked without signing in. */
7842
+ async overview() {
7843
+ const config = await this.deps.config.load();
7844
+ const sources = await Promise.all(
7845
+ config.sources.map(async (source) => {
7846
+ const session = await this.provider(source.provider).sessionState(source);
7847
+ return {
7848
+ id: source.id,
7849
+ provider: source.provider,
7850
+ kind: source.kind,
7851
+ settings: source.settings,
7852
+ session,
7853
+ next: NEXT_BY_STATE[session]
7854
+ };
7855
+ })
7856
+ );
7857
+ const bySource = new Map(sources.map((s) => [s.id, s]));
7858
+ const aliases = await Promise.all(
7859
+ config.aliases.map(async (alias) => {
7860
+ const resolved = await this.resolve(config, alias);
7861
+ const source = bySource.get(alias.source);
7862
+ return {
7863
+ name: alias.name,
7864
+ source: alias.source,
7865
+ provider: resolved.source.provider,
7866
+ target: alias.target,
7867
+ label: resolved.target?.label,
7868
+ available: Boolean(resolved.target),
7869
+ region: resolved.region,
7870
+ attributes: resolved.target?.attributes ?? {},
7871
+ session: source.session,
7872
+ next: source.next
7873
+ };
7874
+ })
7875
+ );
7876
+ return { sources, aliases };
7877
+ }
7878
+ /** The status of one alias: which account it points to and whether it can run right away. */
7879
+ async aliasStatus(name) {
7880
+ const { aliases } = await this.overview();
7881
+ const status = aliases.find((a2) => a2.name === name);
7882
+ if (!status) {
7883
+ throw new CloudhopError(
7884
+ "ALIAS_NOT_FOUND",
7885
+ `Alias "${name}" not found.`,
7886
+ "See your aliases with `cloudhop ls`."
7887
+ );
7888
+ }
7889
+ return status;
7890
+ }
7664
7891
  /** Changes an alias' region; matching the source's region clears the override. */
7665
7892
  async setAliasRegion(name, region) {
7666
7893
  const config = await this.deps.config.load();
@@ -7685,6 +7912,28 @@ var CloudhopApp = class {
7685
7912
  return this.runWithSession(resolved, provider, "cli", provider.cli.command, args);
7686
7913
  }
7687
7914
  /** Runs any command with the alias' temporary credentials in its environment. */
7915
+ /**
7916
+ * Runs the alias' cloud CLI without a terminal and returns what it printed.
7917
+ * Used where stdin/stdout belong to someone else (the MCP server). It never
7918
+ * opens a browser: an expired session fails with LOGIN_REQUIRED.
7919
+ */
7920
+ async captureAsAlias(name, args, overrides = {}) {
7921
+ const resolved = await this.usableAlias(name, overrides);
7922
+ const provider = this.provider(resolved.source.provider);
7923
+ await this.requireCli(provider);
7924
+ await this.ensureSession(resolved.source.id, { interactive: false });
7925
+ const env = await provider.processEnv(resolved, "cli");
7926
+ const result = await this.deps.runner.capture(provider.cli.command, args, { env });
7927
+ if (result.exitCode !== 0 && provider.isSessionError(`${result.stdout}
7928
+ ${result.stderr}`)) {
7929
+ throw loginRequired(resolved.source.id);
7930
+ }
7931
+ return result;
7932
+ }
7933
+ /** The command that shows who an alias is, e.g. `aws sts get-caller-identity`. */
7934
+ identityArgs(name) {
7935
+ return this.resolveAlias(name).then((r2) => this.provider(r2.source.provider).identityArgs);
7936
+ }
7688
7937
  async execAsAlias(name, command, args, overrides = {}) {
7689
7938
  const resolved = await this.usableAlias(name, overrides);
7690
7939
  const provider = this.provider(resolved.source.provider);
@@ -7819,62 +8068,397 @@ var CloudhopApp = class {
7819
8068
  var PROVIDER_IDS = ["aws", "azure", "gcp"];
7820
8069
  var emptyConfig = () => ({ providers: [], sources: [], aliases: [] });
7821
8070
 
7822
- // src/flags.ts
7823
- var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["yes", "all", "list", "noOpen"]);
7824
- var camel = (name) => name.replace(/-([a-z])/g, (_2, c3) => c3.toUpperCase());
7825
- function parseArgs(argv) {
7826
- const positional = [];
7827
- const flags = {};
7828
- for (let i2 = 0; i2 < argv.length; i2++) {
7829
- const arg = argv[i2];
7830
- if (!arg.startsWith("--")) {
7831
- positional.push(arg);
7832
- continue;
7833
- }
7834
- const [rawName, inline] = arg.slice(2).split(/=(.*)/s, 2);
7835
- const name = camel(rawName);
7836
- if (inline !== void 0) flags[name] = inline;
7837
- else if (BOOLEAN_FLAGS.has(name)) flags[name] = "true";
7838
- else flags[name] = argv[++i2] ?? "";
7839
- }
7840
- return { positional, flags };
8071
+ // ../adapters-node/src/yaml-config-store.ts
8072
+ var import_yaml = __toESM(require_dist(), 1);
8073
+ function defaultConfigDir(env = process.env) {
8074
+ if (env.CLOUDHOP_HOME) return env.CLOUDHOP_HOME;
8075
+ return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "cloudhop");
7841
8076
  }
7842
- var interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
7843
-
7844
- // ../../node_modules/.pnpm/@clack+prompts@1.8.1/node_modules/@clack/prompts/dist/index.mjs
7845
- var dist_exports = {};
7846
- __export(dist_exports, {
7847
- CANCEL_SYMBOL: () => CANCEL_SYMBOL,
7848
- MULTISELECT_INSTRUCTIONS: () => MULTISELECT_INSTRUCTIONS,
7849
- SELECT_INSTRUCTIONS: () => SELECT_INSTRUCTIONS,
7850
- S_BAR: () => S_BAR,
7851
- S_BAR_END: () => S_BAR_END,
7852
- S_BAR_END_RIGHT: () => S_BAR_END_RIGHT,
7853
- S_BAR_H: () => S_BAR_H,
7854
- S_BAR_START: () => S_BAR_START,
7855
- S_BAR_START_RIGHT: () => S_BAR_START_RIGHT,
7856
- S_CHECKBOX_ACTIVE: () => S_CHECKBOX_ACTIVE,
7857
- S_CHECKBOX_INACTIVE: () => S_CHECKBOX_INACTIVE,
7858
- S_CHECKBOX_SELECTED: () => S_CHECKBOX_SELECTED,
7859
- S_CONNECT_LEFT: () => S_CONNECT_LEFT,
7860
- S_CORNER_BOTTOM_LEFT: () => S_CORNER_BOTTOM_LEFT,
7861
- S_CORNER_BOTTOM_RIGHT: () => S_CORNER_BOTTOM_RIGHT,
7862
- S_CORNER_TOP_LEFT: () => S_CORNER_TOP_LEFT,
7863
- S_CORNER_TOP_RIGHT: () => S_CORNER_TOP_RIGHT,
7864
- S_ERROR: () => S_ERROR,
7865
- S_INFO: () => S_INFO,
7866
- S_PASSWORD_MASK: () => S_PASSWORD_MASK,
7867
- S_RADIO_ACTIVE: () => S_RADIO_ACTIVE,
7868
- S_RADIO_INACTIVE: () => S_RADIO_INACTIVE,
7869
- S_STEP_ACTIVE: () => S_STEP_ACTIVE,
7870
- S_STEP_CANCEL: () => S_STEP_CANCEL,
7871
- S_STEP_ERROR: () => S_STEP_ERROR,
7872
- S_STEP_SUBMIT: () => S_STEP_SUBMIT,
7873
- S_SUCCESS: () => S_SUCCESS,
7874
- S_WARN: () => S_WARN,
7875
- autocomplete: () => autocomplete,
7876
- autocompleteMultiselect: () => autocompleteMultiselect,
7877
- box: () => box,
8077
+ var HEADER = "# cloudhop configuration. Contains no secrets.\n# Docs: https://github.com/dayepesb/cloudhop\n";
8078
+ var YamlConfigStore = class {
8079
+ constructor(dir = defaultConfigDir()) {
8080
+ this.dir = dir;
8081
+ }
8082
+ dir;
8083
+ get configPath() {
8084
+ return join(this.dir, "config.yaml");
8085
+ }
8086
+ get targetsPath() {
8087
+ return join(this.dir, "targets.json");
8088
+ }
8089
+ async load() {
8090
+ const raw = await this.read(this.configPath);
8091
+ if (!raw) return emptyConfig();
8092
+ const file = (0, import_yaml.parse)(raw) ?? {};
8093
+ return {
8094
+ providers: (file.providers ?? []).filter(
8095
+ (p3) => PROVIDER_IDS.includes(p3)
8096
+ ),
8097
+ sources: Object.entries(file.sources ?? {}).map(([id, s]) => ({
8098
+ id,
8099
+ ...s,
8100
+ settings: s.settings ?? {}
8101
+ })),
8102
+ aliases: Object.entries(file.aliases ?? {}).map(([name, a2]) => ({ name, ...a2 }))
8103
+ };
8104
+ }
8105
+ async save(config) {
8106
+ const file = {
8107
+ providers: config.providers,
8108
+ sources: Object.fromEntries(config.sources.map(({ id, ...rest }) => [id, rest])),
8109
+ aliases: Object.fromEntries(config.aliases.map(({ name, ...rest }) => [name, rest]))
8110
+ };
8111
+ await this.write(this.configPath, HEADER + (0, import_yaml.stringify)(file));
8112
+ }
8113
+ async loadTargets(sourceId) {
8114
+ return (await this.allTargets())[sourceId] ?? [];
8115
+ }
8116
+ async saveTargets(sourceId, targets) {
8117
+ const all = await this.allTargets();
8118
+ all[sourceId] = targets;
8119
+ await this.write(this.targetsPath, JSON.stringify(all, null, 2));
8120
+ }
8121
+ async removeTargets(sourceId) {
8122
+ const all = await this.allTargets();
8123
+ delete all[sourceId];
8124
+ await this.write(this.targetsPath, JSON.stringify(all, null, 2));
8125
+ }
8126
+ async reset() {
8127
+ await rm(this.dir, { recursive: true, force: true });
8128
+ }
8129
+ async allTargets() {
8130
+ const raw = await this.read(this.targetsPath);
8131
+ return raw ? JSON.parse(raw) : {};
8132
+ }
8133
+ async read(path2) {
8134
+ try {
8135
+ return await readFile(path2, "utf8");
8136
+ } catch (error) {
8137
+ if (error.code === "ENOENT") return void 0;
8138
+ throw error;
8139
+ }
8140
+ }
8141
+ /** Writes through a temp file so a crash never leaves a half-written config. */
8142
+ async write(path2, content) {
8143
+ await mkdir(this.dir, { recursive: true, mode: 448 });
8144
+ const tmp = `${path2}.${process.pid}.tmp`;
8145
+ await writeFile(tmp, content, { mode: 384 });
8146
+ await rename(tmp, path2);
8147
+ }
8148
+ };
8149
+
8150
+ // ../mcp/src/server.ts
8151
+ import { createInterface } from "readline";
8152
+
8153
+ // ../mcp/src/tools.ts
8154
+ var MAX_OUTPUT = 1e5;
8155
+ var cap = (text4) => text4.length > MAX_OUTPUT ? `${text4.slice(0, MAX_OUTPUT)}
8156
+ \u2026 [truncated: ${text4.length - MAX_OUTPUT} more characters. Narrow the command with --query or filters.]` : text4;
8157
+ var WINDOWS_SHELL_CHARS = /[&|<>^%!"\r\n]/;
8158
+ var text = (args, key) => {
8159
+ const value = args[key];
8160
+ if (typeof value !== "string" || !value.trim()) {
8161
+ throw new CloudhopError("INVALID_NAME", `"${key}" is required.`);
8162
+ }
8163
+ return value.trim();
8164
+ };
8165
+ var stringArray = (args, key) => {
8166
+ const value = args[key];
8167
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
8168
+ throw new CloudhopError("INVALID_NAME", `"${key}" must be an array of strings.`);
8169
+ }
8170
+ return value;
8171
+ };
8172
+ var aliasInput = {
8173
+ alias: {
8174
+ type: "string",
8175
+ description: "The cloudhop alias to use, as listed by list_aliases. Ask the user which one."
8176
+ }
8177
+ };
8178
+ function createTools(app) {
8179
+ return [
8180
+ {
8181
+ name: "list_aliases",
8182
+ title: "List cloudhop aliases",
8183
+ description: "Lists every cloudhop source (SSO portal or personal account) with its session state, and every alias: name, account/role label, region and whether a sign-in is needed. Call this first, then ask the user which alias to work with. Never contains secrets.",
8184
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
8185
+ annotations: { readOnlyHint: true, openWorldHint: false },
8186
+ run: () => app.overview()
8187
+ },
8188
+ {
8189
+ name: "alias_status",
8190
+ title: "Check an alias",
8191
+ description: 'Shows which account, role and region an alias points to and whether it can run now. `next` is "ready", "silent-renewal" (runs without asking), "browser-sign-in" (call login first, or ask the user to run `cloudhop login <source>`) or "unknown".',
8192
+ inputSchema: {
8193
+ type: "object",
8194
+ properties: aliasInput,
8195
+ required: ["alias"],
8196
+ additionalProperties: false
8197
+ },
8198
+ annotations: { readOnlyHint: true, openWorldHint: false },
8199
+ run: (args) => app.aliasStatus(text(args, "alias"))
8200
+ },
8201
+ {
8202
+ name: "whoami",
8203
+ title: "Who am I in this alias",
8204
+ description: "Asks the cloud who the alias really is (for AWS: `aws sts get-caller-identity`). Use it to confirm the account before doing anything important.",
8205
+ inputSchema: {
8206
+ type: "object",
8207
+ properties: aliasInput,
8208
+ required: ["alias"],
8209
+ additionalProperties: false
8210
+ },
8211
+ annotations: { readOnlyHint: true, openWorldHint: true },
8212
+ run: async (args) => {
8213
+ const alias = text(args, "alias");
8214
+ const result = await app.captureAsAlias(alias, await app.identityArgs(alias));
8215
+ return {
8216
+ alias,
8217
+ exitCode: result.exitCode,
8218
+ output: cap(result.stdout),
8219
+ errors: cap(result.stderr)
8220
+ };
8221
+ }
8222
+ },
8223
+ {
8224
+ name: "login",
8225
+ title: "Sign in to a source",
8226
+ description: "Opens the user's browser to sign in to a source (an SSO portal or a personal account) and waits up to 5 minutes for them to approve, then refreshes its accounts and roles. Tell the user to look at their browser before calling it. Only call it when alias_status says `browser-sign-in` or a tool returned LOGIN_REQUIRED.",
8227
+ inputSchema: {
8228
+ type: "object",
8229
+ properties: {
8230
+ source: { type: "string", description: "The source id, as shown in list_aliases." }
8231
+ },
8232
+ required: ["source"],
8233
+ additionalProperties: false
8234
+ },
8235
+ annotations: {
8236
+ readOnlyHint: false,
8237
+ destructiveHint: false,
8238
+ idempotentHint: true,
8239
+ openWorldHint: true
8240
+ },
8241
+ run: async (args) => {
8242
+ const source = text(args, "source");
8243
+ const report = await app.login(source);
8244
+ return { source, signedIn: true, targets: report.targets.length };
8245
+ }
8246
+ },
8247
+ {
8248
+ name: "run_cloud_cli",
8249
+ title: "Run the cloud CLI as an alias",
8250
+ description: 'Runs the alias\' cloud CLI (`aws` for AWS aliases) with the alias\' account, role and region, like `hop <alias> <args>`. `args` are the CLI arguments without the program name and without --profile, e.g. ["s3", "ls"]. Returns stdout, stderr and the exit code. It can create, change or delete cloud resources: before any command that is not read-only, show the user the alias, account, region and exact command and get their explicit confirmation.',
8251
+ inputSchema: {
8252
+ type: "object",
8253
+ properties: {
8254
+ ...aliasInput,
8255
+ args: {
8256
+ type: "array",
8257
+ items: { type: "string" },
8258
+ description: 'CLI arguments, e.g. ["ec2", "describe-instances", "--output", "json"].'
8259
+ },
8260
+ region: {
8261
+ type: "string",
8262
+ description: "Optional region for this call only; defaults to the alias' region."
8263
+ }
8264
+ },
8265
+ required: ["alias", "args"],
8266
+ additionalProperties: false
8267
+ },
8268
+ annotations: {
8269
+ readOnlyHint: false,
8270
+ destructiveHint: true,
8271
+ idempotentHint: false,
8272
+ openWorldHint: true
8273
+ },
8274
+ run: async (args) => {
8275
+ const alias = text(args, "alias");
8276
+ const cliArgs = stringArray(args, "args");
8277
+ if (cliArgs.includes("--profile")) {
8278
+ throw new CloudhopError(
8279
+ "INVALID_NAME",
8280
+ "Do not pass --profile: the alias already chooses the account and role."
8281
+ );
8282
+ }
8283
+ if (process.platform === "win32" && cliArgs.some((a2) => WINDOWS_SHELL_CHARS.test(a2))) {
8284
+ throw new CloudhopError(
8285
+ "INVALID_NAME",
8286
+ 'On Windows, arguments cannot contain & | < > ^ % ! " or line breaks.',
8287
+ "Ask the user to run this command in their terminal with hop instead."
8288
+ );
8289
+ }
8290
+ const region = typeof args.region === "string" && args.region.trim() ? args.region.trim() : void 0;
8291
+ const result = await app.captureAsAlias(alias, cliArgs, region ? { region } : {});
8292
+ return {
8293
+ alias,
8294
+ exitCode: result.exitCode,
8295
+ stdout: cap(result.stdout),
8296
+ stderr: cap(result.stderr)
8297
+ };
8298
+ }
8299
+ }
8300
+ ];
8301
+ }
8302
+
8303
+ // ../mcp/src/server.ts
8304
+ var MCP_INSTRUCTIONS = `cloudhop gives each cloud account + role + region a short alias and signs the user in only when a session expired.
8305
+ Before the first cloud operation in a conversation: call list_aliases, show the user the aliases (name, account/role, region) and ask which one to use. Never guess the alias.
8306
+ Use run_cloud_cli for cloud CLI commands, never --profile. Read-only commands may run once the alias is chosen; before anything that creates, changes or deletes resources, show the alias, account, region and exact command, and wait for the user's explicit OK. Be extra careful with production aliases.
8307
+ If a tool returns LOGIN_REQUIRED or alias_status says browser-sign-in: tell the user their browser will open, then call login (it waits up to 5 minutes for them to approve). Never print or ask for credentials.`;
8308
+ var SUPPORTED_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
8309
+ function serveMcp({
8310
+ app,
8311
+ version,
8312
+ input = process.stdin,
8313
+ output = process.stdout
8314
+ }) {
8315
+ const tools = createTools(app);
8316
+ const byName = new Map(tools.map((t2) => [t2.name, t2]));
8317
+ const send2 = (message) => output.write(`${JSON.stringify(message)}
8318
+ `);
8319
+ const reply = (id, result) => send2({ jsonrpc: "2.0", id, result });
8320
+ const fail = (id, code, message) => send2({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
8321
+ const handle = async (request) => {
8322
+ const { id, method, params = {} } = request;
8323
+ const isNotification = id === void 0;
8324
+ switch (method) {
8325
+ case "initialize": {
8326
+ const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
8327
+ reply(id, {
8328
+ protocolVersion: SUPPORTED_VERSIONS.includes(asked) ? asked : SUPPORTED_VERSIONS[0],
8329
+ capabilities: { tools: { listChanged: false } },
8330
+ serverInfo: { name: "cloudhop", title: "cloudhop", version },
8331
+ instructions: MCP_INSTRUCTIONS
8332
+ });
8333
+ return;
8334
+ }
8335
+ case "ping":
8336
+ reply(id, {});
8337
+ return;
8338
+ case "tools/list":
8339
+ reply(id, {
8340
+ tools: tools.map(({ name, title, description, inputSchema, annotations }) => ({
8341
+ name,
8342
+ title,
8343
+ description,
8344
+ inputSchema,
8345
+ annotations
8346
+ }))
8347
+ });
8348
+ return;
8349
+ case "tools/call": {
8350
+ const tool = byName.get(String(params.name));
8351
+ if (!tool) {
8352
+ fail(id, -32602, `Unknown tool: ${String(params.name)}`);
8353
+ return;
8354
+ }
8355
+ const args = params.arguments ?? {};
8356
+ try {
8357
+ const result = await tool.run(args);
8358
+ reply(id, {
8359
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
8360
+ structuredContent: result
8361
+ });
8362
+ } catch (error) {
8363
+ const known = error instanceof CloudhopError;
8364
+ const body = {
8365
+ error: known ? error.code : "UNEXPECTED",
8366
+ message: error.message,
8367
+ ...known && error.hint ? { hint: error.hint } : {}
8368
+ };
8369
+ reply(id, {
8370
+ content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
8371
+ structuredContent: body,
8372
+ isError: true
8373
+ });
8374
+ }
8375
+ return;
8376
+ }
8377
+ default:
8378
+ if (!isNotification) fail(id, -32601, `Method not found: ${method}`);
8379
+ }
8380
+ };
8381
+ return new Promise((resolve) => {
8382
+ const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
8383
+ const pending = /* @__PURE__ */ new Set();
8384
+ lines.on("line", (line) => {
8385
+ if (!line.trim()) return;
8386
+ let request;
8387
+ try {
8388
+ request = JSON.parse(line);
8389
+ } catch {
8390
+ fail(null, -32700, "Parse error");
8391
+ return;
8392
+ }
8393
+ if (typeof request.method !== "string") return;
8394
+ const task = handle(request).catch((error) => {
8395
+ if (request.id !== void 0) fail(request.id, -32603, error.message);
8396
+ });
8397
+ pending.add(task);
8398
+ task.finally(() => pending.delete(task));
8399
+ });
8400
+ lines.on("close", () => {
8401
+ Promise.allSettled([...pending]).then(() => resolve());
8402
+ });
8403
+ });
8404
+ }
8405
+
8406
+ // src/flags.ts
8407
+ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["yes", "all", "list", "noOpen", "json", "noMcp", "force"]);
8408
+ var camel = (name) => name.replace(/-([a-z])/g, (_2, c3) => c3.toUpperCase());
8409
+ function parseArgs(argv) {
8410
+ const positional = [];
8411
+ const flags = {};
8412
+ for (let i2 = 0; i2 < argv.length; i2++) {
8413
+ const arg = argv[i2];
8414
+ if (!arg.startsWith("--")) {
8415
+ positional.push(arg);
8416
+ continue;
8417
+ }
8418
+ const [rawName, inline] = arg.slice(2).split(/=(.*)/s, 2);
8419
+ const name = camel(rawName);
8420
+ if (inline !== void 0) flags[name] = inline;
8421
+ else if (BOOLEAN_FLAGS.has(name)) flags[name] = "true";
8422
+ else flags[name] = argv[++i2] ?? "";
8423
+ }
8424
+ return { positional, flags };
8425
+ }
8426
+ var interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
8427
+
8428
+ // ../../node_modules/.pnpm/@clack+prompts@1.8.1/node_modules/@clack/prompts/dist/index.mjs
8429
+ var dist_exports = {};
8430
+ __export(dist_exports, {
8431
+ CANCEL_SYMBOL: () => CANCEL_SYMBOL,
8432
+ MULTISELECT_INSTRUCTIONS: () => MULTISELECT_INSTRUCTIONS,
8433
+ SELECT_INSTRUCTIONS: () => SELECT_INSTRUCTIONS,
8434
+ S_BAR: () => S_BAR,
8435
+ S_BAR_END: () => S_BAR_END,
8436
+ S_BAR_END_RIGHT: () => S_BAR_END_RIGHT,
8437
+ S_BAR_H: () => S_BAR_H,
8438
+ S_BAR_START: () => S_BAR_START,
8439
+ S_BAR_START_RIGHT: () => S_BAR_START_RIGHT,
8440
+ S_CHECKBOX_ACTIVE: () => S_CHECKBOX_ACTIVE,
8441
+ S_CHECKBOX_INACTIVE: () => S_CHECKBOX_INACTIVE,
8442
+ S_CHECKBOX_SELECTED: () => S_CHECKBOX_SELECTED,
8443
+ S_CONNECT_LEFT: () => S_CONNECT_LEFT,
8444
+ S_CORNER_BOTTOM_LEFT: () => S_CORNER_BOTTOM_LEFT,
8445
+ S_CORNER_BOTTOM_RIGHT: () => S_CORNER_BOTTOM_RIGHT,
8446
+ S_CORNER_TOP_LEFT: () => S_CORNER_TOP_LEFT,
8447
+ S_CORNER_TOP_RIGHT: () => S_CORNER_TOP_RIGHT,
8448
+ S_ERROR: () => S_ERROR,
8449
+ S_INFO: () => S_INFO,
8450
+ S_PASSWORD_MASK: () => S_PASSWORD_MASK,
8451
+ S_RADIO_ACTIVE: () => S_RADIO_ACTIVE,
8452
+ S_RADIO_INACTIVE: () => S_RADIO_INACTIVE,
8453
+ S_STEP_ACTIVE: () => S_STEP_ACTIVE,
8454
+ S_STEP_CANCEL: () => S_STEP_CANCEL,
8455
+ S_STEP_ERROR: () => S_STEP_ERROR,
8456
+ S_STEP_SUBMIT: () => S_STEP_SUBMIT,
8457
+ S_SUCCESS: () => S_SUCCESS,
8458
+ S_WARN: () => S_WARN,
8459
+ autocomplete: () => autocomplete,
8460
+ autocompleteMultiselect: () => autocompleteMultiselect,
8461
+ box: () => box,
7878
8462
  cancel: () => cancel,
7879
8463
  confirm: () => confirm,
7880
8464
  date: () => date,
@@ -7903,7 +8487,7 @@ __export(dist_exports, {
7903
8487
  symbolBar: () => symbolBar,
7904
8488
  taskLog: () => taskLog,
7905
8489
  tasks: () => tasks,
7906
- text: () => text,
8490
+ text: () => text2,
7907
8491
  unicode: () => unicode,
7908
8492
  unicodeOr: () => unicodeOr,
7909
8493
  updateSettings: () => updateSettings
@@ -9240,7 +9824,7 @@ import { styleText as styleText2, stripVTControlCharacters } from "util";
9240
9824
  import process$1 from "process";
9241
9825
  var import_sisteransi2 = __toESM(require_src(), 1);
9242
9826
  import { existsSync, lstatSync, readdirSync } from "fs";
9243
- import { dirname, join } from "path";
9827
+ import { dirname, join as join2 } from "path";
9244
9828
  function isUnicodeSupported() {
9245
9829
  if (process$1.platform !== "win32") {
9246
9830
  return process$1.env.TERM !== "linux";
@@ -10181,7 +10765,7 @@ var path = (e) => {
10181
10765
  existsSync(t2) ? lstatSync(t2).isDirectory() && (!e.directory || t2.endsWith("/")) ? i2 = t2 : i2 = dirname(t2) : i2 = dirname(t2);
10182
10766
  const c3 = t2.length > 1 && t2.endsWith("/") ? t2.slice(0, -1) : t2;
10183
10767
  return readdirSync(i2).map((r2) => {
10184
- const n3 = join(i2, r2), m3 = lstatSync(n3);
10768
+ const n3 = join2(i2, r2), m3 = lstatSync(n3);
10185
10769
  return {
10186
10770
  name: r2,
10187
10771
  path: n3,
@@ -10608,7 +11192,7 @@ ${e.value}` : e.value;
10608
11192
  }
10609
11193
  };
10610
11194
  };
10611
- var text = (t2) => new n2({
11195
+ var text2 = (t2) => new n2({
10612
11196
  validate: t2.validate,
10613
11197
  placeholder: t2.placeholder,
10614
11198
  defaultValue: t2.defaultValue,
@@ -10665,8 +11249,8 @@ function answer(value) {
10665
11249
  }
10666
11250
  var colorful = (stream2) => Boolean(stream2.isTTY) && !process.env.NO_COLOR;
10667
11251
  var truecolor = /truecolor|24bit/i.test(process.env.COLORTERM ?? "");
10668
- var dim = (text3) => colorful(process.stderr) ? `\x1B[2m${text3}\x1B[22m` : text3;
10669
- var bold = (text3) => colorful(process.stdout) ? `\x1B[1m${text3}\x1B[22m` : text3;
11252
+ var dim = (text4) => colorful(process.stderr) ? `\x1B[2m${text4}\x1B[22m` : text4;
11253
+ var bold = (text4) => colorful(process.stdout) ? `\x1B[1m${text4}\x1B[22m` : text4;
10670
11254
  var STYLES = {
10671
11255
  dim: ["2", "22"],
10672
11256
  green: ["32", "39"],
@@ -10675,7 +11259,7 @@ var STYLES = {
10675
11259
  sky: [truecolor ? "38;2;59;184;245" : "38;5;75", "39"],
10676
11260
  coral: [truecolor ? "38;2;255;122;89" : "38;5;209", "39"]
10677
11261
  };
10678
- var paint = (style, text3) => colorful(process.stdout) ? `\x1B[${STYLES[style][0]}m${text3}\x1B[${STYLES[style][1]}m` : text3;
11262
+ var paint = (style, text4) => colorful(process.stdout) ? `\x1B[${STYLES[style][0]}m${text4}\x1B[${STYLES[style][1]}m` : text4;
10679
11263
  var BANNER = [
10680
11264
  " _ _ _",
10681
11265
  " ___ | | ___ _ _ __| || |__ ___ _ __",
@@ -10922,50 +11506,578 @@ async function aliasCommand(app, { positional, flags }) {
10922
11506
  dist_exports.outro(`Created ${aliases.length} alias(es).`);
10923
11507
  }
10924
11508
 
10925
- // src/commands/manage.ts
10926
- function printReport(report) {
10927
- const changes = [
10928
- `${report.targets.length} available`,
10929
- report.added.length ? `${report.added.length} new` : "",
10930
- report.removed.length ? `${report.removed.length} removed` : ""
10931
- ].filter(Boolean);
10932
- dist_exports.log.success(`${report.sourceId}: ${changes.join(", ")}.`);
10933
- for (const t2 of report.added) dist_exports.log.message(` + ${t2.label}`);
10934
- for (const t2 of report.removed) dist_exports.log.message(` - ${t2.label}`);
10935
- if (report.orphanedAliases.length) {
10936
- dist_exports.log.warn(
10937
- `No longer available: ${report.orphanedAliases.join(", ")}. Remove them with \`cloudhop rm <alias>\`.`
10938
- );
11509
+ // src/commands/agent.ts
11510
+ import { homedir as homedir2 } from "os";
11511
+ import { isAbsolute, relative } from "path";
11512
+
11513
+ // ../assistants/src/installer.ts
11514
+ import { createHash } from "crypto";
11515
+ import { existsSync as existsSync2 } from "fs";
11516
+ import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, rmdir, writeFile as writeFile2 } from "fs/promises";
11517
+ import { dirname as dirname2, join as join4 } from "path";
11518
+
11519
+ // ../assistants/src/assets/skill.md
11520
+ var skill_default = "---\nname: cloudhop\ndescription: Run AWS CLI commands and other cloud tools as a specific account, role and region through cloudhop aliases (the `hop` and `cloudhop` commands). Use whenever the user asks to do anything in their AWS accounts, inspect or change cloud resources, run aws/terraform/cdk against an account, list their cloud accounts or aliases, or mentions hop or cloudhop.\n---\n\n# Working with cloud accounts through cloudhop\n\nThe user manages their cloud access with **cloudhop**. Each **alias** (for\nexample `acme-dev`) points to one account, one role and one region. cloudhop\nsigns the user in through their browser only when a session has expired. There\nare no access keys: never ask for, create, print or store credentials.\n\n## 1. Pick the alias with the user, every time\n\nBefore the first cloud operation in a conversation:\n\n1. List the aliases. Use the `list_aliases` tool if the cloudhop MCP server is\n connected; otherwise run `cloudhop ls --json`.\n2. Show the user the options (alias, account / role, region) and **ask which\n one to use**. Don't guess, even if one name looks right, and don't pick\n one just because there is only one: confirm it.\n3. Use that alias for the rest of the conversation. Ask again if the user\n moves to another environment, account or task.\n\n## 2. Check the session before running anything\n\nRun `cloudhop status <alias> --json` (or the `alias_status` tool) and read\n`next`:\n\n| `next` | Meaning | What to do |\n| ----------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `ready` | Session valid | Go ahead |\n| `silent-renewal` | Expired but renewable without the user | Go ahead; cloudhop renews it |\n| `browser-sign-in` | The user must sign in | Tell the user their browser will open and they need to approve, then run `cloudhop login <source>` (or the `login` tool) and wait up to 5 minutes |\n| `unknown` | Managed by the cloud CLI (personal accounts) | Go ahead; if it fails, run `cloudhop login <source>` |\n\nDon't start several cloud commands in parallel while a sign-in is pending.\n\n## 3. Run commands through the alias\n\n- **AWS CLI**: `hop <alias> <aws arguments>`, e.g. `hop acme-dev s3 ls`.\n It is exactly `aws <arguments>` with the alias' account, role and region.\n Never add `--profile`. Use `--region <r>` only to override the region once.\n With the MCP server, use `run_cloud_cli` with `args` (no program name).\n- **Who am I**: `hop <alias>` with no arguments (or the `whoami` tool).\n- **Other tools** (Terraform, CDK, SDK scripts):\n `cloudhop exec <alias> -- <command>`, e.g.\n `cloudhop exec acme-dev -- terraform plan`.\n- Prefer `--output json` and `--query` to keep output small.\n- Don't export `AWS_PROFILE`, `AWS_ACCESS_KEY_ID` or similar variables, don't\n run commands that print credentials (`env`, `printenv`, `export-credentials`)\n and never edit `~/.aws/config`, `~/.aws/credentials` or cloudhop's own files.\n\n## 4. Confirm before changing anything\n\n- Read-only commands (`list`, `describe`, `get`, `s3 ls`, `logs tail`) can run\n once the user has chosen the alias.\n- Before any command that **creates, changes, deletes or costs money**, show\n the alias, account, region and the exact command, and wait for the user's\n explicit \"yes\". One confirmation covers one command, not a series.\n- Treat aliases or accounts that look like production (`prod`, `prd`,\n `production`, `live`) with extra care: confirm every change, and suggest a\n dry run (`--dryrun`, `terraform plan`) first.\n- Don't change cloudhop itself (`cloudhop add`, `rm`, `region`, `login` for\n another source) unless the user asked for it.\n\n## 5. When something goes wrong\n\n| You see | Do this |\n| --------------------------------------------------- | ------------------------------------------------------------------------------ |\n| \"Opening your browser to log in\" | Tell the user to approve in the browser; the command continues by itself |\n| The command was cut off while waiting for a sign-in | Run `cloudhop login <source>` with a timeout of at least 5 minutes, then retry |\n| `LOGIN_REQUIRED` | Same as `browser-sign-in` above |\n| No browser on this machine (remote, container) | Run `cloudhop login <source> --device-code` and show the user the URL and code |\n| `Alias \"x\" not found` | Run `cloudhop ls` and ask the user again |\n| AWS CLI missing or too old | Suggest `cloudhop doctor`; don't install anything without asking |\n| `AccessDenied` | The role lacks permission: tell the user; don't try other aliases on your own |\n\nMore: https://dayepesb.github.io/cloudhop/guides/hop-like-aws/\n";
11521
+
11522
+ // ../assistants/src/targets.ts
11523
+ import { join as join3 } from "path";
11524
+ var ASSISTANT_IDS = [
11525
+ "claude",
11526
+ "kiro",
11527
+ "copilot",
11528
+ "cursor",
11529
+ "codex"
11530
+ ];
11531
+ var SKILL_NAME = "cloudhop";
11532
+ var MCP_COMMAND = "cloudhop";
11533
+ var MCP_ARGS = ["mcp"];
11534
+ var ASSISTANTS = {
11535
+ claude: {
11536
+ id: "claude",
11537
+ name: "Claude Code",
11538
+ markers: ({ home }) => [join3(home, ".claude")],
11539
+ skillDir: (scope, { home, project }) => join3(scope === "user" ? home : project, ".claude", "skills", SKILL_NAME),
11540
+ mcp: (scope, { project }) => scope === "project" ? { kind: "json", file: join3(project, ".mcp.json"), key: "mcpServers", withType: false } : {
11541
+ kind: "cli",
11542
+ command: "claude",
11543
+ add: ["mcp", "add", "--scope", "user", "cloudhop", "--", MCP_COMMAND, ...MCP_ARGS],
11544
+ remove: ["mcp", "remove", "--scope", "user", "cloudhop"],
11545
+ manual: "claude mcp add --scope user cloudhop -- cloudhop mcp"
11546
+ }
11547
+ },
11548
+ kiro: {
11549
+ id: "kiro",
11550
+ name: "Kiro",
11551
+ markers: ({ home }) => [join3(home, ".kiro")],
11552
+ skillDir: (scope, { home, project }) => join3(scope === "user" ? home : project, ".kiro", "skills", SKILL_NAME),
11553
+ mcp: (scope, { home, project }) => ({
11554
+ kind: "json",
11555
+ file: join3(scope === "user" ? home : project, ".kiro", "settings", "mcp.json"),
11556
+ key: "mcpServers",
11557
+ withType: false
11558
+ })
11559
+ },
11560
+ copilot: {
11561
+ id: "copilot",
11562
+ name: "GitHub Copilot (VS Code)",
11563
+ markers: ({ home }) => [join3(home, ".copilot"), join3(home, ".vscode")],
11564
+ skillDir: (scope, { home, project }) => scope === "user" ? join3(home, ".copilot", "skills", SKILL_NAME) : join3(project, ".github", "skills", SKILL_NAME),
11565
+ mcp: (scope, { project }) => scope === "project" ? {
11566
+ kind: "json",
11567
+ file: join3(project, ".vscode", "mcp.json"),
11568
+ key: "servers",
11569
+ withType: true
11570
+ } : {
11571
+ kind: "cli",
11572
+ command: "code",
11573
+ add: [
11574
+ "--add-mcp",
11575
+ JSON.stringify({
11576
+ name: "cloudhop",
11577
+ type: "stdio",
11578
+ command: MCP_COMMAND,
11579
+ args: MCP_ARGS
11580
+ })
11581
+ ],
11582
+ remove: void 0,
11583
+ manual: 'VS Code \u2192 "MCP: Add Server" \u2192 stdio \u2192 command `cloudhop mcp`'
11584
+ }
11585
+ },
11586
+ cursor: {
11587
+ id: "cursor",
11588
+ name: "Cursor",
11589
+ markers: ({ home }) => [join3(home, ".cursor")],
11590
+ skillDir: (scope, { home, project }) => join3(scope === "user" ? home : project, ".cursor", "skills", SKILL_NAME),
11591
+ mcp: (scope, { home, project }) => ({
11592
+ kind: "json",
11593
+ file: join3(scope === "user" ? home : project, ".cursor", "mcp.json"),
11594
+ key: "mcpServers",
11595
+ withType: true
11596
+ })
11597
+ },
11598
+ codex: {
11599
+ id: "codex",
11600
+ name: "Codex",
11601
+ markers: ({ home }) => [join3(home, ".codex"), join3(home, ".agents")],
11602
+ skillDir: (scope, { home, project }) => join3(scope === "user" ? home : project, ".agents", "skills", SKILL_NAME),
11603
+ mcp: (scope, { home, project }) => ({
11604
+ kind: "toml",
11605
+ file: join3(scope === "user" ? home : project, ".codex", "config.toml")
11606
+ })
10939
11607
  }
11608
+ };
11609
+
11610
+ // ../assistants/src/installer.ts
11611
+ var sha = (text4) => createHash("sha256").update(text4).digest("hex");
11612
+ async function writeAtomic(file, content) {
11613
+ await mkdir2(dirname2(file), { recursive: true });
11614
+ const tmp = `${file}.cloudhop-${process.pid}.tmp`;
11615
+ await writeFile2(tmp, content);
11616
+ await rename2(tmp, file);
10940
11617
  }
10941
- async function syncCommand(app, sourceId) {
10942
- const ids = sourceId ? [sourceId] : (await app.config()).sources.map((s) => s.id);
10943
- if (ids.length === 0) {
10944
- dist_exports.log.info("No sources yet. Run `cloudhop add`.");
10945
- return;
11618
+ async function removeFileAndEmptyDir(file) {
11619
+ await rm2(file, { force: true });
11620
+ await rmdir(dirname2(file)).catch(() => {
11621
+ });
11622
+ }
11623
+ var readText = (file) => readFile2(file, "utf8").catch(() => void 0);
11624
+ var AssistantsInstaller = class {
11625
+ constructor(options) {
11626
+ this.options = options;
10946
11627
  }
10947
- for (const id of ids) {
10948
- await app.ensureSession(id);
10949
- printReport(await app.syncSource(id));
11628
+ options;
11629
+ /** The skill as written to disk: the bundled content plus a footer naming the version. */
11630
+ get skillContent() {
11631
+ return `${skill_default.replace(/\r\n/g, "\n").trimEnd()}
11632
+
11633
+ <!-- Installed by cloudhop ${this.options.cloudhopVersion}. Refresh with \`cloudhop agent update\`; edits here are kept. -->
11634
+ `;
10950
11635
  }
10951
- }
10952
- async function loginCommand(app, sourceId) {
10953
- const { sources } = await app.config();
10954
- const id = sourceId ?? answer(
10955
- await dist_exports.select({
10956
- message: "Log in to which source?",
10957
- options: sources.map((s) => ({ value: s.id, label: s.id }))
10958
- })
10959
- );
10960
- printReport(await app.login(id));
10961
- }
11636
+ /** Assistants that look installed on this machine. */
11637
+ detect() {
11638
+ const { places } = this.options;
11639
+ return ASSISTANT_IDS.filter((id) => ASSISTANTS[id].markers(places).some((p3) => existsSync2(p3)));
11640
+ }
11641
+ async status(scope) {
11642
+ const detected = new Set(this.detect());
11643
+ const manifest = await this.readManifest();
11644
+ return Promise.all(
11645
+ ASSISTANT_IDS.map(async (id) => {
11646
+ const assistant = ASSISTANTS[id];
11647
+ const skillPath = join4(assistant.skillDir(scope, this.options.places), "SKILL.md");
11648
+ const entry = manifest.find((e) => e.skillFile === skillPath);
11649
+ return {
11650
+ assistant: id,
11651
+ name: assistant.name,
11652
+ scope,
11653
+ detected: detected.has(id),
11654
+ skill: await this.skillState(skillPath, entry),
11655
+ skillPath,
11656
+ mcp: await this.mcpState(assistant.mcp(scope, this.options.places))
11657
+ };
11658
+ })
11659
+ );
11660
+ }
11661
+ async install(ids, scope, { mcp = true } = {}) {
11662
+ const results = [];
11663
+ const manifest = await this.readManifest();
11664
+ for (const id of ids) {
11665
+ const assistant = ASSISTANTS[id];
11666
+ const skillPath = join4(assistant.skillDir(scope, this.options.places), "SKILL.md");
11667
+ const content = this.skillContent;
11668
+ await writeAtomic(skillPath, content);
11669
+ const kept = manifest.filter((e) => e.skillFile !== skillPath);
11670
+ kept.push({
11671
+ assistant: id,
11672
+ scope,
11673
+ skillFile: skillPath,
11674
+ hash: sha(content),
11675
+ cloudhopVersion: this.options.cloudhopVersion
11676
+ });
11677
+ manifest.splice(0, manifest.length, ...kept);
11678
+ const target = assistant.mcp(scope, this.options.places);
11679
+ const outcome = mcp ? await this.addMcp(target) : { mcp: "skipped" };
11680
+ results.push({ assistant: id, name: assistant.name, scope, skillPath, ...outcome });
11681
+ }
11682
+ await this.writeManifest(manifest);
11683
+ return results;
11684
+ }
11685
+ async remove(ids, scope) {
11686
+ const results = [];
11687
+ let manifest = await this.readManifest();
11688
+ for (const id of ids) {
11689
+ const assistant = ASSISTANTS[id];
11690
+ const dir = assistant.skillDir(scope, this.options.places);
11691
+ const skillPath = join4(dir, "SKILL.md");
11692
+ await rm2(dir, { recursive: true, force: true });
11693
+ await rmdir(dirname2(dir)).then(() => rmdir(dirname2(dirname2(dir)))).catch(() => {
11694
+ });
11695
+ manifest = manifest.filter((e) => e.skillFile !== skillPath);
11696
+ const outcome = await this.removeMcp(assistant.mcp(scope, this.options.places));
11697
+ results.push({ assistant: id, name: assistant.name, scope, skillPath, ...outcome });
11698
+ }
11699
+ await this.writeManifest(manifest);
11700
+ return results;
11701
+ }
11702
+ /**
11703
+ * Refreshes skills written by an older cloudhop. Copies the user edited are
11704
+ * left alone unless `force`. Cheap when there is nothing to do: one small read.
11705
+ */
11706
+ async update({ force = false } = {}) {
11707
+ const manifest = await this.readManifest();
11708
+ const content = this.skillContent;
11709
+ const target = sha(content);
11710
+ const updated = [];
11711
+ const edited = [];
11712
+ let changed = false;
11713
+ for (const entry of manifest) {
11714
+ if (entry.hash === target) continue;
11715
+ const onDisk = await readText(entry.skillFile);
11716
+ if (onDisk === void 0) continue;
11717
+ if (sha(onDisk) !== entry.hash && !force) {
11718
+ edited.push(entry.skillFile);
11719
+ continue;
11720
+ }
11721
+ await writeAtomic(entry.skillFile, content);
11722
+ entry.hash = target;
11723
+ entry.cloudhopVersion = this.options.cloudhopVersion;
11724
+ updated.push(entry.skillFile);
11725
+ changed = true;
11726
+ }
11727
+ if (changed) await this.writeManifest(manifest);
11728
+ return { updated, edited };
11729
+ }
11730
+ // ───────────── skill state ─────────────
11731
+ async skillState(path2, entry) {
11732
+ const onDisk = await readText(path2);
11733
+ if (onDisk === void 0) return "missing";
11734
+ if (onDisk === this.skillContent) return "current";
11735
+ if (entry && sha(onDisk) !== entry.hash) return "modified";
11736
+ return "outdated";
11737
+ }
11738
+ // ───────────── MCP registration ─────────────
11739
+ async mcpState(target) {
11740
+ if (target.kind === "cli") return "unknown";
11741
+ const text4 = await readText(target.file);
11742
+ if (text4 === void 0) return "missing";
11743
+ if (target.kind === "toml")
11744
+ return /^\[mcp_servers\.cloudhop\]/m.test(text4) ? "registered" : "missing";
11745
+ try {
11746
+ const servers = JSON.parse(text4)[target.key];
11747
+ return servers?.cloudhop ? "registered" : "missing";
11748
+ } catch {
11749
+ return "unknown";
11750
+ }
11751
+ }
11752
+ async addMcp(target) {
11753
+ switch (target.kind) {
11754
+ case "json": {
11755
+ const config = await this.readJsonConfig(target.file);
11756
+ config[target.key] ??= {};
11757
+ const servers = config[target.key];
11758
+ const entry = {
11759
+ ...target.withType ? { type: "stdio" } : {},
11760
+ command: MCP_COMMAND,
11761
+ args: MCP_ARGS
11762
+ };
11763
+ if (JSON.stringify(servers.cloudhop) === JSON.stringify(entry)) return { mcp: "already" };
11764
+ servers.cloudhop = entry;
11765
+ await writeAtomic(target.file, `${JSON.stringify(config, null, 2)}
11766
+ `);
11767
+ return { mcp: "added" };
11768
+ }
11769
+ case "toml": {
11770
+ const text4 = await readText(target.file) ?? "";
11771
+ if (/^\[mcp_servers\.cloudhop\]/m.test(text4)) return { mcp: "already" };
11772
+ const block2 = `${TOML_MARK}
11773
+ [mcp_servers.cloudhop]
11774
+ command = "${MCP_COMMAND}"
11775
+ args = [${MCP_ARGS.map((a2) => `"${a2}"`).join(", ")}]
11776
+ `;
11777
+ await writeAtomic(
11778
+ target.file,
11779
+ `${text4}${text4 && !text4.endsWith("\n") ? "\n" : ""}${text4 ? "\n" : ""}${block2}`
11780
+ );
11781
+ return { mcp: "added" };
11782
+ }
11783
+ case "cli": {
11784
+ if (!(await this.options.runner.probe(target.command)).installed) {
11785
+ return { mcp: "manual", mcpManual: target.manual };
11786
+ }
11787
+ const result = await this.options.runner.capture(target.command, target.add);
11788
+ if (result.exitCode === 0) return { mcp: "added" };
11789
+ if (/already exists/i.test(`${result.stdout}${result.stderr}`)) return { mcp: "already" };
11790
+ return { mcp: "manual", mcpManual: target.manual };
11791
+ }
11792
+ }
11793
+ }
11794
+ async removeMcp(target) {
11795
+ switch (target.kind) {
11796
+ case "json": {
11797
+ const text4 = await readText(target.file);
11798
+ if (text4 === void 0) return { mcp: "already" };
11799
+ const config = await this.readJsonConfig(target.file);
11800
+ const servers = config[target.key];
11801
+ if (!servers?.cloudhop) return { mcp: "already" };
11802
+ delete servers.cloudhop;
11803
+ const onlyEmptyKey = Object.keys(servers).length === 0 && Object.keys(config).length === 1;
11804
+ if (onlyEmptyKey) await removeFileAndEmptyDir(target.file);
11805
+ else await writeAtomic(target.file, `${JSON.stringify(config, null, 2)}
11806
+ `);
11807
+ return { mcp: "removed" };
11808
+ }
11809
+ case "toml": {
11810
+ const text4 = await readText(target.file);
11811
+ if (!text4 || !/^\[mcp_servers\.cloudhop\]/m.test(text4)) return { mcp: "already" };
11812
+ const cleaned = text4.replace(new RegExp(`${escapeRegExp(TOML_MARK)}\\n`, "g"), "").replace(/^\[mcp_servers\.cloudhop\]\n(?:(?!\[)[^\n]*\n?)*/m, "").replace(/\n{3,}/g, "\n\n");
11813
+ if (cleaned.trim()) await writeAtomic(target.file, `${cleaned.trimEnd()}
11814
+ `);
11815
+ else await removeFileAndEmptyDir(target.file);
11816
+ return { mcp: "removed" };
11817
+ }
11818
+ case "cli": {
11819
+ if (!target.remove || !(await this.options.runner.probe(target.command)).installed) {
11820
+ return {
11821
+ mcp: "manual",
11822
+ mcpManual: `Remove the "cloudhop" MCP server in ${target.command === "code" ? "VS Code (MCP: List Servers)" : target.command}.`
11823
+ };
11824
+ }
11825
+ await this.options.runner.capture(target.command, target.remove);
11826
+ return { mcp: "removed" };
11827
+ }
11828
+ }
11829
+ }
11830
+ async readJsonConfig(file) {
11831
+ const text4 = await readText(file);
11832
+ if (text4 === void 0 || !text4.trim()) return {};
11833
+ try {
11834
+ const value = JSON.parse(text4);
11835
+ if (value && typeof value === "object" && !Array.isArray(value))
11836
+ return value;
11837
+ } catch {
11838
+ }
11839
+ throw new CloudhopError(
11840
+ "INVALID_NAME",
11841
+ `${file} isn't plain JSON (it may have comments), so cloudhop won't rewrite it.`,
11842
+ `Add the server by hand: "cloudhop": { "command": "cloudhop", "args": ["mcp"] }, or rerun with --no-mcp.`
11843
+ );
11844
+ }
11845
+ // ───────────── manifest ─────────────
11846
+ get manifestPath() {
11847
+ return join4(this.options.configDir, "assistants.json");
11848
+ }
11849
+ async readManifest() {
11850
+ const text4 = await readText(this.manifestPath);
11851
+ if (!text4) return [];
11852
+ try {
11853
+ const parsed = JSON.parse(text4);
11854
+ return Array.isArray(parsed.installs) ? parsed.installs : [];
11855
+ } catch {
11856
+ return [];
11857
+ }
11858
+ }
11859
+ async writeManifest(installs) {
11860
+ await writeAtomic(this.manifestPath, `${JSON.stringify({ installs }, null, 2)}
11861
+ `);
11862
+ }
11863
+ };
11864
+ var TOML_MARK = "# Added by cloudhop (`cloudhop agent remove` deletes it)";
11865
+ var escapeRegExp = (text4) => text4.replace(/[.*+?^${}()|[\]\\`]/g, "\\$&");
11866
+
11867
+ // src/commands/agent.ts
11868
+ var MCP_TEXT = {
11869
+ added: paint("green", "MCP server added"),
11870
+ already: "MCP server already there",
11871
+ removed: "MCP server removed",
11872
+ manual: paint("yellow", "MCP server: add it by hand"),
11873
+ skipped: paint("dim", "MCP server skipped")
11874
+ };
11875
+ var tilde = (path2) => {
11876
+ const inProject = relative(process.cwd(), path2);
11877
+ if (!inProject.startsWith("..") && !isAbsolute(inProject)) return inProject;
11878
+ return path2.replace(homedir2(), "~");
11879
+ };
11880
+ function parseTools(value, fallback) {
11881
+ if (!value) return fallback;
11882
+ const ids = value.split(",").map((v) => v.trim().toLowerCase());
11883
+ const unknown = ids.filter((id) => !ASSISTANT_IDS.includes(id));
11884
+ if (unknown.length) {
11885
+ throw new CloudhopError(
11886
+ "INVALID_NAME",
11887
+ `Unknown assistant: ${unknown.join(", ")}.`,
11888
+ `Use any of: ${ASSISTANT_IDS.join(", ")}.`
11889
+ );
11890
+ }
11891
+ return ids;
11892
+ }
11893
+ function parseScope(value) {
11894
+ if (value === void 0) return void 0;
11895
+ if (value === "user" || value === "project") return value;
11896
+ throw new CloudhopError("INVALID_NAME", '--scope must be "user" or "project".');
11897
+ }
11898
+ function printResults(results) {
11899
+ for (const r2 of results) {
11900
+ console.log(`${paint("green", "\u2714")} ${bold(r2.name)}`);
11901
+ console.log(` skill ${tilde(r2.skillPath)}`);
11902
+ console.log(` ${MCP_TEXT[r2.mcp]}${r2.mcpManual ? `: ${r2.mcpManual}` : ""}`);
11903
+ }
11904
+ }
11905
+ async function agentCommand(installer, args) {
11906
+ const { positional, flags } = parseArgs(args);
11907
+ const action = positional[0] ?? "status";
11908
+ const scopeFlag = parseScope(flags.scope);
11909
+ switch (action) {
11910
+ case "status": {
11911
+ for (const scope of scopeFlag ? [scopeFlag] : ["user", "project"]) {
11912
+ console.log(
11913
+ bold(
11914
+ scope === "user" ? "For you (all projects)" : `This project (${tilde(process.cwd())})`
11915
+ )
11916
+ );
11917
+ for (const s of await installer.status(scope)) {
11918
+ const skill = s.skill === "current" ? paint("green", "\u25CF skill up to date") : s.skill === "outdated" ? paint("yellow", "\u25D0 skill outdated (run `cloudhop agent update`)") : s.skill === "modified" ? paint(
11919
+ "yellow",
11920
+ "\u25D0 skill edited by you (`cloudhop agent update --force` replaces it)"
11921
+ ) : paint("dim", "\u25CB no skill");
11922
+ const mcp = s.mcp === "registered" ? ", MCP on" : s.mcp === "unknown" ? ", MCP: check in the app" : "";
11923
+ const seen = s.detected ? "" : paint("dim", " (not detected)");
11924
+ console.log(` ${s.name.padEnd(26)} ${skill}${s.skill === "missing" ? "" : mcp}${seen}`);
11925
+ }
11926
+ }
11927
+ return;
11928
+ }
11929
+ case "install": {
11930
+ const detected = installer.detect();
11931
+ let tools = parseTools(flags.tools, detected);
11932
+ let scope = scopeFlag ?? "user";
11933
+ let mcp = !flags.noMcp;
11934
+ if (!flags.tools && interactive() && !flags.yes) {
11935
+ dist_exports.intro("cloudhop agent install");
11936
+ dist_exports.note(
11937
+ "Installs a skill that teaches your AI assistant to ask which cloudhop alias to use,\nrun commands with hop, and confirm before changing anything. Optionally also\nregisters the cloudhop MCP server (tools to list aliases, check sessions and run the CLI).",
11938
+ "Teach your AI assistant cloudhop"
11939
+ );
11940
+ tools = answer(
11941
+ await dist_exports.multiselect({
11942
+ message: "Which assistants do you use?",
11943
+ options: ASSISTANT_IDS.map((id) => ({
11944
+ value: id,
11945
+ label: ASSISTANTS[id].name,
11946
+ ...detected.includes(id) ? { hint: "detected" } : {}
11947
+ })),
11948
+ initialValues: detected,
11949
+ required: true
11950
+ })
11951
+ );
11952
+ if (!scopeFlag) {
11953
+ scope = answer(
11954
+ await dist_exports.select({
11955
+ message: "Where should it go?",
11956
+ options: [
11957
+ { value: "user", label: "For me, in every project", hint: "your home folder" },
11958
+ {
11959
+ value: "project",
11960
+ label: "Only this project",
11961
+ hint: "commit it and your team gets it too"
11962
+ }
11963
+ ]
11964
+ })
11965
+ );
11966
+ }
11967
+ if (!flags.noMcp) {
11968
+ mcp = answer(
11969
+ await dist_exports.confirm({
11970
+ message: "Also register the cloudhop MCP server?",
11971
+ initialValue: true
11972
+ })
11973
+ );
11974
+ }
11975
+ }
11976
+ if (tools.length === 0) {
11977
+ throw new CloudhopError(
11978
+ "INVALID_NAME",
11979
+ "No AI assistant detected.",
11980
+ `Choose them with --tools ${ASSISTANT_IDS.join(",")}.`
11981
+ );
11982
+ }
11983
+ printResults(await installer.install(tools, scope, { mcp }));
11984
+ console.log(
11985
+ paint("dim", "\nStart a new chat (or restart the assistant) so it picks up the skill.")
11986
+ );
11987
+ return;
11988
+ }
11989
+ case "update": {
11990
+ const { updated, edited } = await installer.update({ force: Boolean(flags.force) });
11991
+ for (const file of updated) console.log(`${paint("green", "\u2714")} updated ${tilde(file)}`);
11992
+ for (const file of edited)
11993
+ console.log(
11994
+ `${paint("yellow", "\u25D0")} kept your edits in ${tilde(file)} (use --force to replace)`
11995
+ );
11996
+ if (!updated.length && !edited.length) console.log("Every installed skill is up to date.");
11997
+ return;
11998
+ }
11999
+ case "remove": {
12000
+ const scope = scopeFlag ?? "user";
12001
+ const statuses = await installer.status(scope);
12002
+ const installed = statuses.filter((s) => s.skill !== "missing" || s.mcp === "registered").map((s) => s.assistant);
12003
+ const tools = parseTools(flags.tools, installed);
12004
+ if (tools.length === 0) {
12005
+ console.log(`Nothing to remove for scope "${scope}".`);
12006
+ return;
12007
+ }
12008
+ printResults(await installer.remove(tools, scope));
12009
+ return;
12010
+ }
12011
+ default:
12012
+ throw new CloudhopError(
12013
+ "INVALID_NAME",
12014
+ `Unknown action "${action}".`,
12015
+ "Use: cloudhop agent [status | install | update | remove]"
12016
+ );
12017
+ }
12018
+ }
12019
+
12020
+ // src/commands/manage.ts
12021
+ function printReport(report) {
12022
+ const changes = [
12023
+ `${report.targets.length} available`,
12024
+ report.added.length ? `${report.added.length} new` : "",
12025
+ report.removed.length ? `${report.removed.length} removed` : ""
12026
+ ].filter(Boolean);
12027
+ dist_exports.log.success(`${report.sourceId}: ${changes.join(", ")}.`);
12028
+ for (const t2 of report.added) dist_exports.log.message(` + ${t2.label}`);
12029
+ for (const t2 of report.removed) dist_exports.log.message(` - ${t2.label}`);
12030
+ if (report.orphanedAliases.length) {
12031
+ dist_exports.log.warn(
12032
+ `No longer available: ${report.orphanedAliases.join(", ")}. Remove them with \`cloudhop rm <alias>\`.`
12033
+ );
12034
+ }
12035
+ }
12036
+ async function syncCommand(app, sourceId) {
12037
+ const ids = sourceId ? [sourceId] : (await app.config()).sources.map((s) => s.id);
12038
+ if (ids.length === 0) {
12039
+ dist_exports.log.info("No sources yet. Run `cloudhop add`.");
12040
+ return;
12041
+ }
12042
+ for (const id of ids) {
12043
+ await app.ensureSession(id);
12044
+ printReport(await app.syncSource(id));
12045
+ }
12046
+ }
12047
+ async function loginCommand(app, sourceId) {
12048
+ const { sources } = await app.config();
12049
+ const id = sourceId ?? answer(
12050
+ await dist_exports.select({
12051
+ message: "Log in to which source?",
12052
+ options: sources.map((s) => ({ value: s.id, label: s.id }))
12053
+ })
12054
+ );
12055
+ printReport(await app.login(id));
12056
+ }
10962
12057
  var SESSION_LABEL = {
10963
12058
  valid: paint("green", "\u25CF session valid"),
10964
12059
  refreshable: paint("yellow", "\u25D0 renews automatically on next use"),
10965
12060
  expired: paint("red", "\u25CB expired, opens the browser on next use"),
10966
12061
  unknown: paint("dim", "\xB7 session managed by the cloud CLI")
10967
12062
  };
10968
- async function listCommand(app, sourceId) {
12063
+ var JSON_VERSION = 1;
12064
+ var printJson = (value) => console.log(JSON.stringify({ version: JSON_VERSION, ...value }, null, 2));
12065
+ async function listCommand(app, args = []) {
12066
+ const { positional, flags } = parseArgs(args);
12067
+ const sourceId = positional[0];
12068
+ if (flags.json) {
12069
+ const overview = await app.overview();
12070
+ if (sourceId && !overview.sources.some((s) => s.id === sourceId)) {
12071
+ throw new CloudhopError("SOURCE_NOT_FOUND", `Source "${sourceId}" not found.`);
12072
+ }
12073
+ printJson(
12074
+ sourceId ? {
12075
+ sources: overview.sources.filter((s) => s.id === sourceId),
12076
+ aliases: overview.aliases.filter((a2) => a2.source === sourceId)
12077
+ } : overview
12078
+ );
12079
+ return;
12080
+ }
10969
12081
  const config = await app.config();
10970
12082
  if (config.sources.length === 0) {
10971
12083
  console.log("Nothing here yet. Run `cloudhop setup` to get started.");
@@ -11059,389 +12171,208 @@ async function providerCommand(app, args) {
11059
12171
  for (const pid of PROVIDER_IDS) {
11060
12172
  const available = app.availableProviders().some((x2) => x2.id === pid);
11061
12173
  const status = providers.includes(pid) ? "enabled" : available ? "disabled" : "coming soon";
11062
- console.log(` ${pid.padEnd(8)} ${status}`);
11063
- }
11064
- return;
11065
- }
11066
- if (!id || !PROVIDER_IDS.includes(id)) {
11067
- throw new Error(`Usage: cloudhop provider ${action} <${PROVIDER_IDS.join("|")}>`);
11068
- }
11069
- if (action === "enable") await app.enableProvider(id);
11070
- else if (action === "disable") await app.disableProvider(id);
11071
- else throw new Error("Usage: cloudhop provider [ls|enable|disable] <provider>");
11072
- dist_exports.log.success(`${id} ${action}d.`);
11073
- }
11074
- async function regionCommand(app, args) {
11075
- const [aliasArg, regionArg] = args;
11076
- if ((!aliasArg || !regionArg) && !interactive()) {
11077
- throw new CloudhopError("INVALID_NAME", "Usage: cloudhop region <alias> <region>");
11078
- }
11079
- const aliases = await app.listAliases();
11080
- const name = aliasArg ?? answer(
11081
- await dist_exports.select({
11082
- message: "Which alias?",
11083
- options: aliases.map((a2) => ({
11084
- value: a2.alias.name,
11085
- label: a2.alias.name,
11086
- hint: `${a2.target?.label ?? a2.alias.target}, ${a2.region}`
11087
- }))
11088
- })
11089
- );
11090
- const current = await app.resolveAlias(name);
11091
- const validate = regionValidator(app, current.source);
11092
- const region = regionArg?.trim() ?? (answer(
11093
- await dist_exports.text({
11094
- message: `Region for ${name}`,
11095
- placeholder: current.region,
11096
- defaultValue: current.region,
11097
- validate: (v) => v?.trim() ? validate?.(v.trim()) : void 0
11098
- })
11099
- ).trim() || current.region);
11100
- const problem = region ? validate?.(region) : "Say which region.";
11101
- if (!region || problem) throw new CloudhopError("INVALID_NAME", problem ?? "Invalid region.");
11102
- await app.setAliasRegion(name, region);
11103
- console.log(`${name}: ${current.region ?? "(none)"} \u2192 ${region}`);
11104
- }
11105
-
11106
- // src/commands/run.ts
11107
- async function proxyCommand(app, alias, args) {
11108
- const resolved = await app.resolveAlias(alias);
11109
- const defaultArgs = resolved.source.provider === "aws" ? ["sts", "get-caller-identity"] : [];
11110
- return app.runAsAlias(alias, args.length > 0 ? args : defaultArgs);
11111
- }
11112
- async function execCommand(app, args) {
11113
- const separator = args.indexOf("--");
11114
- const before = separator === -1 ? args.slice(0, 1) : args.slice(0, separator);
11115
- const command = separator === -1 ? args.slice(1) : args.slice(separator + 1);
11116
- const [alias, ...flags] = before;
11117
- if (!alias || command.length === 0) {
11118
- throw new Error("Usage: cloudhop exec <alias> [--region <region>] -- <command> [args...]");
11119
- }
11120
- const overrides = {};
11121
- const regionIndex = flags.indexOf("--region");
11122
- if (regionIndex !== -1 && flags[regionIndex + 1])
11123
- overrides.region = flags[regionIndex + 1];
11124
- const [bin, ...rest] = command;
11125
- return app.execAsAlias(alias, bin, rest, overrides);
11126
- }
11127
-
11128
- // src/commands/setup.ts
11129
- var UPCOMING = ["Azure", "Google Cloud"];
11130
- var WELCOME = [
11131
- "cloudhop lets you hop between cloud accounts, roles and regions with one command.",
11132
- "Add your SSO portals and personal accounts once and give each account/role a short",
11133
- "alias. Then `hop <alias> <command>` runs the cloud CLI as that account: cloudhop",
11134
- "logs you in when a session expires, and never stores access keys."
11135
- ].join("\n");
11136
- async function offerInstall(app, entry) {
11137
- const { cli, tool, recipe, outdated } = entry;
11138
- if (tool.installed && !outdated) return true;
11139
- const problem = outdated ? `${cli.displayName} ${tool.version} is too old (cloudhop needs ${cli.minVersion}+).` : `${cli.displayName} is not installed. cloudhop can still log you in, but needs it to run \`${cli.command}\` commands.`;
11140
- dist_exports.log.warn(problem);
11141
- if (!recipe?.command) {
11142
- dist_exports.log.info(
11143
- `Install it from ${recipe?.manualUrl ?? "the official docs"}, then run \`cloudhop doctor\`.`
11144
- );
11145
- return false;
11146
- }
11147
- const command = [recipe.command, ...recipe.args].join(" ");
11148
- const install = answer(
11149
- await dist_exports.confirm({ message: `Install it with ${recipe.via}? (${command})` })
11150
- );
11151
- if (!install) return false;
11152
- const ok = await app.installCli(recipe);
11153
- if (ok) dist_exports.log.success(`${cli.displayName} installed.`);
11154
- else dist_exports.log.error(`Could not install automatically. See ${recipe.manualUrl}`);
11155
- return ok;
11156
- }
11157
- async function setupCommand(app) {
11158
- requireInteractive(
11159
- "setup",
11160
- "Run it in a regular terminal, or script it: `cloudhop provider enable aws`, then `cloudhop add aws sso --start-url <url> --region <r>`."
11161
- );
11162
- printBanner();
11163
- dist_exports.intro("cloudhop setup");
11164
- dist_exports.note(WELCOME, "Welcome");
11165
- const config = await app.config();
11166
- const providers = answer(
11167
- await dist_exports.multiselect({
11168
- message: "Which clouds do you use?",
11169
- options: app.availableProviders().map((provider) => ({ value: provider.id, label: provider.displayName })),
11170
- initialValues: config.providers.length > 0 ? config.providers : ["aws"],
11171
- required: true
11172
- })
11173
- );
11174
- dist_exports.log.info(`Coming soon: ${UPCOMING.join(", ")}.`);
11175
- await app.setEnabledProviders(providers);
11176
- for (const entry of await app.doctor()) {
11177
- if (entry.tool.installed && !entry.outdated) {
11178
- dist_exports.log.success(`${entry.cli.displayName} ${entry.tool.version ?? ""} found.`);
11179
- } else {
11180
- await offerInstall(app, entry);
11181
- }
11182
- }
11183
- const addNow = answer(await dist_exports.confirm({ message: "Add an account or SSO portal now?" }));
11184
- if (!addNow) {
11185
- dist_exports.outro("All set. Run `cloudhop add` whenever you are ready.");
11186
- return;
11187
- }
11188
- await addCommand(app, { positional: [], flags: {} });
11189
- }
11190
- async function doctorCommand(app) {
11191
- dist_exports.intro("cloudhop doctor");
11192
- const entries = await app.doctor();
11193
- if (entries.length === 0) {
11194
- dist_exports.outro("No cloud enabled. Run `cloudhop setup`.");
11195
- return;
11196
- }
11197
- let healthy = true;
11198
- for (const entry of entries) {
11199
- if (entry.tool.installed && !entry.outdated) {
11200
- dist_exports.log.success(`${entry.displayName}: ${entry.cli.displayName} ${entry.tool.version ?? ""}`);
11201
- } else {
11202
- healthy = await offerInstall(app, entry) && healthy;
11203
- }
11204
- }
11205
- dist_exports.outro(
11206
- healthy ? "Everything looks good." : "Some tools are missing; login and `cloudhop exec` still work."
11207
- );
11208
- }
11209
-
11210
- // ../adapters-node/src/node-browser-opener.ts
11211
- import { spawn } from "child_process";
11212
- var NodeBrowserOpener = class {
11213
- async open(url) {
11214
- const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
11215
- await new Promise((resolve) => {
11216
- const child = spawn(command, args, { stdio: "ignore", detached: true });
11217
- child.on("error", () => resolve());
11218
- child.on("spawn", () => {
11219
- child.unref();
11220
- resolve();
11221
- });
11222
- });
11223
- }
11224
- };
11225
-
11226
- // ../adapters-node/src/node-process-runner.ts
11227
- import { spawn as spawn2 } from "child_process";
11228
- var STDERR_TAIL_BYTES = 8 * 1024;
11229
- function buildEnv(overrides) {
11230
- const env = { ...process.env };
11231
- for (const [key, value] of Object.entries(overrides ?? {})) {
11232
- if (value === void 0) delete env[key];
11233
- else env[key] = value;
11234
- }
11235
- return env;
11236
- }
11237
- var useShell = process.platform === "win32";
11238
- var NodeProcessRunner = class {
11239
- run(command, args, options = {}) {
11240
- return new Promise((resolve, reject) => {
11241
- const child = spawn2(command, args, {
11242
- env: buildEnv(options.env),
11243
- stdio: ["inherit", "inherit", options.captureStderr ? "pipe" : "inherit"],
11244
- shell: useShell
11245
- });
11246
- let tail = "";
11247
- child.stderr?.on("data", (chunk) => {
11248
- process.stderr.write(chunk);
11249
- tail = (tail + chunk.toString()).slice(-STDERR_TAIL_BYTES);
11250
- });
11251
- const ignore = () => {
11252
- };
11253
- process.on("SIGINT", ignore);
11254
- process.on("SIGTERM", ignore);
11255
- const cleanup = () => {
11256
- process.off("SIGINT", ignore);
11257
- process.off("SIGTERM", ignore);
11258
- };
11259
- child.on("error", (error) => {
11260
- cleanup();
11261
- reject(error);
11262
- });
11263
- child.on("close", (code, signal) => {
11264
- cleanup();
11265
- resolve({ exitCode: code ?? (signal ? 128 + 2 : 1), stderrTail: tail });
11266
- });
11267
- });
11268
- }
11269
- capture(command, args, options = {}) {
11270
- return new Promise((resolve) => {
11271
- const child = spawn2(command, args, { env: buildEnv(options.env), shell: useShell });
11272
- let stdout2 = "";
11273
- let stderr = "";
11274
- child.stdout.on("data", (c3) => {
11275
- stdout2 += c3.toString();
11276
- });
11277
- child.stderr.on("data", (c3) => {
11278
- stderr += c3.toString();
11279
- });
11280
- child.on("error", (error) => resolve({ exitCode: 127, stdout: stdout2, stderr: error.message }));
11281
- child.on("close", (code) => resolve({ exitCode: code ?? 1, stdout: stdout2, stderr }));
11282
- });
11283
- }
11284
- async probe(command) {
11285
- const result = await this.capture(command, ["--version"]);
11286
- if (result.exitCode !== 0) return { installed: false };
11287
- const output = `${result.stdout} ${result.stderr}`;
11288
- const version = output.match(/(\d+\.\d+\.\d+)/)?.[1];
11289
- return { installed: true, ...version ? { version } : {} };
11290
- }
11291
- };
11292
-
11293
- // ../adapters-node/src/system-tool-installer.ts
11294
- var TOOLS = {
11295
- aws: {
11296
- manualUrl: "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html",
11297
- recipes: {
11298
- darwin: [{ via: "Homebrew", command: "brew", args: ["install", "awscli"] }],
11299
- win32: [
11300
- { via: "winget", command: "winget", args: ["install", "--exact", "--id", "Amazon.AWSCLI"] }
11301
- ]
11302
- }
11303
- },
11304
- az: {
11305
- manualUrl: "https://learn.microsoft.com/cli/azure/install-azure-cli",
11306
- recipes: {
11307
- darwin: [{ via: "Homebrew", command: "brew", args: ["install", "azure-cli"] }],
11308
- win32: [
11309
- {
11310
- via: "winget",
11311
- command: "winget",
11312
- args: ["install", "--exact", "--id", "Microsoft.AzureCLI"]
11313
- }
11314
- ]
11315
- }
11316
- },
11317
- gcloud: {
11318
- manualUrl: "https://cloud.google.com/sdk/docs/install",
11319
- recipes: {
11320
- darwin: [
11321
- { via: "Homebrew", command: "brew", args: ["install", "--cask", "google-cloud-sdk"] }
11322
- ],
11323
- win32: [
11324
- {
11325
- via: "winget",
11326
- command: "winget",
11327
- args: ["install", "--exact", "--id", "Google.CloudSDK"]
11328
- }
11329
- ]
11330
- }
11331
- }
11332
- };
11333
- var SystemToolInstaller = class {
11334
- constructor(runner, platform = process.platform) {
11335
- this.runner = runner;
11336
- this.platform = platform;
11337
- }
11338
- runner;
11339
- platform;
11340
- available = /* @__PURE__ */ new Map();
11341
- recipeFor(command) {
11342
- const tool = TOOLS[command];
11343
- if (!tool) return void 0;
11344
- const recipe = tool.recipes[this.platform]?.[0];
11345
- return recipe ? { ...recipe, manualUrl: tool.manualUrl } : { via: "official installer", command: "", args: [], manualUrl: tool.manualUrl };
11346
- }
11347
- async install(recipe) {
11348
- if (!recipe.command) return false;
11349
- if (!await this.hasCommand(recipe.command)) return false;
11350
- const { exitCode } = await this.runner.run(recipe.command, recipe.args);
11351
- return exitCode === 0;
11352
- }
11353
- async hasCommand(command) {
11354
- if (!this.available.has(command)) {
11355
- this.available.set(command, (await this.runner.probe(command)).installed);
12174
+ console.log(` ${pid.padEnd(8)} ${status}`);
11356
12175
  }
11357
- return this.available.get(command) ?? false;
12176
+ return;
11358
12177
  }
11359
- };
11360
-
11361
- // ../adapters-node/src/yaml-config-store.ts
11362
- import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
11363
- import { homedir } from "os";
11364
- import { join as join2 } from "path";
11365
- var import_yaml = __toESM(require_dist(), 1);
11366
- function defaultConfigDir(env = process.env) {
11367
- if (env.CLOUDHOP_HOME) return env.CLOUDHOP_HOME;
11368
- return join2(env.XDG_CONFIG_HOME || join2(homedir(), ".config"), "cloudhop");
11369
- }
11370
- var HEADER = "# cloudhop configuration. Contains no secrets.\n# Docs: https://github.com/dayepesb/cloudhop\n";
11371
- var YamlConfigStore = class {
11372
- constructor(dir = defaultConfigDir()) {
11373
- this.dir = dir;
12178
+ if (!id || !PROVIDER_IDS.includes(id)) {
12179
+ throw new Error(`Usage: cloudhop provider ${action} <${PROVIDER_IDS.join("|")}>`);
11374
12180
  }
11375
- dir;
11376
- get configPath() {
11377
- return join2(this.dir, "config.yaml");
12181
+ if (action === "enable") await app.enableProvider(id);
12182
+ else if (action === "disable") await app.disableProvider(id);
12183
+ else throw new Error("Usage: cloudhop provider [ls|enable|disable] <provider>");
12184
+ dist_exports.log.success(`${id} ${action}d.`);
12185
+ }
12186
+ async function regionCommand(app, args) {
12187
+ const [aliasArg, regionArg] = args;
12188
+ if ((!aliasArg || !regionArg) && !interactive()) {
12189
+ throw new CloudhopError("INVALID_NAME", "Usage: cloudhop region <alias> <region>");
11378
12190
  }
11379
- get targetsPath() {
11380
- return join2(this.dir, "targets.json");
12191
+ const aliases = await app.listAliases();
12192
+ const name = aliasArg ?? answer(
12193
+ await dist_exports.select({
12194
+ message: "Which alias?",
12195
+ options: aliases.map((a2) => ({
12196
+ value: a2.alias.name,
12197
+ label: a2.alias.name,
12198
+ hint: `${a2.target?.label ?? a2.alias.target}, ${a2.region}`
12199
+ }))
12200
+ })
12201
+ );
12202
+ const current = await app.resolveAlias(name);
12203
+ const validate = regionValidator(app, current.source);
12204
+ const region = regionArg?.trim() ?? (answer(
12205
+ await dist_exports.text({
12206
+ message: `Region for ${name}`,
12207
+ placeholder: current.region,
12208
+ defaultValue: current.region,
12209
+ validate: (v) => v?.trim() ? validate?.(v.trim()) : void 0
12210
+ })
12211
+ ).trim() || current.region);
12212
+ const problem = region ? validate?.(region) : "Say which region.";
12213
+ if (!region || problem) throw new CloudhopError("INVALID_NAME", problem ?? "Invalid region.");
12214
+ await app.setAliasRegion(name, region);
12215
+ console.log(`${name}: ${current.region ?? "(none)"} \u2192 ${region}`);
12216
+ }
12217
+ var STATE_SHORT = {
12218
+ valid: paint("green", "\u25CF session valid"),
12219
+ refreshable: paint("yellow", "\u25D0 session expired, renewable"),
12220
+ expired: paint("red", "\u25CB session expired"),
12221
+ unknown: paint("dim", "\xB7 session")
12222
+ };
12223
+ var NEXT_TEXT = {
12224
+ ready: "ready to run",
12225
+ "silent-renewal": "the next command renews the session silently",
12226
+ "browser-sign-in": "the next command opens the browser to sign in",
12227
+ unknown: "the cloud CLI manages this session"
12228
+ };
12229
+ async function statusCommand(app, args) {
12230
+ const { positional, flags } = parseArgs(args);
12231
+ const name = positional[0];
12232
+ if (name) {
12233
+ const status = await app.aliasStatus(name);
12234
+ if (flags.json) return printJson({ alias: status });
12235
+ const account = status.attributes.accountId ? ` (${status.attributes.accountId})` : "";
12236
+ console.log(
12237
+ `${bold(paint("sky", status.name))} \u2192 ${status.label ?? status.target}${account}, ${status.region ?? "no region"}`
12238
+ );
12239
+ console.log(` ${STATE_SHORT[status.session]}: ${NEXT_TEXT[status.next]}`);
12240
+ if (status.next === "browser-sign-in") {
12241
+ console.log(paint("dim", ` Sign in now with \`cloudhop login ${status.source}\`.`));
12242
+ }
12243
+ if (!status.available) console.log(paint("red", " This account/role is no longer available."));
12244
+ return;
11381
12245
  }
11382
- async load() {
11383
- const raw = await this.read(this.configPath);
11384
- if (!raw) return emptyConfig();
11385
- const file = (0, import_yaml.parse)(raw) ?? {};
11386
- return {
11387
- providers: (file.providers ?? []).filter(
11388
- (p3) => PROVIDER_IDS.includes(p3)
11389
- ),
11390
- sources: Object.entries(file.sources ?? {}).map(([id, s]) => ({
11391
- id,
11392
- ...s,
11393
- settings: s.settings ?? {}
11394
- })),
11395
- aliases: Object.entries(file.aliases ?? {}).map(([name, a2]) => ({ name, ...a2 }))
11396
- };
12246
+ const overview = await app.overview();
12247
+ if (flags.json) return printJson({ sources: overview.sources });
12248
+ if (overview.sources.length === 0) {
12249
+ console.log("Nothing here yet. Run `cloudhop setup` to get started.");
12250
+ return;
11397
12251
  }
11398
- async save(config) {
11399
- const file = {
11400
- providers: config.providers,
11401
- sources: Object.fromEntries(config.sources.map(({ id, ...rest }) => [id, rest])),
11402
- aliases: Object.fromEntries(config.aliases.map(({ name, ...rest }) => [name, rest]))
11403
- };
11404
- await this.write(this.configPath, HEADER + (0, import_yaml.stringify)(file));
12252
+ for (const source of overview.sources) {
12253
+ console.log(
12254
+ `${bold(paint("sky", source.id))} ${STATE_SHORT[source.session]}: ${NEXT_TEXT[source.next]}`
12255
+ );
11405
12256
  }
11406
- async loadTargets(sourceId) {
11407
- return (await this.allTargets())[sourceId] ?? [];
12257
+ }
12258
+
12259
+ // src/commands/run.ts
12260
+ async function proxyCommand(app, alias, args) {
12261
+ return app.runAsAlias(alias, args.length > 0 ? args : await app.identityArgs(alias));
12262
+ }
12263
+ async function execCommand(app, args) {
12264
+ const separator = args.indexOf("--");
12265
+ const before = separator === -1 ? args.slice(0, 1) : args.slice(0, separator);
12266
+ const command = separator === -1 ? args.slice(1) : args.slice(separator + 1);
12267
+ const [alias, ...flags] = before;
12268
+ if (!alias || command.length === 0) {
12269
+ throw new Error("Usage: cloudhop exec <alias> [--region <region>] -- <command> [args...]");
11408
12270
  }
11409
- async saveTargets(sourceId, targets) {
11410
- const all = await this.allTargets();
11411
- all[sourceId] = targets;
11412
- await this.write(this.targetsPath, JSON.stringify(all, null, 2));
12271
+ const overrides = {};
12272
+ const regionIndex = flags.indexOf("--region");
12273
+ if (regionIndex !== -1 && flags[regionIndex + 1])
12274
+ overrides.region = flags[regionIndex + 1];
12275
+ const [bin, ...rest] = command;
12276
+ return app.execAsAlias(alias, bin, rest, overrides);
12277
+ }
12278
+
12279
+ // src/commands/setup.ts
12280
+ var UPCOMING = ["Azure", "Google Cloud"];
12281
+ var WELCOME = [
12282
+ "cloudhop lets you hop between cloud accounts, roles and regions with one command.",
12283
+ "Add your SSO portals and personal accounts once and give each account/role a short",
12284
+ "alias. Then `hop <alias> <command>` runs the cloud CLI as that account: cloudhop",
12285
+ "logs you in when a session expires, and never stores access keys."
12286
+ ].join("\n");
12287
+ async function offerInstall(app, entry) {
12288
+ const { cli, tool, recipe, outdated } = entry;
12289
+ if (tool.installed && !outdated) return true;
12290
+ const problem = outdated ? `${cli.displayName} ${tool.version} is too old (cloudhop needs ${cli.minVersion}+).` : `${cli.displayName} is not installed. cloudhop can still log you in, but needs it to run \`${cli.command}\` commands.`;
12291
+ dist_exports.log.warn(problem);
12292
+ if (!recipe?.command) {
12293
+ dist_exports.log.info(
12294
+ `Install it from ${recipe?.manualUrl ?? "the official docs"}, then run \`cloudhop doctor\`.`
12295
+ );
12296
+ return false;
11413
12297
  }
11414
- async removeTargets(sourceId) {
11415
- const all = await this.allTargets();
11416
- delete all[sourceId];
11417
- await this.write(this.targetsPath, JSON.stringify(all, null, 2));
12298
+ const command = [recipe.command, ...recipe.args].join(" ");
12299
+ const install = answer(
12300
+ await dist_exports.confirm({ message: `Install it with ${recipe.via}? (${command})` })
12301
+ );
12302
+ if (!install) return false;
12303
+ const ok = await app.installCli(recipe);
12304
+ if (ok) dist_exports.log.success(`${cli.displayName} installed.`);
12305
+ else dist_exports.log.error(`Could not install automatically. See ${recipe.manualUrl}`);
12306
+ return ok;
12307
+ }
12308
+ async function setupCommand(app, assistants) {
12309
+ requireInteractive(
12310
+ "setup",
12311
+ "Run it in a regular terminal, or script it: `cloudhop provider enable aws`, then `cloudhop add aws sso --start-url <url> --region <r>`."
12312
+ );
12313
+ printBanner();
12314
+ dist_exports.intro("cloudhop setup");
12315
+ dist_exports.note(WELCOME, "Welcome");
12316
+ const config = await app.config();
12317
+ const providers = answer(
12318
+ await dist_exports.multiselect({
12319
+ message: "Which clouds do you use?",
12320
+ options: app.availableProviders().map((provider) => ({ value: provider.id, label: provider.displayName })),
12321
+ initialValues: config.providers.length > 0 ? config.providers : ["aws"],
12322
+ required: true
12323
+ })
12324
+ );
12325
+ dist_exports.log.info(`Coming soon: ${UPCOMING.join(", ")}.`);
12326
+ await app.setEnabledProviders(providers);
12327
+ for (const entry of await app.doctor()) {
12328
+ if (entry.tool.installed && !entry.outdated) {
12329
+ dist_exports.log.success(`${entry.cli.displayName} ${entry.tool.version ?? ""} found.`);
12330
+ } else {
12331
+ await offerInstall(app, entry);
12332
+ }
11418
12333
  }
11419
- async reset() {
11420
- await rm(this.dir, { recursive: true, force: true });
12334
+ const addNow = answer(await dist_exports.confirm({ message: "Add an account or SSO portal now?" }));
12335
+ if (addNow) await addCommand(app, { positional: [], flags: {} });
12336
+ else dist_exports.log.info("Run `cloudhop add` whenever you are ready.");
12337
+ const detected = assistants.detect();
12338
+ if (detected.length > 0) {
12339
+ const names = detected.map((id) => ASSISTANTS[id].name).join(", ");
12340
+ const teach = answer(
12341
+ await dist_exports.confirm({
12342
+ message: `Teach your AI assistant (${names}) to use cloudhop?`,
12343
+ initialValue: true
12344
+ })
12345
+ );
12346
+ if (teach) {
12347
+ await agentCommand(assistants, ["install"]);
12348
+ return;
12349
+ }
12350
+ dist_exports.log.info("You can do it later with `cloudhop agent install`.");
11421
12351
  }
11422
- async allTargets() {
11423
- const raw = await this.read(this.targetsPath);
11424
- return raw ? JSON.parse(raw) : {};
12352
+ if (!addNow) dist_exports.outro("All set.");
12353
+ }
12354
+ async function doctorCommand(app) {
12355
+ dist_exports.intro("cloudhop doctor");
12356
+ const entries = await app.doctor();
12357
+ if (entries.length === 0) {
12358
+ dist_exports.outro("No cloud enabled. Run `cloudhop setup`.");
12359
+ return;
11425
12360
  }
11426
- async read(path2) {
11427
- try {
11428
- return await readFile(path2, "utf8");
11429
- } catch (error) {
11430
- if (error.code === "ENOENT") return void 0;
11431
- throw error;
12361
+ let healthy = true;
12362
+ for (const entry of entries) {
12363
+ if (entry.tool.installed && !entry.outdated) {
12364
+ dist_exports.log.success(`${entry.displayName}: ${entry.cli.displayName} ${entry.tool.version ?? ""}`);
12365
+ } else {
12366
+ healthy = await offerInstall(app, entry) && healthy;
11432
12367
  }
11433
12368
  }
11434
- /** Writes through a temp file so a crash never leaves a half-written config. */
11435
- async write(path2, content) {
11436
- await mkdir(this.dir, { recursive: true, mode: 448 });
11437
- const tmp = `${path2}.${process.pid}.tmp`;
11438
- await writeFile(tmp, content, { mode: 384 });
11439
- await rename(tmp, path2);
11440
- }
11441
- };
12369
+ dist_exports.outro(
12370
+ healthy ? "Everything looks good." : "Some tools are missing; login and `cloudhop exec` still work."
12371
+ );
12372
+ }
11442
12373
 
11443
12374
  // ../ui/src/api.ts
11444
- var text2 = (body, key) => {
12375
+ var text3 = (body, key) => {
11445
12376
  const value = body[key];
11446
12377
  return typeof value === "string" ? value.trim() : "";
11447
12378
  };
@@ -11460,7 +12391,23 @@ var reportView = (report) => ({
11460
12391
  removed: report.removed.length,
11461
12392
  orphanedAliases: report.orphanedAliases
11462
12393
  });
11463
- function createApi(app) {
12394
+ var SCOPES = /* @__PURE__ */ new Set(["user", "project"]);
12395
+ var scopeOf = (value) => {
12396
+ if (SCOPES.has(value)) return value;
12397
+ throw badRequest('Scope must be "user" or "project".');
12398
+ };
12399
+ var assistantIds = (value) => {
12400
+ const ids = Array.isArray(value) ? value : [];
12401
+ if (ids.length === 0 || !ids.every((id) => ASSISTANT_IDS.includes(id))) {
12402
+ throw badRequest("Choose at least one assistant.");
12403
+ }
12404
+ return ids;
12405
+ };
12406
+ function createApi(app, assistants) {
12407
+ const requireAssistants = () => {
12408
+ if (!assistants) throw badRequest("AI assistant setup is not available here.");
12409
+ return assistants;
12410
+ };
11464
12411
  const regionCheck = (source, region) => {
11465
12412
  const validate = app.provider(source.provider).sourceKinds.find((k) => k.kind === source.kind)?.fields.find((f2) => f2.key === "region")?.validate;
11466
12413
  const problem = region ? validate?.(region) : "Region is required.";
@@ -11518,6 +12465,26 @@ function createApi(app) {
11518
12465
  }))
11519
12466
  };
11520
12467
  },
12468
+ /** Which AI assistants know cloudhop, for both scopes. Paths are shown, never file contents. */
12469
+ "GET /api/assistants": async () => {
12470
+ const installer = requireAssistants();
12471
+ return {
12472
+ project: process.cwd(),
12473
+ user: await installer.status("user"),
12474
+ projectScope: await installer.status("project")
12475
+ };
12476
+ },
12477
+ "POST /api/assistants": async (_2, body) => {
12478
+ const tools = assistantIds(body.tools);
12479
+ const scope = scopeOf(body.scope);
12480
+ return {
12481
+ results: await requireAssistants().install(tools, scope, { mcp: body.mcp !== false })
12482
+ };
12483
+ },
12484
+ "POST /api/assistants/remove": async (_2, body) => ({
12485
+ results: await requireAssistants().remove(assistantIds(body.tools), scopeOf(body.scope))
12486
+ }),
12487
+ "POST /api/assistants/update": async (_2, body) => requireAssistants().update({ force: body.force === true }),
11521
12488
  "GET /api/doctor": async () => (await app.doctor()).map((entry) => ({
11522
12489
  provider: entry.provider,
11523
12490
  cli: entry.cli.displayName,
@@ -11536,19 +12503,19 @@ function createApi(app) {
11536
12503
  },
11537
12504
  /** Adds a source and logs in right away, like `cloudhop add`; a failed login adds nothing. */
11538
12505
  "POST /api/sources": async (_2, body) => {
11539
- const provider = app.provider(text2(body, "provider"));
11540
- const kind = provider.sourceKinds.find((k) => k.kind === text2(body, "kind"));
12506
+ const provider = app.provider(text3(body, "provider"));
12507
+ const kind = provider.sourceKinds.find((k) => k.kind === text3(body, "kind"));
11541
12508
  if (!kind) throw badRequest("Choose what you want to add.");
11542
12509
  const input = body.settings ?? {};
11543
12510
  const settings2 = {};
11544
12511
  for (const field of kind.fields) {
11545
- const value = text2(input, field.key) || field.defaultValue || "";
12512
+ const value = text3(input, field.key) || field.defaultValue || "";
11546
12513
  if (!value) throw badRequest(`${field.label} is required.`);
11547
12514
  const problem = field.validate?.(value);
11548
12515
  if (problem) throw badRequest(`${field.label}: ${problem}`);
11549
12516
  settings2[field.key] = value;
11550
12517
  }
11551
- const id = text2(body, "id");
12518
+ const id = text3(body, "id");
11552
12519
  return reportView(
11553
12520
  await app.connectSource({ id, provider: provider.id, kind: kind.kind, settings: settings2 })
11554
12521
  );
@@ -11576,13 +12543,13 @@ function createApi(app) {
11576
12543
  if (items.length === 0) throw badRequest("Pick at least one account/role.");
11577
12544
  const aliases = [];
11578
12545
  for (const item of items) {
11579
- const source = await findSource(text2(item, "source"));
11580
- const region = text2(item, "region");
12546
+ const source = await findSource(text3(item, "source"));
12547
+ const region = text3(item, "region");
11581
12548
  if (region) regionCheck(source, region);
11582
12549
  aliases.push({
11583
- name: text2(item, "name"),
12550
+ name: text3(item, "name"),
11584
12551
  source: source.id,
11585
- target: text2(item, "target"),
12552
+ target: text3(item, "target"),
11586
12553
  ...region && region !== source.settings.region ? { region } : {}
11587
12554
  });
11588
12555
  }
@@ -11591,7 +12558,7 @@ function createApi(app) {
11591
12558
  },
11592
12559
  "PATCH /api/aliases/:name": async ([name], body) => {
11593
12560
  const resolved = await app.resolveAlias(name);
11594
- const region = text2(body, "region");
12561
+ const region = text3(body, "region");
11595
12562
  regionCheck(resolved.source, region);
11596
12563
  await app.setAliasRegion(name, region);
11597
12564
  return { name, region };
@@ -11631,7 +12598,7 @@ import { randomBytes, timingSafeEqual } from "crypto";
11631
12598
  import { createServer } from "http";
11632
12599
 
11633
12600
  // ../ui/src/assets/app.js
11634
- var app_default = "// cloudhop local UI. Plain DOM, no build step: served as-is by `cloudhop ui`.\n\nconst TOKEN_KEY = 'cloudhop-token';\nconst SESSION_TEXT = {\n valid: 'Session valid',\n refreshable: 'Renews on next use',\n expired: 'Expired: sign in on next use',\n unknown: 'Managed by the cloud CLI',\n};\n\n// The token arrives in the URL fragment once; keep it for this tab and clean the address bar.\nconst fromHash = new URLSearchParams(location.hash.slice(1)).get('token');\nif (fromHash) {\n try {\n sessionStorage.setItem(TOKEN_KEY, fromHash);\n } catch {}\n history.replaceState(null, '', location.pathname);\n}\nlet token = fromHash;\ntry {\n token = token ?? sessionStorage.getItem(TOKEN_KEY);\n} catch {}\n\n// A fresh link opened in this same tab only changes the fragment: reload to pick up its token.\naddEventListener('hashchange', () => {\n if (new URLSearchParams(location.hash.slice(1)).has('token')) location.reload();\n});\n\nconst $main = document.getElementById('main');\nconst $dialog = document.getElementById('dialog');\nconst $toasts = document.getElementById('toasts');\nconst $addSource = document.getElementById('add-source');\nlet state;\n\n/** Tiny element builder: h('button', { class: 'primary', onclick }, 'Save'). */\nfunction h(tag, attrs = {}, ...children) {\n const el = document.createElement(tag);\n for (const [key, value] of Object.entries(attrs)) {\n if (value === undefined || value === null || value === false) continue;\n if (key.startsWith('on')) el.addEventListener(key.slice(2), value);\n else if (key === 'class') el.className = value;\n else if (value === true) el.setAttribute(key, '');\n else el.setAttribute(key, value);\n }\n for (const child of children.flat()) {\n if (child === undefined || child === null || child === false) continue;\n el.append(child instanceof Node ? child : document.createTextNode(String(child)));\n }\n return el;\n}\n\nclass ApiError extends Error {\n constructor(message, hint) {\n super(message);\n this.hint = hint;\n }\n}\n\n// Calls that may sign in wait for the person in the browser; everything else should answer quickly.\nconst MAY_SIGN_IN = /^\\/api\\/sources(\\/[^/]+\\/(login|sync|targets))?$/;\nconst QUICK_TIMEOUT_MS = 20_000;\n\nasync function api(method, path, body) {\n const waitsForPerson = MAY_SIGN_IN.test(path);\n let response;\n try {\n response = await fetch(path, {\n method,\n headers: {\n 'x-cloudhop-token': token ?? '',\n ...(body ? { 'content-type': 'application/json' } : {}),\n },\n body: body ? JSON.stringify(body) : undefined,\n signal: waitsForPerson ? undefined : AbortSignal.timeout(QUICK_TIMEOUT_MS),\n });\n } catch (error) {\n if (error.name === 'TimeoutError') {\n throw new ApiError(\n 'cloudhop ui is not responding.',\n 'Is it paused in your terminal (Ctrl-Z)? Run `fg` there, or stop it and run `cloudhop ui` again.',\n );\n }\n throw new ApiError(\n 'cloudhop ui is not running.',\n 'Run `cloudhop ui` again and open the new link it prints.',\n );\n }\n const data = await response.json().catch(() => ({}));\n if (!response.ok)\n throw new ApiError(data.error ?? `Request failed (${response.status}).`, data.hint);\n return data;\n}\n\nfunction toast(message, { error = false, hint } = {}) {\n const el = h(\n 'div',\n { class: `toast${error ? ' error' : ''}` },\n message,\n hint && h('small', {}, hint),\n );\n $toasts.append(el);\n setTimeout(() => el.remove(), error ? 9000 : 4000);\n}\n\nconst fail = (error) => toast(error.message, { error: true, hint: error.hint });\n\n/** Disables a button while its action runs. */\nasync function busy(button, action) {\n button.disabled = true;\n try {\n return await action();\n } finally {\n button.disabled = false;\n }\n}\n\nasync function refresh() {\n try {\n state = await api('GET', '/api/state');\n render();\n checkDoctor();\n } catch (error) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Cannot reach cloudhop'),\n h('p', {}, error.message, error.hint ? ` ${error.hint}` : ''),\n ),\n );\n }\n}\n\nasync function checkDoctor() {\n try {\n const entries = await api('GET', '/api/doctor');\n const problems = entries.filter((e) => !e.installed || e.outdated);\n document.getElementById('doctor')?.remove();\n if (problems.length === 0) return;\n $main.prepend(\n h(\n 'div',\n { class: 'notice', id: 'doctor' },\n ...problems.map((e) =>\n h(\n 'div',\n {},\n e.installed\n ? `${e.cli} ${e.version} is older than ${e.minVersion}. `\n : `${e.cli} is not installed, so aliases cannot run commands yet. `,\n 'Run ',\n h('code', {}, 'cloudhop doctor'),\n ' in a terminal to install it.',\n ),\n ),\n ),\n );\n } catch {}\n}\n\n// ---------- main view ----------\n\nfunction render() {\n const enabled = state.enabledProviders;\n $addSource.hidden = enabled.length === 0;\n if (enabled.length === 0) return renderChooseClouds();\n if (state.sources.length === 0) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Add your first account'),\n h(\n 'p',\n {},\n 'Add a company SSO portal or a personal account. cloudhop signs you in, finds every account and role you can use, and lets you give each one a short alias.',\n ),\n h('button', { class: 'primary', onclick: () => openAddSource() }, '+ Add source'),\n ),\n );\n return;\n }\n $main.replaceChildren(...state.sources.map(sourceCard));\n}\n\nfunction renderChooseClouds() {\n const boxes = state.providers.map((p) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', { type: 'checkbox', value: p.id, checked: true }),\n h('span', {}, p.name),\n ),\n );\n const save = h(\n 'button',\n {\n class: 'primary',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const ids = boxes\n .map((b) => b.querySelector('input'))\n .filter((i) => i.checked)\n .map((i) => i.value);\n await api('PUT', '/api/providers', { enabled: ids }).catch(fail);\n await refresh();\n }),\n },\n 'Continue',\n );\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Which clouds do you use?'),\n h('p', {}, 'Azure and Google Cloud are coming soon.'),\n h(\n 'div',\n { class: 'choices', style: 'max-width:320px;margin:0 auto 16px;text-align:left' },\n boxes,\n ),\n save,\n ),\n );\n}\n\nfunction sourceCard(source) {\n const aliases = state.aliases.filter((a) => a.source === source.id);\n const where = source.settings.startUrl ?? source.kindLabel;\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, source.id),\n h('span', { class: `badge ${source.session}` }, SESSION_TEXT[source.session] ?? source.session),\n h('span', { class: 'meta' }, `${source.providerName} \xB7 ${where}`),\n h('div', { class: 'spacer' }),\n h(\n 'div',\n { class: 'actions' },\n h('button', { onclick: () => openAliasPicker(source) }, '+ Aliases'),\n h(\n 'button',\n {\n title: 'Sign in again and refresh its accounts',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n toast('Opening the browser to sign in\u2026');\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/login`,\n ).catch(fail);\n if (report)\n toast(`${source.id}: signed in, ${report.targets.length} account/role(s).`);\n await refresh();\n }),\n },\n 'Sign in',\n ),\n h(\n 'button',\n {\n title: 'Refresh the accounts and roles you have access to',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/sync`,\n ).catch(fail);\n if (report) toast(syncMessage(source.id, report));\n await refresh();\n }),\n },\n 'Sync',\n ),\n h(\n 'button',\n {\n class: 'danger',\n onclick: (e) => {\n const count = aliases.length;\n const message = `Remove \"${source.id}\"${count ? ` and its ${count} alias(es)` : ''}?`;\n if (!confirm(message)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/sources/${encodeURIComponent(source.id)}`).catch(fail);\n await refresh();\n });\n },\n },\n 'Remove',\n ),\n ),\n );\n\n const body =\n aliases.length === 0\n ? h(\n 'div',\n { class: 'empty' },\n 'No aliases yet. ',\n h(\n 'button',\n { class: 'primary', onclick: () => openAliasPicker(source) },\n 'Pick accounts',\n ),\n )\n : h(\n 'table',\n {},\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Alias'),\n h('th', {}, 'Region'),\n h('th', {}, 'Account / role'),\n h('th'),\n ),\n ),\n h('tbody', {}, aliases.map(aliasRow)),\n );\n return h('section', { class: 'card' }, head, h('div', { class: 'card-body' }, body));\n}\n\nfunction syncMessage(id, report) {\n const parts = [`${report.targets.length} available`];\n if (report.added) parts.push(`${report.added} new`);\n if (report.removed) parts.push(`${report.removed} removed`);\n const orphaned = report.orphanedAliases.length\n ? ` No longer available: ${report.orphanedAliases.join(', ')}.`\n : '';\n return `${id}: ${parts.join(', ')}.${orphaned}`;\n}\n\nfunction aliasRow(alias) {\n const input = h('input', {\n value: alias.region ?? '',\n 'aria-label': `Region for ${alias.name}`,\n class: 'mono',\n spellcheck: 'false',\n });\n const save = h('button', { hidden: true }, 'Save');\n const commit = () =>\n busy(save, async () => {\n const region = input.value.trim();\n const done = await api('PATCH', `/api/aliases/${encodeURIComponent(alias.name)}`, {\n region,\n }).catch(fail);\n if (done) toast(`${alias.name} now runs in ${region}.`);\n await refresh();\n });\n input.addEventListener('input', () => {\n save.hidden = input.value.trim() === (alias.region ?? '');\n });\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' && !save.hidden) commit();\n if (e.key === 'Escape') {\n input.value = alias.region ?? '';\n save.hidden = true;\n }\n });\n save.addEventListener('click', commit);\n\n return h(\n 'tr',\n {},\n h('td', { class: 'alias', title: `hop ${alias.name} <command>` }, alias.name),\n h('td', {}, h('div', { class: 'region' }, input, save)),\n h(\n 'td',\n {},\n alias.missing\n ? h('span', { class: 'missing' }, `${alias.target} (no longer available)`)\n : alias.label,\n ),\n h(\n 'td',\n { style: 'text-align:right' },\n h(\n 'button',\n {\n class: 'link',\n title: `Remove ${alias.name}`,\n 'aria-label': `Remove ${alias.name}`,\n onclick: (e) => {\n if (!confirm(`Remove alias \"${alias.name}\"?`)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/aliases/${encodeURIComponent(alias.name)}`).catch(fail);\n await refresh();\n });\n },\n },\n '\u2715',\n ),\n ),\n );\n}\n\n// ---------- dialogs ----------\n\nfunction showDialog(title, body, footer) {\n $dialog.replaceChildren(\n h(\n 'div',\n { class: 'dlg-head' },\n h('h2', {}, title),\n h('button', { class: 'link', 'aria-label': 'Close', onclick: () => $dialog.close() }, '\u2715'),\n ),\n h('div', { class: 'dlg-body' }, body),\n footer ? h('div', { class: 'dlg-foot' }, footer) : null,\n );\n if (!$dialog.open) $dialog.showModal();\n}\n\nfunction suggestSourceId(settings, kind) {\n const host = /^https:\\/\\/([^./]+)\\./.exec(settings.startUrl ?? '')?.[1];\n return slug(host ?? (kind === 'console-login' ? 'personal' : 'main'));\n}\n\nconst slug = (text) =>\n text\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 63) || 'main';\n\n/** `previous` refills the form after a failed sign-in: { provider, kind, id, settings, error }. */\nfunction openAddSource(previous) {\n const providers = state.providers.filter((p) => state.enabledProviders.includes(p.id));\n let provider = providers.find((p) => p.id === previous?.provider) ?? providers[0];\n let kind = provider.kinds.find((k) => k.kind === previous?.kind) ?? provider.kinds[0];\n\n const form = h('form', { onsubmit: (e) => e.preventDefault() });\n const nameInput = h('input', {\n id: 'f-name',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n });\n let nameTouched = Boolean(previous);\n nameInput.addEventListener('input', () => {\n nameTouched = true;\n });\n\n const fieldInputs = {};\n const fieldsBox = h('div');\n const nameLabel = h('label', { for: 'f-name' });\n\n function updateSuggestion() {\n if (nameTouched) return;\n const settings = Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n );\n const taken = new Set(state.sources.map((s) => s.id));\n let id = suggestSourceId(settings, kind.kind);\n for (let i = 2; taken.has(id); i++) id = `${suggestSourceId(settings, kind.kind)}-${i}`;\n nameInput.value = id;\n }\n\n function renderFields() {\n for (const key of Object.keys(fieldInputs)) delete fieldInputs[key];\n fieldsBox.replaceChildren(\n ...kind.fields.map((f) => {\n const input = h('input', {\n id: `f-${f.key}`,\n placeholder: f.placeholder ?? f.defaultValue ?? '',\n value: previous?.settings?.[f.key] ?? f.defaultValue ?? '',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n oninput: updateSuggestion,\n });\n fieldInputs[f.key] = input;\n return h('div', { class: 'field' }, h('label', { for: input.id }, f.label), input);\n }),\n );\n nameLabel.textContent = kind.nameLabel ?? 'Short name for this source';\n updateSuggestion();\n }\n\n const providerSelect =\n providers.length > 1\n ? h(\n 'div',\n { class: 'field' },\n h('label', { for: 'f-provider' }, 'Cloud'),\n h(\n 'select',\n {\n id: 'f-provider',\n onchange: (e) => {\n provider = providers.find((p) => p.id === e.target.value);\n kind = provider.kinds[0];\n renderKinds();\n },\n },\n providers.map((p) => h('option', { value: p.id, selected: p === provider }, p.name)),\n ),\n )\n : null;\n\n const kindsBox = h('div', { class: 'choices' });\n function renderKinds() {\n kindsBox.replaceChildren(\n ...provider.kinds.map((k) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', {\n type: 'radio',\n name: 'kind',\n value: k.kind,\n checked: k === kind,\n onchange: () => {\n kind = k;\n renderFields();\n },\n }),\n h('span', {}, h('strong', {}, k.label), k.hint ? h('small', {}, k.hint) : null),\n ),\n ),\n );\n renderFields();\n }\n\n form.append(\n previous?.error\n ? h(\n 'div',\n { class: 'notice error' },\n h('strong', {}, 'Could not sign in, so nothing was saved. '),\n previous.error.message,\n previous.error.hint ? h('div', { class: 'help' }, previous.error.hint) : null,\n )\n : '',\n providerSelect ?? '',\n h('div', { class: 'field' }, h('label', {}, 'What do you want to add?')),\n kindsBox,\n fieldsBox,\n h(\n 'div',\n { class: 'field' },\n nameLabel,\n nameInput,\n h(\n 'span',\n { class: 'help' },\n 'Lowercase letters, numbers and dashes. You will see it in ',\n h('code', {}, 'cloudhop ls'),\n '.',\n ),\n ),\n );\n renderKinds();\n if (previous) nameInput.value = previous.id;\n\n const submit = h('button', { class: 'primary', type: 'submit' }, 'Add and sign in');\n form.addEventListener('submit', async () => {\n if (!form.reportValidity()) return;\n const body = {\n provider: provider.id,\n kind: kind.kind,\n id: nameInput.value.trim(),\n settings: Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n ),\n };\n showDialog(\n 'Sign in',\n h(\n 'div',\n { class: 'waiting' },\n h('div', { class: 'spinner' }),\n h('strong', {}, 'Finish signing in in the browser tab that just opened.'),\n h('p', { class: 'help' }, 'This page continues on its own once you approve the request.'),\n ),\n );\n try {\n const report = await api('POST', '/api/sources', body);\n await refresh();\n const source = state.sources.find((s) => s.id === body.id);\n toast(`Signed in to ${body.id}. ${report.targets.length} account/role(s) found.`);\n openAliasPicker(source, report.targets);\n } catch (error) {\n // Nothing was saved: reopen the form with what was typed so it can be fixed.\n await refresh();\n openAddSource({ ...body, error });\n }\n });\n\n showDialog('Add source', form, [\n h('button', { type: 'button', onclick: () => $dialog.close() }, 'Cancel'),\n submit,\n ]);\n submit.setAttribute('form', 'add-form');\n form.id = 'add-form';\n}\n\nasync function openAliasPicker(source, preloaded) {\n let targets = preloaded;\n if (!targets) {\n showDialog(\n `Aliases for ${source.id}`,\n h('div', { class: 'waiting' }, h('div', { class: 'spinner' }), 'Loading accounts\u2026'),\n );\n try {\n targets = (await api('GET', `/api/sources/${encodeURIComponent(source.id)}/targets`)).targets;\n } catch (error) {\n $dialog.close();\n return fail(error);\n }\n }\n\n const used = new Map(\n state.aliases.filter((a) => a.source === source.id).map((a) => [a.target, a.name]),\n );\n const taken = new Set(state.aliases.map((a) => a.name));\n const defaultRegion = source.settings.region ?? '';\n const filter = h('input', {\n type: 'search',\n placeholder: `Filter ${targets.length} account/role(s)\u2026`,\n style: 'width:100%;margin-bottom:12px',\n });\n\n const rows = targets.map((t) => {\n const existing = used.get(t.id);\n let name = t.suggestedAlias;\n for (let i = 2; taken.has(name); i++) name = `${t.suggestedAlias}-${i}`;\n const check = h('input', {\n type: 'checkbox',\n disabled: Boolean(existing),\n 'aria-label': t.label,\n });\n const aliasInput = h('input', {\n value: name,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Alias for ${t.label}`,\n placeholder: 'alias',\n });\n const regionInput = h('input', {\n value: defaultRegion,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Region for ${t.label}`,\n placeholder: 'region',\n });\n // Only the checkbox and its text sit in the label, so clicking the inputs never toggles it.\n const el = h(\n 'div',\n { class: 'target' },\n h(\n 'label',\n { class: 'pick' },\n check,\n h(\n 'span',\n {},\n t.label,\n existing ? h('small', { class: 'already' }, `Already: ${existing}`) : null,\n ),\n ),\n h('div', { class: 'inputs' }, aliasInput, regionInput),\n );\n return { target: t, el, check, aliasInput, regionInput };\n });\n\n filter.addEventListener('input', () => {\n const q = filter.value.trim().toLowerCase();\n for (const row of rows) row.el.hidden = q !== '' && !row.target.label.toLowerCase().includes(q);\n });\n\n const save = h('button', { class: 'primary' }, 'Create aliases');\n save.addEventListener('click', () =>\n busy(save, async () => {\n const aliases = rows\n .filter((r) => r.check.checked)\n .map((r) => ({\n source: source.id,\n target: r.target.id,\n name: r.aliasInput.value.trim(),\n region: r.regionInput.value.trim(),\n }));\n if (aliases.length === 0) return toast('Pick at least one account/role.', { error: true });\n try {\n const result = await api('POST', '/api/aliases', { aliases });\n $dialog.close();\n toast(`Created ${result.created.join(', ')}. Try: hop ${result.created[0]}`);\n await refresh();\n } catch (error) {\n fail(error);\n }\n }),\n );\n\n const body =\n targets.length === 0\n ? h('p', { class: 'empty' }, 'This source has no accounts or roles assigned to you.')\n : h(\n 'div',\n {},\n filter,\n h(\n 'div',\n { class: 'targets' },\n rows.map((r) => r.el),\n ),\n );\n showDialog(`Aliases for ${source.id}`, body, [\n h('span', { class: 'note' }, 'Check an account/role, then name its alias and region.'),\n h('button', { onclick: () => $dialog.close() }, 'Cancel'),\n targets.length ? save : null,\n ]);\n}\n\n$addSource.addEventListener('click', () => openAddSource());\n\nif (!token) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Open the link from your terminal'),\n h('p', {}, 'Run cloudhop ui and open the link it prints. It includes a one-time access key.'),\n ),\n );\n} else {\n refresh();\n}\n";
12601
+ var app_default = "// cloudhop local UI. Plain DOM, no build step: served as-is by `cloudhop ui`.\n\nconst TOKEN_KEY = 'cloudhop-token';\nconst SESSION_TEXT = {\n valid: 'Session valid',\n refreshable: 'Renews on next use',\n expired: 'Expired: sign in on next use',\n unknown: 'Managed by the cloud CLI',\n};\n\n// The token arrives in the URL fragment once; keep it for this tab and clean the address bar.\nconst fromHash = new URLSearchParams(location.hash.slice(1)).get('token');\nif (fromHash) {\n try {\n sessionStorage.setItem(TOKEN_KEY, fromHash);\n } catch {}\n history.replaceState(null, '', location.pathname);\n}\nlet token = fromHash;\ntry {\n token = token ?? sessionStorage.getItem(TOKEN_KEY);\n} catch {}\n\n// A fresh link opened in this same tab only changes the fragment: reload to pick up its token.\naddEventListener('hashchange', () => {\n if (new URLSearchParams(location.hash.slice(1)).has('token')) location.reload();\n});\n\nconst $main = document.getElementById('main');\nconst $dialog = document.getElementById('dialog');\nconst $toasts = document.getElementById('toasts');\nconst $addSource = document.getElementById('add-source');\nlet state;\n\n/** Tiny element builder: h('button', { class: 'primary', onclick }, 'Save'). */\nfunction h(tag, attrs = {}, ...children) {\n const el = document.createElement(tag);\n for (const [key, value] of Object.entries(attrs)) {\n if (value === undefined || value === null || value === false) continue;\n if (key.startsWith('on')) el.addEventListener(key.slice(2), value);\n else if (key === 'class') el.className = value;\n else if (value === true) el.setAttribute(key, '');\n else el.setAttribute(key, value);\n }\n for (const child of children.flat()) {\n if (child === undefined || child === null || child === false) continue;\n el.append(child instanceof Node ? child : document.createTextNode(String(child)));\n }\n return el;\n}\n\nclass ApiError extends Error {\n constructor(message, hint) {\n super(message);\n this.hint = hint;\n }\n}\n\n// Calls that may sign in wait for the person in the browser; everything else should answer quickly.\nconst MAY_SIGN_IN = /^\\/api\\/sources(\\/[^/]+\\/(login|sync|targets))?$/;\nconst QUICK_TIMEOUT_MS = 20_000;\n\nasync function api(method, path, body) {\n const waitsForPerson = MAY_SIGN_IN.test(path);\n let response;\n try {\n response = await fetch(path, {\n method,\n headers: {\n 'x-cloudhop-token': token ?? '',\n ...(body ? { 'content-type': 'application/json' } : {}),\n },\n body: body ? JSON.stringify(body) : undefined,\n signal: waitsForPerson ? undefined : AbortSignal.timeout(QUICK_TIMEOUT_MS),\n });\n } catch (error) {\n if (error.name === 'TimeoutError') {\n throw new ApiError(\n 'cloudhop ui is not responding.',\n 'Is it paused in your terminal (Ctrl-Z)? Run `fg` there, or stop it and run `cloudhop ui` again.',\n );\n }\n throw new ApiError(\n 'cloudhop ui is not running.',\n 'Run `cloudhop ui` again and open the new link it prints.',\n );\n }\n const data = await response.json().catch(() => ({}));\n if (!response.ok)\n throw new ApiError(data.error ?? `Request failed (${response.status}).`, data.hint);\n return data;\n}\n\nfunction toast(message, { error = false, hint } = {}) {\n const el = h(\n 'div',\n { class: `toast${error ? ' error' : ''}` },\n message,\n hint && h('small', {}, hint),\n );\n $toasts.append(el);\n setTimeout(() => el.remove(), error ? 9000 : 4000);\n}\n\nconst fail = (error) => toast(error.message, { error: true, hint: error.hint });\n\n/** Disables a button while its action runs. */\nasync function busy(button, action) {\n button.disabled = true;\n try {\n return await action();\n } finally {\n button.disabled = false;\n }\n}\n\nasync function refresh() {\n try {\n state = await api('GET', '/api/state');\n render();\n checkDoctor();\n } catch (error) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Cannot reach cloudhop'),\n h('p', {}, error.message, error.hint ? ` ${error.hint}` : ''),\n ),\n );\n }\n}\n\nasync function checkDoctor() {\n try {\n const entries = await api('GET', '/api/doctor');\n const problems = entries.filter((e) => !e.installed || e.outdated);\n document.getElementById('doctor')?.remove();\n if (problems.length === 0) return;\n $main.prepend(\n h(\n 'div',\n { class: 'notice', id: 'doctor' },\n ...problems.map((e) =>\n h(\n 'div',\n {},\n e.installed\n ? `${e.cli} ${e.version} is older than ${e.minVersion}. `\n : `${e.cli} is not installed, so aliases cannot run commands yet. `,\n 'Run ',\n h('code', {}, 'cloudhop doctor'),\n ' in a terminal to install it.',\n ),\n ),\n ),\n );\n } catch {}\n}\n\n// ---------- main view ----------\n\nfunction render() {\n const enabled = state.enabledProviders;\n $addSource.hidden = enabled.length === 0;\n if (enabled.length === 0) return renderChooseClouds();\n if (state.sources.length === 0) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Add your first account'),\n h(\n 'p',\n {},\n 'Add a company SSO portal or a personal account. cloudhop signs you in, finds every account and role you can use, and lets you give each one a short alias.',\n ),\n h('button', { class: 'primary', onclick: () => openAddSource() }, '+ Add source'),\n ),\n assistantsCard(),\n );\n loadAssistants();\n return;\n }\n $main.replaceChildren(...state.sources.map(sourceCard), assistantsCard());\n loadAssistants();\n}\n\n// ---------- AI assistants ----------\n\nlet assistantScope = 'user';\ntry {\n assistantScope =\n localStorage.getItem('cloudhop-assistant-scope') === 'project' ? 'project' : 'user';\n} catch {}\nlet assistantsData;\n\nconst SKILL_TEXT = {\n current: ['valid', 'Up to date'],\n outdated: ['refreshable', 'Outdated'],\n modified: ['refreshable', 'Edited by you'],\n missing: ['', 'Not installed'],\n};\nconst MCP_TEXT = { registered: 'On', missing: 'Off', unknown: 'Check in the app' };\n\nfunction assistantsCard() {\n return h('section', { class: 'card', id: 'assistants', hidden: true });\n}\n\nasync function loadAssistants() {\n try {\n assistantsData = await api('GET', '/api/assistants');\n renderAssistants();\n } catch {\n document.getElementById('assistants')?.remove();\n }\n}\n\nfunction setAssistantScope(scope) {\n assistantScope = scope;\n try {\n localStorage.setItem('cloudhop-assistant-scope', scope);\n } catch {}\n renderAssistants();\n}\n\nfunction assistantAction(label, run, { danger = false, confirmText } = {}) {\n return h(\n 'button',\n {\n class: danger ? 'danger' : undefined,\n onclick: (e) => {\n if (confirmText && !confirm(confirmText)) return;\n busy(e.currentTarget, async () => {\n try {\n await run();\n } catch (error) {\n fail(error);\n }\n await loadAssistants();\n });\n },\n },\n label,\n );\n}\n\nfunction reportInstall(results) {\n for (const r of results) {\n if (r.mcp === 'manual') {\n toast(`${r.name}: skill installed. Add the MCP server by hand.`, { hint: r.mcpManual });\n } else {\n toast(`${r.name} now knows cloudhop. Start a new chat so it picks it up.`);\n }\n }\n}\n\nfunction renderAssistants() {\n const el = document.getElementById('assistants');\n if (!el || !assistantsData) return;\n const rows = assistantScope === 'user' ? assistantsData.user : assistantsData.projectScope;\n const scope = assistantScope;\n const install = (tools) =>\n api('POST', '/api/assistants', { tools, scope, mcp: true }).then((r) =>\n reportInstall(r.results),\n );\n const missingDetected = rows\n .filter((r) => r.detected && r.skill === 'missing')\n .map((r) => r.assistant);\n\n const seg = h(\n 'div',\n { class: 'seg', role: 'group', 'aria-label': 'Where' },\n h(\n 'button',\n { 'aria-pressed': String(scope === 'user'), onclick: () => setAssistantScope('user') },\n 'For me',\n ),\n h(\n 'button',\n { 'aria-pressed': String(scope === 'project'), onclick: () => setAssistantScope('project') },\n 'This project',\n ),\n );\n\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, 'AI assistants'),\n h(\n 'span',\n { class: 'meta' },\n scope === 'user'\n ? 'Installed in your home folder, for every project'\n : assistantsData.project,\n ),\n h('div', { class: 'spacer' }),\n h(\n 'div',\n { class: 'actions' },\n seg,\n missingDetected.length > 1\n ? assistantAction('Install for all detected', () => install(missingDetected))\n : null,\n ),\n );\n\n const table = h(\n 'table',\n { class: 'assistants' },\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Assistant'),\n h('th', {}, 'Skill'),\n h('th', {}, 'MCP server'),\n h('th'),\n ),\n ),\n h(\n 'tbody',\n {},\n rows.map((r) => {\n const [badgeClass, skillText] = SKILL_TEXT[r.skill];\n const actions = [];\n if (r.skill === 'missing')\n actions.push(assistantAction('Install', () => install([r.assistant])));\n if (r.skill === 'outdated') {\n actions.push(\n assistantAction('Update', () =>\n api('POST', '/api/assistants/update', {}).then(() =>\n toast(`${r.name}: skill updated.`),\n ),\n ),\n );\n }\n if (r.skill === 'modified') {\n actions.push(\n assistantAction(\n 'Replace',\n () =>\n api('POST', '/api/assistants/update', { force: true }).then(() =>\n toast('Edited skills replaced.'),\n ),\n {\n confirmText: 'Replace your edited copies with the current cloudhop skill?',\n },\n ),\n );\n }\n if (r.skill !== 'missing' || r.mcp === 'registered') {\n actions.push(\n assistantAction(\n 'Remove',\n () => api('POST', '/api/assistants/remove', { tools: [r.assistant], scope }),\n {\n danger: true,\n confirmText: `Remove the cloudhop skill and MCP server from ${r.name}?`,\n },\n ),\n );\n }\n return h(\n 'tr',\n {},\n h(\n 'td',\n { class: 'alias' },\n r.name,\n r.detected ? null : h('small', { class: 'muted-inline' }, ' not detected'),\n ),\n h('td', {}, h('span', { class: `badge ${badgeClass}`, title: r.skillPath }, skillText)),\n h('td', {}, r.skill === 'missing' && r.mcp !== 'registered' ? '' : MCP_TEXT[r.mcp]),\n h(\n 'td',\n { style: 'text-align:right' },\n h('div', { class: 'actions', style: 'justify-content:flex-end' }, actions),\n ),\n );\n }),\n ),\n );\n\n el.replaceChildren(\n head,\n h(\n 'div',\n { class: 'card-body' },\n h(\n 'p',\n { class: 'card-help' },\n 'Teach your AI assistant to use cloudhop: it asks you which alias to work with, runs commands with hop, and confirms before changing anything. Includes the cloudhop MCP server.',\n ),\n table,\n ),\n );\n el.hidden = false;\n}\n\nfunction renderChooseClouds() {\n const boxes = state.providers.map((p) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', { type: 'checkbox', value: p.id, checked: true }),\n h('span', {}, p.name),\n ),\n );\n const save = h(\n 'button',\n {\n class: 'primary',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const ids = boxes\n .map((b) => b.querySelector('input'))\n .filter((i) => i.checked)\n .map((i) => i.value);\n await api('PUT', '/api/providers', { enabled: ids }).catch(fail);\n await refresh();\n }),\n },\n 'Continue',\n );\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Which clouds do you use?'),\n h('p', {}, 'Azure and Google Cloud are coming soon.'),\n h(\n 'div',\n { class: 'choices', style: 'max-width:320px;margin:0 auto 16px;text-align:left' },\n boxes,\n ),\n save,\n ),\n );\n}\n\nfunction sourceCard(source) {\n const aliases = state.aliases.filter((a) => a.source === source.id);\n const where = source.settings.startUrl ?? source.kindLabel;\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, source.id),\n h('span', { class: `badge ${source.session}` }, SESSION_TEXT[source.session] ?? source.session),\n h('span', { class: 'meta' }, `${source.providerName} \xB7 ${where}`),\n h('div', { class: 'spacer' }),\n h(\n 'div',\n { class: 'actions' },\n h('button', { onclick: () => openAliasPicker(source) }, '+ Aliases'),\n h(\n 'button',\n {\n title: 'Sign in again and refresh its accounts',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n toast('Opening the browser to sign in\u2026');\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/login`,\n ).catch(fail);\n if (report)\n toast(`${source.id}: signed in, ${report.targets.length} account/role(s).`);\n await refresh();\n }),\n },\n 'Sign in',\n ),\n h(\n 'button',\n {\n title: 'Refresh the accounts and roles you have access to',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/sync`,\n ).catch(fail);\n if (report) toast(syncMessage(source.id, report));\n await refresh();\n }),\n },\n 'Sync',\n ),\n h(\n 'button',\n {\n class: 'danger',\n onclick: (e) => {\n const count = aliases.length;\n const message = `Remove \"${source.id}\"${count ? ` and its ${count} alias(es)` : ''}?`;\n if (!confirm(message)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/sources/${encodeURIComponent(source.id)}`).catch(fail);\n await refresh();\n });\n },\n },\n 'Remove',\n ),\n ),\n );\n\n const body =\n aliases.length === 0\n ? h(\n 'div',\n { class: 'empty' },\n 'No aliases yet. ',\n h(\n 'button',\n { class: 'primary', onclick: () => openAliasPicker(source) },\n 'Pick accounts',\n ),\n )\n : h(\n 'table',\n {},\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Alias'),\n h('th', {}, 'Region'),\n h('th', {}, 'Account / role'),\n h('th'),\n ),\n ),\n h('tbody', {}, aliases.map(aliasRow)),\n );\n return h('section', { class: 'card' }, head, h('div', { class: 'card-body' }, body));\n}\n\nfunction syncMessage(id, report) {\n const parts = [`${report.targets.length} available`];\n if (report.added) parts.push(`${report.added} new`);\n if (report.removed) parts.push(`${report.removed} removed`);\n const orphaned = report.orphanedAliases.length\n ? ` No longer available: ${report.orphanedAliases.join(', ')}.`\n : '';\n return `${id}: ${parts.join(', ')}.${orphaned}`;\n}\n\nfunction aliasRow(alias) {\n const input = h('input', {\n value: alias.region ?? '',\n 'aria-label': `Region for ${alias.name}`,\n class: 'mono',\n spellcheck: 'false',\n });\n const save = h('button', { hidden: true }, 'Save');\n const commit = () =>\n busy(save, async () => {\n const region = input.value.trim();\n const done = await api('PATCH', `/api/aliases/${encodeURIComponent(alias.name)}`, {\n region,\n }).catch(fail);\n if (done) toast(`${alias.name} now runs in ${region}.`);\n await refresh();\n });\n input.addEventListener('input', () => {\n save.hidden = input.value.trim() === (alias.region ?? '');\n });\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' && !save.hidden) commit();\n if (e.key === 'Escape') {\n input.value = alias.region ?? '';\n save.hidden = true;\n }\n });\n save.addEventListener('click', commit);\n\n return h(\n 'tr',\n {},\n h('td', { class: 'alias', title: `hop ${alias.name} <command>` }, alias.name),\n h('td', {}, h('div', { class: 'region' }, input, save)),\n h(\n 'td',\n {},\n alias.missing\n ? h('span', { class: 'missing' }, `${alias.target} (no longer available)`)\n : alias.label,\n ),\n h(\n 'td',\n { style: 'text-align:right' },\n h(\n 'button',\n {\n class: 'link',\n title: `Remove ${alias.name}`,\n 'aria-label': `Remove ${alias.name}`,\n onclick: (e) => {\n if (!confirm(`Remove alias \"${alias.name}\"?`)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/aliases/${encodeURIComponent(alias.name)}`).catch(fail);\n await refresh();\n });\n },\n },\n '\u2715',\n ),\n ),\n );\n}\n\n// ---------- dialogs ----------\n\nfunction showDialog(title, body, footer) {\n $dialog.replaceChildren(\n h(\n 'div',\n { class: 'dlg-head' },\n h('h2', {}, title),\n h('button', { class: 'link', 'aria-label': 'Close', onclick: () => $dialog.close() }, '\u2715'),\n ),\n h('div', { class: 'dlg-body' }, body),\n footer ? h('div', { class: 'dlg-foot' }, footer) : null,\n );\n if (!$dialog.open) $dialog.showModal();\n}\n\nfunction suggestSourceId(settings, kind) {\n const host = /^https:\\/\\/([^./]+)\\./.exec(settings.startUrl ?? '')?.[1];\n return slug(host ?? (kind === 'console-login' ? 'personal' : 'main'));\n}\n\nconst slug = (text) =>\n text\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 63) || 'main';\n\n/** `previous` refills the form after a failed sign-in: { provider, kind, id, settings, error }. */\nfunction openAddSource(previous) {\n const providers = state.providers.filter((p) => state.enabledProviders.includes(p.id));\n let provider = providers.find((p) => p.id === previous?.provider) ?? providers[0];\n let kind = provider.kinds.find((k) => k.kind === previous?.kind) ?? provider.kinds[0];\n\n const form = h('form', { onsubmit: (e) => e.preventDefault() });\n const nameInput = h('input', {\n id: 'f-name',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n });\n let nameTouched = Boolean(previous);\n nameInput.addEventListener('input', () => {\n nameTouched = true;\n });\n\n const fieldInputs = {};\n const fieldsBox = h('div');\n const nameLabel = h('label', { for: 'f-name' });\n\n function updateSuggestion() {\n if (nameTouched) return;\n const settings = Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n );\n const taken = new Set(state.sources.map((s) => s.id));\n let id = suggestSourceId(settings, kind.kind);\n for (let i = 2; taken.has(id); i++) id = `${suggestSourceId(settings, kind.kind)}-${i}`;\n nameInput.value = id;\n }\n\n function renderFields() {\n for (const key of Object.keys(fieldInputs)) delete fieldInputs[key];\n fieldsBox.replaceChildren(\n ...kind.fields.map((f) => {\n const input = h('input', {\n id: `f-${f.key}`,\n placeholder: f.placeholder ?? f.defaultValue ?? '',\n value: previous?.settings?.[f.key] ?? f.defaultValue ?? '',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n oninput: updateSuggestion,\n });\n fieldInputs[f.key] = input;\n return h('div', { class: 'field' }, h('label', { for: input.id }, f.label), input);\n }),\n );\n nameLabel.textContent = kind.nameLabel ?? 'Short name for this source';\n updateSuggestion();\n }\n\n const providerSelect =\n providers.length > 1\n ? h(\n 'div',\n { class: 'field' },\n h('label', { for: 'f-provider' }, 'Cloud'),\n h(\n 'select',\n {\n id: 'f-provider',\n onchange: (e) => {\n provider = providers.find((p) => p.id === e.target.value);\n kind = provider.kinds[0];\n renderKinds();\n },\n },\n providers.map((p) => h('option', { value: p.id, selected: p === provider }, p.name)),\n ),\n )\n : null;\n\n const kindsBox = h('div', { class: 'choices' });\n function renderKinds() {\n kindsBox.replaceChildren(\n ...provider.kinds.map((k) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', {\n type: 'radio',\n name: 'kind',\n value: k.kind,\n checked: k === kind,\n onchange: () => {\n kind = k;\n renderFields();\n },\n }),\n h('span', {}, h('strong', {}, k.label), k.hint ? h('small', {}, k.hint) : null),\n ),\n ),\n );\n renderFields();\n }\n\n form.append(\n previous?.error\n ? h(\n 'div',\n { class: 'notice error' },\n h('strong', {}, 'Could not sign in, so nothing was saved. '),\n previous.error.message,\n previous.error.hint ? h('div', { class: 'help' }, previous.error.hint) : null,\n )\n : '',\n providerSelect ?? '',\n h('div', { class: 'field' }, h('label', {}, 'What do you want to add?')),\n kindsBox,\n fieldsBox,\n h(\n 'div',\n { class: 'field' },\n nameLabel,\n nameInput,\n h(\n 'span',\n { class: 'help' },\n 'Lowercase letters, numbers and dashes. You will see it in ',\n h('code', {}, 'cloudhop ls'),\n '.',\n ),\n ),\n );\n renderKinds();\n if (previous) nameInput.value = previous.id;\n\n const submit = h('button', { class: 'primary', type: 'submit' }, 'Add and sign in');\n form.addEventListener('submit', async () => {\n if (!form.reportValidity()) return;\n const body = {\n provider: provider.id,\n kind: kind.kind,\n id: nameInput.value.trim(),\n settings: Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n ),\n };\n showDialog(\n 'Sign in',\n h(\n 'div',\n { class: 'waiting' },\n h('div', { class: 'spinner' }),\n h('strong', {}, 'Finish signing in in the browser tab that just opened.'),\n h('p', { class: 'help' }, 'This page continues on its own once you approve the request.'),\n ),\n );\n try {\n const report = await api('POST', '/api/sources', body);\n await refresh();\n const source = state.sources.find((s) => s.id === body.id);\n toast(`Signed in to ${body.id}. ${report.targets.length} account/role(s) found.`);\n openAliasPicker(source, report.targets);\n } catch (error) {\n // Nothing was saved: reopen the form with what was typed so it can be fixed.\n await refresh();\n openAddSource({ ...body, error });\n }\n });\n\n showDialog('Add source', form, [\n h('button', { type: 'button', onclick: () => $dialog.close() }, 'Cancel'),\n submit,\n ]);\n submit.setAttribute('form', 'add-form');\n form.id = 'add-form';\n}\n\nasync function openAliasPicker(source, preloaded) {\n let targets = preloaded;\n if (!targets) {\n showDialog(\n `Aliases for ${source.id}`,\n h('div', { class: 'waiting' }, h('div', { class: 'spinner' }), 'Loading accounts\u2026'),\n );\n try {\n targets = (await api('GET', `/api/sources/${encodeURIComponent(source.id)}/targets`)).targets;\n } catch (error) {\n $dialog.close();\n return fail(error);\n }\n }\n\n const used = new Map(\n state.aliases.filter((a) => a.source === source.id).map((a) => [a.target, a.name]),\n );\n const taken = new Set(state.aliases.map((a) => a.name));\n const defaultRegion = source.settings.region ?? '';\n const filter = h('input', {\n type: 'search',\n placeholder: `Filter ${targets.length} account/role(s)\u2026`,\n style: 'width:100%;margin-bottom:12px',\n });\n\n const rows = targets.map((t) => {\n const existing = used.get(t.id);\n let name = t.suggestedAlias;\n for (let i = 2; taken.has(name); i++) name = `${t.suggestedAlias}-${i}`;\n const check = h('input', {\n type: 'checkbox',\n disabled: Boolean(existing),\n 'aria-label': t.label,\n });\n const aliasInput = h('input', {\n value: name,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Alias for ${t.label}`,\n placeholder: 'alias',\n });\n const regionInput = h('input', {\n value: defaultRegion,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Region for ${t.label}`,\n placeholder: 'region',\n });\n // Only the checkbox and its text sit in the label, so clicking the inputs never toggles it.\n const el = h(\n 'div',\n { class: 'target' },\n h(\n 'label',\n { class: 'pick' },\n check,\n h(\n 'span',\n {},\n t.label,\n existing ? h('small', { class: 'already' }, `Already: ${existing}`) : null,\n ),\n ),\n h('div', { class: 'inputs' }, aliasInput, regionInput),\n );\n return { target: t, el, check, aliasInput, regionInput };\n });\n\n filter.addEventListener('input', () => {\n const q = filter.value.trim().toLowerCase();\n for (const row of rows) row.el.hidden = q !== '' && !row.target.label.toLowerCase().includes(q);\n });\n\n const save = h('button', { class: 'primary' }, 'Create aliases');\n save.addEventListener('click', () =>\n busy(save, async () => {\n const aliases = rows\n .filter((r) => r.check.checked)\n .map((r) => ({\n source: source.id,\n target: r.target.id,\n name: r.aliasInput.value.trim(),\n region: r.regionInput.value.trim(),\n }));\n if (aliases.length === 0) return toast('Pick at least one account/role.', { error: true });\n try {\n const result = await api('POST', '/api/aliases', { aliases });\n $dialog.close();\n toast(`Created ${result.created.join(', ')}. Try: hop ${result.created[0]}`);\n await refresh();\n } catch (error) {\n fail(error);\n }\n }),\n );\n\n const body =\n targets.length === 0\n ? h('p', { class: 'empty' }, 'This source has no accounts or roles assigned to you.')\n : h(\n 'div',\n {},\n filter,\n h(\n 'div',\n { class: 'targets' },\n rows.map((r) => r.el),\n ),\n );\n showDialog(`Aliases for ${source.id}`, body, [\n h('span', { class: 'note' }, 'Check an account/role, then name its alias and region.'),\n h('button', { onclick: () => $dialog.close() }, 'Cancel'),\n targets.length ? save : null,\n ]);\n}\n\n$addSource.addEventListener('click', () => openAddSource());\n\nif (!token) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Open the link from your terminal'),\n h('p', {}, 'Run cloudhop ui and open the link it prints. It includes a one-time access key.'),\n ),\n );\n} else {\n refresh();\n}\n";
11635
12602
 
11636
12603
  // ../ui/src/assets/index.html
11637
12604
  var assets_default = `<!doctype html>
@@ -11877,6 +12844,15 @@ var assets_default = `<!doctype html>
11877
12844
  margin-bottom: 18px;
11878
12845
  }
11879
12846
  .notice.error { border-left-color: var(--bad); }
12847
+ .seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
12848
+ .seg button { border: 0; border-radius: 0; }
12849
+ .seg button + button { border-left: 1px solid var(--border); }
12850
+ .seg button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-strong); font-weight: 600; }
12851
+ .card-help { color: var(--muted); font-size: 14px; margin: 10px 16px 6px; }
12852
+ .muted-inline { color: var(--muted); font-weight: 400; font-size: 12px; }
12853
+ table.assistants th:nth-child(2) { width: 170px; }
12854
+ table.assistants th:nth-child(3) { width: 140px; }
12855
+ table.assistants th:nth-child(4) { width: 230px; }
11880
12856
  @media (max-width: 640px) {
11881
12857
  .tagline { display: none; }
11882
12858
  th:nth-child(3), td:nth-child(3) { display: none; }
@@ -11885,6 +12861,10 @@ var assets_default = `<!doctype html>
11885
12861
  .region input { width: 96px; }
11886
12862
  th:nth-child(1) { width: auto; }
11887
12863
  th:nth-child(2) { width: 124px; }
12864
+ table.assistants th:nth-child(2) { width: 112px; }
12865
+ table.assistants th:nth-child(4) { width: 96px; }
12866
+ table.assistants .actions { flex-direction: column; align-items: flex-end; }
12867
+ .card-help { margin: 10px 10px 6px; }
11888
12868
  }
11889
12869
  </style>
11890
12870
  </head>
@@ -11932,9 +12912,13 @@ async function readJson(req) {
11932
12912
  return raw ? JSON.parse(raw) : {};
11933
12913
  }
11934
12914
  var sameSecret = (given, expected) => given !== void 0 && given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
11935
- async function startUiServer({ app, port = 0 }) {
12915
+ async function startUiServer({
12916
+ app,
12917
+ assistants,
12918
+ port = 0
12919
+ }) {
11936
12920
  const token = randomBytes(24).toString("base64url");
11937
- const handle = createApi(app);
12921
+ const handle = createApi(app, assistants);
11938
12922
  let allowedHosts = /* @__PURE__ */ new Set();
11939
12923
  const server = createServer(async (req, res) => {
11940
12924
  try {
@@ -11994,12 +12978,12 @@ async function startUiServer({ app, port = 0 }) {
11994
12978
  }
11995
12979
 
11996
12980
  // src/commands/ui.ts
11997
- async function uiCommand(app, { flags }) {
12981
+ async function uiCommand(app, assistants, { flags }) {
11998
12982
  const port = flags.port ? Number(flags.port) : 0;
11999
12983
  if (!Number.isInteger(port) || port < 0 || port > 65535) {
12000
12984
  throw new CloudhopError("INVALID_NAME", `--port must be a number between 1 and 65535.`);
12001
12985
  }
12002
- const server = await startUiServer({ app, port });
12986
+ const server = await startUiServer({ app, assistants, port });
12003
12987
  console.log(`cloudhop UI running at ${bold(server.url)}`);
12004
12988
  console.log("Only this computer can open it, and the link works only while this runs.");
12005
12989
  console.log("Press Ctrl-C to stop.");
@@ -12012,15 +12996,15 @@ async function uiCommand(app, { flags }) {
12012
12996
  }
12013
12997
 
12014
12998
  // src/container.ts
12015
- import { homedir as homedir2 } from "os";
12016
- import { join as join5 } from "path";
12999
+ import { homedir as homedir3 } from "os";
13000
+ import { join as join7 } from "path";
12017
13001
 
12018
13002
  // ../provider-aws/src/aws-provider.ts
12019
- import { join as join4 } from "path";
13003
+ import { join as join6 } from "path";
12020
13004
 
12021
13005
  // ../provider-aws/src/aws-config.ts
12022
- import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
12023
- import { dirname as dirname2 } from "path";
13006
+ import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
13007
+ import { dirname as dirname3 } from "path";
12024
13008
  var BLOCK_START = "# >>> cloudhop managed >>> (edit with `cloudhop`, not by hand)";
12025
13009
  var BLOCK_END = "# <<< cloudhop managed <<<";
12026
13010
  function renderBlock(sections) {
@@ -12067,13 +13051,13 @@ function replaceManagedBlock(content, sections) {
12067
13051
  async function writeManagedBlock(path2, sections) {
12068
13052
  let content = "";
12069
13053
  try {
12070
- content = await readFile2(path2, "utf8");
13054
+ content = await readFile3(path2, "utf8");
12071
13055
  } catch {
12072
13056
  }
12073
13057
  const next = replaceManagedBlock(content, sections);
12074
13058
  if (next === content) return;
12075
- await mkdir2(dirname2(path2), { recursive: true, mode: 448 });
12076
- await writeFile2(path2, next, { mode: 384 });
13059
+ await mkdir3(dirname3(path2), { recursive: true, mode: 448 });
13060
+ await writeFile3(path2, next, { mode: 384 });
12077
13061
  }
12078
13062
 
12079
13063
  // ../provider-aws/src/http.ts
@@ -12088,11 +13072,11 @@ var AwsHttpError = class extends Error {
12088
13072
  code;
12089
13073
  };
12090
13074
  async function parse2(response) {
12091
- const text3 = await response.text();
12092
- const body = text3 ? JSON.parse(text3) : {};
13075
+ const text4 = await response.text();
13076
+ const body = text4 ? JSON.parse(text4) : {};
12093
13077
  if (!response.ok) {
12094
13078
  const code = body.error ?? body.__type ?? body.code;
12095
- const description = body.error_description ?? body.message ?? text3;
13079
+ const description = body.error_description ?? body.message ?? text4;
12096
13080
  throw new AwsHttpError(
12097
13081
  response.status,
12098
13082
  code,
@@ -12160,7 +13144,7 @@ var PortalClient = class {
12160
13144
  };
12161
13145
 
12162
13146
  // ../provider-aws/src/sso/sso-login.ts
12163
- import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
13147
+ import { createHash as createHash3, randomBytes as randomBytes2 } from "crypto";
12164
13148
 
12165
13149
  // ../provider-aws/src/sso/loopback.ts
12166
13150
  import { createServer as createServer2 } from "http";
@@ -12293,24 +13277,24 @@ var OidcClient = class {
12293
13277
  };
12294
13278
 
12295
13279
  // ../provider-aws/src/sso/token-cache.ts
12296
- import { createHash } from "crypto";
12297
- import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
12298
- import { join as join3 } from "path";
13280
+ import { createHash as createHash2 } from "crypto";
13281
+ import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
13282
+ import { join as join5 } from "path";
12299
13283
  var EXPIRY_MARGIN_MS = 5 * 60 * 1e3;
12300
13284
  function tokenCachePath(awsDir, sessionName) {
12301
- const hash = createHash("sha1").update(sessionName).digest("hex");
12302
- return join3(awsDir, "sso", "cache", `${hash}.json`);
13285
+ const hash = createHash2("sha1").update(sessionName).digest("hex");
13286
+ return join5(awsDir, "sso", "cache", `${hash}.json`);
12303
13287
  }
12304
13288
  async function readToken(awsDir, sessionName) {
12305
13289
  try {
12306
- return JSON.parse(await readFile3(tokenCachePath(awsDir, sessionName), "utf8"));
13290
+ return JSON.parse(await readFile4(tokenCachePath(awsDir, sessionName), "utf8"));
12307
13291
  } catch {
12308
13292
  return void 0;
12309
13293
  }
12310
13294
  }
12311
13295
  async function writeToken(awsDir, sessionName, token) {
12312
- await mkdir3(join3(awsDir, "sso", "cache"), { recursive: true, mode: 448 });
12313
- await writeFile3(tokenCachePath(awsDir, sessionName), JSON.stringify(token, null, 2), {
13296
+ await mkdir4(join5(awsDir, "sso", "cache"), { recursive: true, mode: 448 });
13297
+ await writeFile4(tokenCachePath(awsDir, sessionName), JSON.stringify(token, null, 2), {
12314
13298
  mode: 384
12315
13299
  });
12316
13300
  }
@@ -12353,7 +13337,7 @@ async function authorizationCodeLogin(options) {
12353
13337
  clientId: client.clientId,
12354
13338
  redirectUri: server.redirectUri,
12355
13339
  state,
12356
- codeChallenge: base64url(createHash2("sha256").update(codeVerifier).digest())
13340
+ codeChallenge: base64url(createHash3("sha256").update(codeVerifier).digest())
12357
13341
  });
12358
13342
  options.interaction.notify("Opening your browser to log in\u2026");
12359
13343
  options.interaction.notify(`If it doesn't open, visit: ${url}`);
@@ -12458,6 +13442,7 @@ var AwsProvider = class {
12458
13442
  id = "aws";
12459
13443
  displayName = "AWS";
12460
13444
  cli = { command: "aws", displayName: "AWS CLI v2", minVersion: "2.32.0" };
13445
+ identityArgs = ["sts", "get-caller-identity"];
12461
13446
  sourceKinds = [
12462
13447
  {
12463
13448
  kind: SSO_KIND,
@@ -12566,7 +13551,7 @@ var AwsProvider = class {
12566
13551
  }
12567
13552
  });
12568
13553
  }
12569
- await writeManagedBlock(join4(this.options.awsDir, "config"), sections);
13554
+ await writeManagedBlock(join6(this.options.awsDir, "config"), sections);
12570
13555
  }
12571
13556
  async processEnv(resolved, mode) {
12572
13557
  const region = resolved.region;
@@ -12631,7 +13616,7 @@ var AwsProvider = class {
12631
13616
  // src/container.ts
12632
13617
  function createApp(env = process.env) {
12633
13618
  const runner = new NodeProcessRunner();
12634
- const awsDir = env.CLOUDHOP_AWS_DIR ?? join5(homedir2(), ".aws");
13619
+ const awsDir = env.CLOUDHOP_AWS_DIR ?? join7(homedir3(), ".aws");
12635
13620
  return new CloudhopApp({
12636
13621
  config: new YamlConfigStore(),
12637
13622
  providers: [
@@ -12648,9 +13633,55 @@ function createApp(env = process.env) {
12648
13633
  notify
12649
13634
  });
12650
13635
  }
13636
+ function createAssistants(version, env = process.env) {
13637
+ return new AssistantsInstaller({
13638
+ places: { home: homedir3(), project: process.cwd() },
13639
+ configDir: defaultConfigDir(env),
13640
+ runner: new NodeProcessRunner(),
13641
+ cloudhopVersion: version
13642
+ });
13643
+ }
13644
+
13645
+ // src/update-check.ts
13646
+ import { mkdir as mkdir5, readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
13647
+ import { join as join8 } from "path";
13648
+ var DAY_MS = 24 * 60 * 60 * 1e3;
13649
+ var REGISTRY_URL = "https://registry.npmjs.org/cloudhop/latest";
13650
+ function startUpdateCheck(configDir, current) {
13651
+ const env = process.env;
13652
+ if (env.CLOUDHOP_NO_UPDATE_CHECK === "1" || env.CI || !process.stderr.isTTY)
13653
+ return async () => {
13654
+ };
13655
+ const file = join8(configDir, "update-check.json");
13656
+ const work = (async () => {
13657
+ let cache = { checkedAt: 0 };
13658
+ try {
13659
+ cache = JSON.parse(await readFile5(file, "utf8"));
13660
+ } catch {
13661
+ }
13662
+ if (cache.latest && compareVersions(cache.latest, current) > 0) {
13663
+ process.stderr.write(
13664
+ `\x1B[2mcloudhop \u203A\x1B[22m cloudhop ${cache.latest} is available (you have ${current}): npm i -g cloudhop
13665
+ `
13666
+ );
13667
+ }
13668
+ if (Date.now() - cache.checkedAt < DAY_MS) return;
13669
+ const response = await fetch(REGISTRY_URL, {
13670
+ headers: { accept: "application/json" },
13671
+ signal: AbortSignal.timeout(1500)
13672
+ });
13673
+ if (!response.ok) return;
13674
+ const { version } = await response.json();
13675
+ if (typeof version !== "string") return;
13676
+ await mkdir5(configDir, { recursive: true });
13677
+ await writeFile5(file, JSON.stringify({ checkedAt: Date.now(), latest: version }));
13678
+ })().catch(() => {
13679
+ });
13680
+ return () => Promise.race([work, new Promise((resolve) => setTimeout(resolve, 300))]);
13681
+ }
12651
13682
 
12652
13683
  // src/cli.ts
12653
- var HELP = `cloudhop ${"1.0.0"}
13684
+ var HELP = `cloudhop ${"1.1.0"}
12654
13685
  Hop between cloud accounts, roles and regions with one command.
12655
13686
 
12656
13687
  Usage
@@ -12669,10 +13700,15 @@ Setup
12669
13700
  Change the region an alias runs in
12670
13701
  cloudhop login [source] Log in again and refresh its accounts
12671
13702
  cloudhop sync [source] Refresh the accounts/roles you have access to
12672
- cloudhop ls [source] List sources and their aliases
13703
+ cloudhop ls [source] [--json] List sources and their aliases
13704
+ cloudhop status [alias] [--json]
13705
+ Can it run now, or will it need a sign-in?
12673
13706
  cloudhop rm <name...> [--yes] Remove aliases or sources (a source takes its aliases)
12674
13707
  cloudhop provider [ls|enable|disable] <provider>
12675
13708
  cloudhop doctor Check (and install) the cloud CLIs
13709
+ cloudhop agent [status|install|update|remove] [--tools <list>] [--scope user|project] [--no-mcp]
13710
+ Teach Claude Code, Kiro, Copilot, Cursor or Codex to use cloudhop
13711
+ cloudhop mcp Model Context Protocol server for AI assistants (stdio)
12676
13712
  cloudhop ui [--port <n>] [--no-open]
12677
13713
  Manage sources and aliases in a local web page
12678
13714
 
@@ -12696,14 +13732,38 @@ async function main(argv) {
12696
13732
  return 0;
12697
13733
  }
12698
13734
  if (command === "--version" || command === "-v" || command === "version") {
12699
- console.log("1.0.0");
13735
+ console.log("1.1.0");
12700
13736
  return 0;
12701
13737
  }
12702
13738
  const app = createApp();
12703
13739
  const local = RESERVED_NAMES.has(command) ? takeGlobalFlags(args) : args;
13740
+ if (command === "mcp") {
13741
+ await serveMcp({ app, version: "1.1.0" });
13742
+ return 0;
13743
+ }
13744
+ const assistants = createAssistants("1.1.0");
13745
+ await refreshAssistantSkills(assistants, command);
13746
+ const finishUpdateCheck = startUpdateCheck(defaultConfigDir(), "1.1.0");
13747
+ try {
13748
+ return await dispatch(app, assistants, command, args, local);
13749
+ } finally {
13750
+ await finishUpdateCheck();
13751
+ }
13752
+ }
13753
+ async function refreshAssistantSkills(assistants, command) {
13754
+ if (command === "agent") return;
13755
+ try {
13756
+ const { updated } = await assistants.update();
13757
+ if (updated.length > 0) {
13758
+ notify(`Updated the cloudhop skill for your AI assistants (${updated.length} file(s)).`);
13759
+ }
13760
+ } catch {
13761
+ }
13762
+ }
13763
+ async function dispatch(app, assistants, command, args, local) {
12704
13764
  switch (command) {
12705
13765
  case "setup":
12706
- await setupCommand(app);
13766
+ await setupCommand(app, assistants);
12707
13767
  return 0;
12708
13768
  case "add":
12709
13769
  await addCommand(app, parseArgs(local));
@@ -12711,6 +13771,9 @@ async function main(argv) {
12711
13771
  case "alias":
12712
13772
  await aliasCommand(app, parseArgs(local));
12713
13773
  return 0;
13774
+ case "status":
13775
+ await statusCommand(app, local);
13776
+ return 0;
12714
13777
  case "region":
12715
13778
  await regionCommand(app, local);
12716
13779
  return 0;
@@ -12722,7 +13785,7 @@ async function main(argv) {
12722
13785
  return 0;
12723
13786
  case "ls":
12724
13787
  case "list":
12725
- await listCommand(app, local[0]);
13788
+ await listCommand(app, local);
12726
13789
  return 0;
12727
13790
  case "rm":
12728
13791
  case "remove":
@@ -12736,8 +13799,11 @@ async function main(argv) {
12736
13799
  return 0;
12737
13800
  case "exec":
12738
13801
  return execCommand(app, local);
13802
+ case "agent":
13803
+ await agentCommand(assistants, local);
13804
+ return 0;
12739
13805
  case "ui":
12740
- await uiCommand(app, parseArgs(local));
13806
+ await uiCommand(app, assistants, parseArgs(local));
12741
13807
  return 0;
12742
13808
  default:
12743
13809
  return proxyCommand(app, command, args);