cloudhop 1.0.0 → 1.2.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 +27 -1
  2. package/dist/cli.js +2685 -575
  3. package/package.json +7 -4
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;
@@ -1567,7 +1511,7 @@ var require_stringify = __commonJS({
1567
1511
  props.push(doc.directives.tagString(tag));
1568
1512
  return props.join(" ");
1569
1513
  }
1570
- function stringify2(item, ctx, onComment, onChompKeep) {
1514
+ function stringify3(item, ctx, onComment, onChompKeep) {
1571
1515
  if (identity.isPair(item))
1572
1516
  return item.toString(ctx, onComment, onChompKeep);
1573
1517
  if (identity.isAlias(item)) {
@@ -1596,7 +1540,7 @@ var require_stringify = __commonJS({
1596
1540
  ${ctx.indent}${str}`;
1597
1541
  }
1598
1542
  exports.createStringifyContext = createStringifyContext;
1599
- exports.stringify = stringify2;
1543
+ exports.stringify = stringify3;
1600
1544
  }
1601
1545
  });
1602
1546
 
@@ -1606,7 +1550,7 @@ var require_stringifyPair = __commonJS({
1606
1550
  "use strict";
1607
1551
  var identity = require_identity();
1608
1552
  var Scalar = require_Scalar();
1609
- var stringify2 = require_stringify();
1553
+ var stringify3 = require_stringify();
1610
1554
  var stringifyComment = require_stringifyComment();
1611
1555
  function stringifyPair({ key, value }, ctx, onComment, onChompKeep) {
1612
1556
  const { allNullValues, doc, indent, indentStep, options: { commentString, indentSeq, simpleKeys } } = ctx;
@@ -1628,7 +1572,7 @@ var require_stringifyPair = __commonJS({
1628
1572
  });
1629
1573
  let keyCommentDone = false;
1630
1574
  let chompKeep = false;
1631
- let str = stringify2.stringify(key, ctx, () => keyCommentDone = true, () => chompKeep = true);
1575
+ let str = stringify3.stringify(key, ctx, () => keyCommentDone = true, () => chompKeep = true);
1632
1576
  if (!explicitKey && !ctx.inFlow && str.length > 1024) {
1633
1577
  if (simpleKeys)
1634
1578
  throw new Error("With simple keys, single line scalar must not span more than 1024 characters");
@@ -1680,7 +1624,7 @@ ${indent}:`;
1680
1624
  ctx.indent = ctx.indent.substring(2);
1681
1625
  }
1682
1626
  let valueCommentDone = false;
1683
- const valueStr = stringify2.stringify(value, ctx, () => valueCommentDone = true, () => chompKeep = true);
1627
+ const valueStr = stringify3.stringify(value, ctx, () => valueCommentDone = true, () => chompKeep = true);
1684
1628
  let ws = " ";
1685
1629
  if (keyComment || vsb || vcb) {
1686
1630
  ws = vsb ? "\n" : "";
@@ -1821,7 +1765,7 @@ var require_addPairToJSMap = __commonJS({
1821
1765
  "use strict";
1822
1766
  var log2 = require_log();
1823
1767
  var merge = require_merge();
1824
- var stringify2 = require_stringify();
1768
+ var stringify3 = require_stringify();
1825
1769
  var identity = require_identity();
1826
1770
  var toJS = require_toJS();
1827
1771
  function addPairToJSMap(ctx, map, { key, value }) {
@@ -1857,7 +1801,7 @@ var require_addPairToJSMap = __commonJS({
1857
1801
  if (typeof jsKey !== "object")
1858
1802
  return String(jsKey);
1859
1803
  if (identity.isNode(key) && ctx?.doc) {
1860
- const strCtx = stringify2.createStringifyContext(ctx.doc, {});
1804
+ const strCtx = stringify3.createStringifyContext(ctx.doc, {});
1861
1805
  strCtx.anchors = /* @__PURE__ */ new Set();
1862
1806
  for (const node of ctx.anchors.keys())
1863
1807
  strCtx.anchors.add(node.anchor);
@@ -1924,12 +1868,12 @@ var require_stringifyCollection = __commonJS({
1924
1868
  "../../node_modules/.pnpm/yaml@2.9.1/node_modules/yaml/dist/stringify/stringifyCollection.js"(exports) {
1925
1869
  "use strict";
1926
1870
  var identity = require_identity();
1927
- var stringify2 = require_stringify();
1871
+ var stringify3 = require_stringify();
1928
1872
  var stringifyComment = require_stringifyComment();
1929
1873
  function stringifyCollection(collection, ctx, options) {
1930
1874
  const flow = ctx.inFlow ?? collection.flow;
1931
- const stringify3 = flow ? stringifyFlowCollection : stringifyBlockCollection;
1932
- return stringify3(collection, ctx, options);
1875
+ const stringify4 = flow ? stringifyFlowCollection : stringifyBlockCollection;
1876
+ return stringify4(collection, ctx, options);
1933
1877
  }
1934
1878
  function stringifyBlockCollection({ comment, items }, ctx, { blockItemPrefix, flowChars, itemIndent, onChompKeep, onComment }) {
1935
1879
  const { indent, options: { commentString } } = ctx;
@@ -1954,7 +1898,7 @@ var require_stringifyCollection = __commonJS({
1954
1898
  }
1955
1899
  }
1956
1900
  chompKeep = false;
1957
- let str2 = stringify2.stringify(item, itemCtx, () => comment2 = null, () => chompKeep = true);
1901
+ let str2 = stringify3.stringify(item, itemCtx, () => comment2 = null, () => chompKeep = true);
1958
1902
  if (comment2)
1959
1903
  str2 += stringifyComment.lineComment(str2, itemIndent, commentString(comment2));
1960
1904
  if (chompKeep && comment2)
@@ -2021,7 +1965,7 @@ ${indent}${line}` : "\n";
2021
1965
  }
2022
1966
  if (comment)
2023
1967
  reqNewline = true;
2024
- let str = stringify2.stringify(item, itemCtx, () => comment = null);
1968
+ let str = stringify3.stringify(item, itemCtx, () => comment = null);
2025
1969
  reqNewline || (reqNewline = lines.length > linesAtValue || str.includes("\n"));
2026
1970
  if (i2 < items.length - 1) {
2027
1971
  str += ",";
@@ -3382,7 +3326,7 @@ var require_stringifyDocument = __commonJS({
3382
3326
  "../../node_modules/.pnpm/yaml@2.9.1/node_modules/yaml/dist/stringify/stringifyDocument.js"(exports) {
3383
3327
  "use strict";
3384
3328
  var identity = require_identity();
3385
- var stringify2 = require_stringify();
3329
+ var stringify3 = require_stringify();
3386
3330
  var stringifyComment = require_stringifyComment();
3387
3331
  function stringifyDocument(doc, options) {
3388
3332
  const lines = [];
@@ -3397,7 +3341,7 @@ var require_stringifyDocument = __commonJS({
3397
3341
  }
3398
3342
  if (hasDirectives)
3399
3343
  lines.push("---");
3400
- const ctx = stringify2.createStringifyContext(doc, options);
3344
+ const ctx = stringify3.createStringifyContext(doc, options);
3401
3345
  const { commentString } = ctx.options;
3402
3346
  if (doc.commentBefore) {
3403
3347
  if (lines.length !== 1)
@@ -3419,7 +3363,7 @@ var require_stringifyDocument = __commonJS({
3419
3363
  contentComment = doc.contents.comment;
3420
3364
  }
3421
3365
  const onChompKeep = contentComment ? void 0 : () => chompKeep = true;
3422
- let body = stringify2.stringify(doc.contents, ctx, () => contentComment = null, onChompKeep);
3366
+ let body = stringify3.stringify(doc.contents, ctx, () => contentComment = null, onChompKeep);
3423
3367
  if (contentComment)
3424
3368
  body += stringifyComment.lineComment(body, "", commentString(contentComment));
3425
3369
  if ((body[0] === "|" || body[0] === ">") && lines[lines.length - 1] === "---") {
@@ -3427,7 +3371,7 @@ var require_stringifyDocument = __commonJS({
3427
3371
  } else
3428
3372
  lines.push(body);
3429
3373
  } else {
3430
- lines.push(stringify2.stringify(doc.contents, ctx));
3374
+ lines.push(stringify3.stringify(doc.contents, ctx));
3431
3375
  }
3432
3376
  if (doc.directives?.docEnd) {
3433
3377
  if (doc.comment) {
@@ -5563,7 +5507,7 @@ var require_cst_scalar = __commonJS({
5563
5507
  var require_cst_stringify = __commonJS({
5564
5508
  "../../node_modules/.pnpm/yaml@2.9.1/node_modules/yaml/dist/parse/cst-stringify.js"(exports) {
5565
5509
  "use strict";
5566
- var stringify2 = (cst) => "type" in cst ? stringifyToken(cst) : stringifyItem(cst);
5510
+ var stringify3 = (cst) => "type" in cst ? stringifyToken(cst) : stringifyItem(cst);
5567
5511
  function stringifyToken(token) {
5568
5512
  switch (token.type) {
5569
5513
  case "block-scalar": {
@@ -5616,7 +5560,7 @@ var require_cst_stringify = __commonJS({
5616
5560
  res += stringifyToken(value);
5617
5561
  return res;
5618
5562
  }
5619
- exports.stringify = stringify2;
5563
+ exports.stringify = stringify3;
5620
5564
  }
5621
5565
  });
5622
5566
 
@@ -6102,9 +6046,9 @@ var require_lexer = __commonJS({
6102
6046
  }
6103
6047
  }
6104
6048
  *parseQuotedScalar() {
6105
- const quote = this.charAt(0);
6106
- let end = this.buffer.indexOf(quote, this.pos + 1);
6107
- if (quote === "'") {
6049
+ const quote2 = this.charAt(0);
6050
+ let end = this.buffer.indexOf(quote2, this.pos + 1);
6051
+ if (quote2 === "'") {
6108
6052
  while (end !== -1 && this.buffer[end + 1] === "'")
6109
6053
  end = this.buffer.indexOf("'", end + 2);
6110
6054
  } else {
@@ -7327,7 +7271,7 @@ var require_public_api = __commonJS({
7327
7271
  }
7328
7272
  return doc;
7329
7273
  }
7330
- function parse3(src, reviver, options) {
7274
+ function parse4(src, reviver, options) {
7331
7275
  let _reviver = void 0;
7332
7276
  if (typeof reviver === "function") {
7333
7277
  _reviver = reviver;
@@ -7346,7 +7290,7 @@ var require_public_api = __commonJS({
7346
7290
  }
7347
7291
  return doc.toJS(Object.assign({ reviver: _reviver }, options));
7348
7292
  }
7349
- function stringify2(value, replacer, options) {
7293
+ function stringify3(value, replacer, options) {
7350
7294
  let _replacer = null;
7351
7295
  if (typeof replacer === "function" || Array.isArray(replacer)) {
7352
7296
  _replacer = replacer;
@@ -7368,10 +7312,10 @@ var require_public_api = __commonJS({
7368
7312
  return value.toString(options);
7369
7313
  return new Document.Document(value, _replacer, options).toString(options);
7370
7314
  }
7371
- exports.parse = parse3;
7315
+ exports.parse = parse4;
7372
7316
  exports.parseAllDocuments = parseAllDocuments;
7373
7317
  exports.parseDocument = parseDocument;
7374
- exports.stringify = stringify2;
7318
+ exports.stringify = stringify3;
7375
7319
  }
7376
7320
  });
7377
7321
 
@@ -7427,90 +7371,378 @@ 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
+ // src/cli.ts
7431
+ import { basename } from "path";
7432
+
7433
+ // ../adapters-node/src/node-browser-opener.ts
7434
+ import { spawn } from "child_process";
7435
+ var NodeBrowserOpener = class {
7436
+ async open(url) {
7437
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
7438
+ await new Promise((resolve) => {
7439
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
7440
+ child.on("error", () => resolve());
7441
+ child.on("spawn", () => {
7442
+ child.unref();
7443
+ resolve();
7444
+ });
7445
+ });
7476
7446
  }
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
- }
7447
+ };
7482
7448
 
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;
7449
+ // ../adapters-node/src/node-process-runner.ts
7450
+ import { spawn as spawn2 } from "child_process";
7451
+ var STDERR_TAIL_BYTES = 8 * 1024;
7452
+ function buildEnv(overrides) {
7453
+ const env = { ...process.env };
7454
+ for (const [key, value] of Object.entries(overrides ?? {})) {
7455
+ if (value === void 0) delete env[key];
7456
+ else env[key] = value;
7490
7457
  }
7491
- return 0;
7458
+ return env;
7492
7459
  }
7493
-
7494
- // ../core/src/application/cloudhop-app.ts
7495
- var CloudhopApp = class {
7496
- constructor(deps) {
7497
- this.deps = deps;
7460
+ var useShell = process.platform === "win32";
7461
+ var NodeProcessRunner = class {
7462
+ run(command, args, options = {}) {
7463
+ return new Promise((resolve, reject) => {
7464
+ const child = spawn2(command, args, {
7465
+ env: buildEnv(options.env),
7466
+ stdio: ["inherit", "inherit", options.captureStderr ? "pipe" : "inherit"],
7467
+ shell: useShell
7468
+ });
7469
+ let tail = "";
7470
+ child.stderr?.on("data", (chunk) => {
7471
+ process.stderr.write(chunk);
7472
+ tail = (tail + chunk.toString()).slice(-STDERR_TAIL_BYTES);
7473
+ });
7474
+ const ignore = () => {
7475
+ };
7476
+ process.on("SIGINT", ignore);
7477
+ process.on("SIGTERM", ignore);
7478
+ const cleanup = () => {
7479
+ process.off("SIGINT", ignore);
7480
+ process.off("SIGTERM", ignore);
7481
+ };
7482
+ child.on("error", (error) => {
7483
+ cleanup();
7484
+ reject(error);
7485
+ });
7486
+ child.on("close", (code, signal) => {
7487
+ cleanup();
7488
+ resolve({ exitCode: code ?? (signal ? 128 + 2 : 1), stderrTail: tail });
7489
+ });
7490
+ });
7498
7491
  }
7499
- deps;
7500
- // ── Providers ────────────────────────────────────────────────────────────
7501
- availableProviders() {
7502
- return this.deps.providers;
7492
+ capture(command, args, options = {}) {
7493
+ return new Promise((resolve) => {
7494
+ const child = spawn2(command, args, { env: buildEnv(options.env), shell: useShell });
7495
+ child.stdin.end();
7496
+ let stdout2 = "";
7497
+ let stderr = "";
7498
+ child.stdout.on("data", (c3) => {
7499
+ stdout2 += c3.toString();
7500
+ });
7501
+ child.stderr.on("data", (c3) => {
7502
+ stderr += c3.toString();
7503
+ });
7504
+ child.on("error", (error) => resolve({ exitCode: 127, stdout: stdout2, stderr: error.message }));
7505
+ child.on("close", (code) => resolve({ exitCode: code ?? 1, stdout: stdout2, stderr }));
7506
+ });
7503
7507
  }
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;
7508
+ async probe(command) {
7509
+ const result = await this.capture(command, ["--version"]);
7510
+ if (result.exitCode !== 0) return { installed: false };
7511
+ const output = `${result.stdout} ${result.stderr}`;
7512
+ const version = output.match(/(\d+\.\d+\.\d+)/)?.[1];
7513
+ return { installed: true, ...version ? { version } : {} };
7514
+ }
7515
+ };
7516
+
7517
+ // ../adapters-node/src/system-tool-installer.ts
7518
+ var TOOLS = {
7519
+ aws: {
7520
+ manualUrl: "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html",
7521
+ recipes: {
7522
+ darwin: [{ via: "Homebrew", command: "brew", args: ["install", "awscli"] }],
7523
+ win32: [
7524
+ { via: "winget", command: "winget", args: ["install", "--exact", "--id", "Amazon.AWSCLI"] }
7525
+ ]
7526
+ }
7527
+ },
7528
+ az: {
7529
+ manualUrl: "https://learn.microsoft.com/cli/azure/install-azure-cli",
7530
+ recipes: {
7531
+ darwin: [{ via: "Homebrew", command: "brew", args: ["install", "azure-cli"] }],
7532
+ win32: [
7533
+ {
7534
+ via: "winget",
7535
+ command: "winget",
7536
+ args: ["install", "--exact", "--id", "Microsoft.AzureCLI"]
7537
+ }
7538
+ ]
7539
+ }
7540
+ },
7541
+ gcloud: {
7542
+ manualUrl: "https://cloud.google.com/sdk/docs/install",
7543
+ recipes: {
7544
+ darwin: [
7545
+ { via: "Homebrew", command: "brew", args: ["install", "--cask", "google-cloud-sdk"] }
7546
+ ],
7547
+ win32: [
7548
+ {
7549
+ via: "winget",
7550
+ command: "winget",
7551
+ args: ["install", "--exact", "--id", "Google.CloudSDK"]
7552
+ }
7553
+ ]
7554
+ }
7555
+ }
7556
+ };
7557
+ var SystemToolInstaller = class {
7558
+ constructor(runner, platform = process.platform) {
7559
+ this.runner = runner;
7560
+ this.platform = platform;
7561
+ }
7562
+ runner;
7563
+ platform;
7564
+ available = /* @__PURE__ */ new Map();
7565
+ recipeFor(command) {
7566
+ const tool = TOOLS[command];
7567
+ if (!tool) return void 0;
7568
+ const recipe = tool.recipes[this.platform]?.[0];
7569
+ return recipe ? { ...recipe, manualUrl: tool.manualUrl } : { via: "official installer", command: "", args: [], manualUrl: tool.manualUrl };
7570
+ }
7571
+ async install(recipe) {
7572
+ if (!recipe.command) return false;
7573
+ if (!await this.hasCommand(recipe.command)) return false;
7574
+ const { exitCode } = await this.runner.run(recipe.command, recipe.args);
7575
+ return exitCode === 0;
7576
+ }
7577
+ async hasCommand(command) {
7578
+ if (!this.available.has(command)) {
7579
+ this.available.set(command, (await this.runner.probe(command)).installed);
7580
+ }
7581
+ return this.available.get(command) ?? false;
7582
+ }
7583
+ };
7584
+
7585
+ // ../adapters-node/src/yaml-config-store.ts
7586
+ import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
7587
+ import { homedir } from "os";
7588
+ import { join } from "path";
7589
+
7590
+ // ../core/src/domain/errors.ts
7591
+ var CloudhopError = class extends Error {
7592
+ constructor(code, message, hint) {
7593
+ super(message);
7594
+ this.code = code;
7595
+ this.hint = hint;
7596
+ this.name = "CloudhopError";
7597
+ }
7598
+ code;
7599
+ hint;
7600
+ };
7601
+
7602
+ // ../core/src/domain/naming.ts
7603
+ var RESERVED_NAMES = /* @__PURE__ */ new Set([
7604
+ "add",
7605
+ "agent",
7606
+ "alias",
7607
+ "login",
7608
+ "team",
7609
+ "shell",
7610
+ "use",
7611
+ "each",
7612
+ "console",
7613
+ "prod",
7614
+ "rename",
7615
+ "mv",
7616
+ "mcp",
7617
+ "region",
7618
+ "setup",
7619
+ "status",
7620
+ "sync",
7621
+ "ls",
7622
+ "list",
7623
+ "rm",
7624
+ "remove",
7625
+ "doctor",
7626
+ "exec",
7627
+ "provider",
7628
+ "ui",
7629
+ "help",
7630
+ "version"
7631
+ ]);
7632
+ var NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,62}$/;
7633
+ function assertValidName(kind, name) {
7634
+ if (!NAME_PATTERN.test(name)) {
7635
+ throw new CloudhopError(
7636
+ "INVALID_NAME",
7637
+ `Invalid ${kind} name "${name}".`,
7638
+ 'Use lowercase letters, numbers, "-" or "_" (max 63 characters).'
7639
+ );
7640
+ }
7641
+ if (kind === "alias" && RESERVED_NAMES.has(name)) {
7642
+ throw new CloudhopError(
7643
+ "INVALID_NAME",
7644
+ `"${name}" is a cloudhop command.`,
7645
+ "Pick another alias name."
7646
+ );
7647
+ }
7648
+ }
7649
+ function suggestAliasName(...parts) {
7650
+ const slug = parts.join("-").toLowerCase().replace(/access$/i, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
7651
+ return slug || "alias";
7652
+ }
7653
+ var slugTokens = (text4) => text4.toLowerCase().replace(/access$/i, "").split(/[^a-z0-9]+/).filter(Boolean);
7654
+ var ROLE_WORDS = {
7655
+ administrator: "admin",
7656
+ administratoraccess: "admin",
7657
+ readonly: "ro",
7658
+ readonlyaccess: "ro",
7659
+ developer: "dev",
7660
+ poweruser: "power",
7661
+ poweruseraccess: "power",
7662
+ viewonly: "view"
7663
+ };
7664
+ var ROLE_NOISE = /* @__PURE__ */ new Set(["aws", "access", "role", "ps", "iam"]);
7665
+ var PRODUCTION = /(^|[^a-z])(prod|prd|production|live)([^a-z]|$)/i;
7666
+ var looksLikeProduction = (...texts) => texts.some((text4) => PRODUCTION.test(text4));
7667
+ function suggestAliases(targets, sourceId, taken = /* @__PURE__ */ new Set()) {
7668
+ const sourceWords = new Set(
7669
+ slugTokens(sourceId).filter((w) => w.length > 2 && !/^(test|dev|main)$/.test(w))
7670
+ );
7671
+ const accountOf = (t2) => {
7672
+ let words = slugTokens(t2.attributes.accountName ?? t2.label.split(" / ")[0] ?? t2.id);
7673
+ if (words.length > 1 && sourceWords.has(words[0])) words = words.slice(1);
7674
+ while (words.length > 1 && /^\d{1,2}$/.test(words[words.length - 1]))
7675
+ words = words.slice(0, -1);
7676
+ return words;
7677
+ };
7678
+ const roleOf = (t2, accountWords) => {
7679
+ const raw = t2.attributes.roleName ?? t2.label.split(" / ")[1] ?? "";
7680
+ const compact = raw.toLowerCase().replace(/[^a-z0-9]/g, "").replace(/^aws/, "").replace(/access$/, "");
7681
+ if (ROLE_WORDS[compact]) return [ROLE_WORDS[compact]];
7682
+ const skip = /* @__PURE__ */ new Set([...accountWords, ...sourceWords, ...ROLE_NOISE]);
7683
+ const words = slugTokens(raw).filter((w) => !skip.has(w)).map((w) => ROLE_WORDS[w] ?? w);
7684
+ return words.length ? words : slugTokens(raw).slice(-1);
7685
+ };
7686
+ const accounts = targets.map(accountOf);
7687
+ const count = /* @__PURE__ */ new Map();
7688
+ for (const words of accounts) count.set(words.join("-"), (count.get(words.join("-")) ?? 0) + 1);
7689
+ const used = new Set(taken);
7690
+ const result = /* @__PURE__ */ new Map();
7691
+ targets.forEach((target, i2) => {
7692
+ const accountWords = accounts[i2];
7693
+ const base = accountWords.join("-");
7694
+ const words = (count.get(base) ?? 0) > 1 ? [...accountWords, ...roleOf(target, accountWords)] : accountWords;
7695
+ const stem = suggestAliasName(words.join("-"));
7696
+ let name = stem;
7697
+ for (let n3 = 2; used.has(name) || RESERVED_NAMES.has(name); n3++) name = `${stem}-${n3}`;
7698
+ used.add(name);
7699
+ result.set(target.id, name);
7700
+ });
7701
+ return result;
7702
+ }
7703
+
7704
+ // ../core/src/domain/version.ts
7705
+ function compareVersions(a2, b3) {
7706
+ const pa = a2.split(".").map((n3) => Number.parseInt(n3, 10) || 0);
7707
+ const pb = b3.split(".").map((n3) => Number.parseInt(n3, 10) || 0);
7708
+ for (let i2 = 0; i2 < Math.max(pa.length, pb.length); i2++) {
7709
+ const diff = (pa[i2] ?? 0) - (pb[i2] ?? 0);
7710
+ if (diff !== 0) return diff;
7711
+ }
7712
+ return 0;
7713
+ }
7714
+
7715
+ // ../core/src/application/cloudhop-app.ts
7716
+ var loginRequired = (sourceId) => new CloudhopError(
7717
+ "LOGIN_REQUIRED",
7718
+ `The session of "${sourceId}" expired and needs a browser sign-in.`,
7719
+ `Run \`cloudhop login ${sourceId}\` (it opens the browser), then try again.`
7720
+ );
7721
+ var NEXT_BY_STATE = {
7722
+ valid: "ready",
7723
+ refreshable: "silent-renewal",
7724
+ expired: "browser-sign-in",
7725
+ unknown: "unknown"
7726
+ };
7727
+ var CloudhopApp = class {
7728
+ constructor(deps) {
7729
+ this.deps = deps;
7730
+ }
7731
+ deps;
7732
+ // ── Providers ────────────────────────────────────────────────────────────
7733
+ availableProviders() {
7734
+ return this.deps.providers;
7735
+ }
7736
+ provider(id) {
7737
+ const provider = this.deps.providers.find((p3) => p3.id === id);
7738
+ if (!provider) {
7739
+ throw new CloudhopError(
7740
+ "PROVIDER_NOT_AVAILABLE",
7741
+ `The ${id} provider is not available yet.`,
7742
+ "Follow https://github.com/dayepesb/cloudhop for upcoming providers."
7743
+ );
7744
+ }
7745
+ return provider;
7514
7746
  }
7515
7747
  async config() {
7516
7748
  return this.deps.config.load();
@@ -7591,13 +7823,19 @@ var CloudhopApp = class {
7591
7823
  return this.syncSource(sourceId);
7592
7824
  }
7593
7825
  /** Makes sure the source has a usable session, renewing it silently or via login. */
7594
- async ensureSession(sourceId) {
7826
+ /**
7827
+ * Makes the source's session usable: renews it silently when possible, and
7828
+ * otherwise signs in through the browser, unless `interactive` is false, in
7829
+ * which case it fails with LOGIN_REQUIRED instead of opening a browser.
7830
+ */
7831
+ async ensureSession(sourceId, { interactive: interactive2 = true } = {}) {
7595
7832
  const config = await this.deps.config.load();
7596
7833
  const source = this.findSource(config, sourceId);
7597
7834
  const provider = this.provider(source.provider);
7598
7835
  const state = await provider.sessionState(source);
7599
7836
  if (state === "valid" || state === "unknown") return "none";
7600
7837
  if (state === "refreshable" && await provider.refreshSession(source)) return "refreshed";
7838
+ if (!interactive2) throw loginRequired(sourceId);
7601
7839
  await this.login(sourceId);
7602
7840
  return "logged-in";
7603
7841
  }
@@ -7661,6 +7899,81 @@ var CloudhopApp = class {
7661
7899
  const config = await this.deps.config.load();
7662
7900
  return Promise.all(config.aliases.map((a2) => this.resolve(config, a2)));
7663
7901
  }
7902
+ /** Sources with their session state and every alias, checked without signing in. */
7903
+ async overview() {
7904
+ const config = await this.deps.config.load();
7905
+ const sources = await Promise.all(
7906
+ config.sources.map(async (source) => {
7907
+ const session = await this.provider(source.provider).sessionState(source);
7908
+ return {
7909
+ id: source.id,
7910
+ provider: source.provider,
7911
+ kind: source.kind,
7912
+ settings: source.settings,
7913
+ session,
7914
+ next: NEXT_BY_STATE[session]
7915
+ };
7916
+ })
7917
+ );
7918
+ const bySource = new Map(sources.map((s) => [s.id, s]));
7919
+ const aliases = await Promise.all(
7920
+ config.aliases.map(async (alias) => {
7921
+ const resolved = await this.resolve(config, alias);
7922
+ const source = bySource.get(alias.source);
7923
+ return {
7924
+ name: alias.name,
7925
+ source: alias.source,
7926
+ provider: resolved.source.provider,
7927
+ target: alias.target,
7928
+ label: resolved.target?.label,
7929
+ available: Boolean(resolved.target),
7930
+ region: resolved.region,
7931
+ attributes: resolved.target?.attributes ?? {},
7932
+ prod: alias.prod === true,
7933
+ session: source.session,
7934
+ next: source.next
7935
+ };
7936
+ })
7937
+ );
7938
+ return { sources, aliases };
7939
+ }
7940
+ /** The status of one alias: which account it points to and whether it can run right away. */
7941
+ async aliasStatus(name) {
7942
+ const { aliases } = await this.overview();
7943
+ const status = aliases.find((a2) => a2.name === name);
7944
+ if (!status) {
7945
+ throw new CloudhopError(
7946
+ "ALIAS_NOT_FOUND",
7947
+ `Alias "${name}" not found.`,
7948
+ "See your aliases with `cloudhop ls`."
7949
+ );
7950
+ }
7951
+ return status;
7952
+ }
7953
+ /** Renames an alias and its profile, keeping its account, role, region and production mark. */
7954
+ async renameAlias(from, to) {
7955
+ assertValidName("alias", to);
7956
+ const config = await this.deps.config.load();
7957
+ const alias = this.findAlias(config, from);
7958
+ if (from === to) return;
7959
+ if (config.aliases.some((a2) => a2.name === to)) {
7960
+ throw new CloudhopError("ALIAS_EXISTS", `Alias "${to}" already exists.`);
7961
+ }
7962
+ alias.name = to;
7963
+ const source = this.findSource(config, alias.source);
7964
+ await this.syncProfiles(source.provider, config);
7965
+ await this.deps.config.save(config);
7966
+ }
7967
+ /** Marks aliases as production (or not). Production aliases ask before changing anything. */
7968
+ async setProduction(names, prod) {
7969
+ const config = await this.deps.config.load();
7970
+ for (const name of names) {
7971
+ const alias = this.findAlias(config, name);
7972
+ if (prod) alias.prod = true;
7973
+ else delete alias.prod;
7974
+ }
7975
+ await this.deps.config.save(config);
7976
+ }
7664
7977
  /** Changes an alias' region; matching the source's region clears the override. */
7665
7978
  async setAliasRegion(name, region) {
7666
7979
  const config = await this.deps.config.load();
@@ -7685,6 +7998,71 @@ var CloudhopApp = class {
7685
7998
  return this.runWithSession(resolved, provider, "cli", provider.cli.command, args);
7686
7999
  }
7687
8000
  /** Runs any command with the alias' temporary credentials in its environment. */
8001
+ /**
8002
+ * Runs the alias' cloud CLI without a terminal and returns what it printed.
8003
+ * Used where stdin/stdout belong to someone else (the MCP server). It never
8004
+ * opens a browser: an expired session fails with LOGIN_REQUIRED.
8005
+ */
8006
+ async captureAsAlias(name, args, overrides = {}) {
8007
+ const resolved = await this.usableAlias(name, overrides);
8008
+ const provider = this.provider(resolved.source.provider);
8009
+ await this.requireCli(provider);
8010
+ await this.ensureSession(resolved.source.id, { interactive: false });
8011
+ const env = await provider.processEnv(resolved, "cli");
8012
+ const result = await this.deps.runner.capture(provider.cli.command, args, { env });
8013
+ if (result.exitCode !== 0 && provider.isSessionError(`${result.stdout}
8014
+ ${result.stderr}`)) {
8015
+ throw loginRequired(resolved.source.id);
8016
+ }
8017
+ return result;
8018
+ }
8019
+ /**
8020
+ * Whether running these CLI arguments (or, with `args` undefined, any
8021
+ * command through `cloudhop exec`) as this alias needs a person's OK: true
8022
+ * for production aliases unless the command surely only reads.
8023
+ */
8024
+ async needsProductionConfirmation(name, args) {
8025
+ const resolved = await this.resolveAlias(name);
8026
+ if (!resolved.alias.prod) return false;
8027
+ if (!args) return true;
8028
+ return !this.provider(resolved.source.provider).isReadOnly(args);
8029
+ }
8030
+ /**
8031
+ * A URL that opens the web console as the alias, signing in first if the
8032
+ * session expired. The URL is a short-lived secret: open it, never print it.
8033
+ */
8034
+ async consoleUrl(name, { service, region } = {}) {
8035
+ const resolved = await this.usableAlias(name, region ? { region } : {});
8036
+ const provider = this.provider(resolved.source.provider);
8037
+ if (!provider.consoleUrl) {
8038
+ throw new CloudhopError(
8039
+ "PROVIDER_NOT_AVAILABLE",
8040
+ `${provider.displayName} has no console sign-in in cloudhop yet.`
8041
+ );
8042
+ }
8043
+ if (service !== void 0 && !/^[a-z0-9-]{1,40}$/.test(service)) {
8044
+ throw new CloudhopError(
8045
+ "INVALID_NAME",
8046
+ `"${service}" doesn't look like a service name.`,
8047
+ "Examples: s3, ec2, lambda, cloudwatch."
8048
+ );
8049
+ }
8050
+ await this.ensureSession(resolved.source.id);
8051
+ return provider.consoleUrl(resolved, service);
8052
+ }
8053
+ /**
8054
+ * Opens the web console as the alias in the user's browser. The sign-in URL
8055
+ * stays inside cloudhop: callers only learn how the user got in.
8056
+ */
8057
+ async openConsole(name, options = {}) {
8058
+ const { url, via } = await this.consoleUrl(name, options);
8059
+ await this.deps.browser.open(url);
8060
+ return via;
8061
+ }
8062
+ /** The command that shows who an alias is, e.g. `aws sts get-caller-identity`. */
8063
+ identityArgs(name) {
8064
+ return this.resolveAlias(name).then((r2) => this.provider(r2.source.provider).identityArgs);
8065
+ }
7688
8066
  async execAsAlias(name, command, args, overrides = {}) {
7689
8067
  const resolved = await this.usableAlias(name, overrides);
7690
8068
  const provider = this.provider(resolved.source.provider);
@@ -7819,30 +8197,387 @@ var CloudhopApp = class {
7819
8197
  var PROVIDER_IDS = ["aws", "azure", "gcp"];
7820
8198
  var emptyConfig = () => ({ providers: [], sources: [], aliases: [] });
7821
8199
 
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 };
8200
+ // ../adapters-node/src/yaml-config-store.ts
8201
+ var import_yaml = __toESM(require_dist(), 1);
8202
+ function defaultConfigDir(env = process.env) {
8203
+ if (env.CLOUDHOP_HOME) return env.CLOUDHOP_HOME;
8204
+ return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "cloudhop");
7841
8205
  }
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 = {};
8206
+ var HEADER = "# cloudhop configuration. Contains no secrets.\n# Docs: https://github.com/dayepesb/cloudhop\n";
8207
+ var YamlConfigStore = class {
8208
+ constructor(dir = defaultConfigDir()) {
8209
+ this.dir = dir;
8210
+ }
8211
+ dir;
8212
+ get configPath() {
8213
+ return join(this.dir, "config.yaml");
8214
+ }
8215
+ get targetsPath() {
8216
+ return join(this.dir, "targets.json");
8217
+ }
8218
+ async load() {
8219
+ const raw = await this.read(this.configPath);
8220
+ if (!raw) return emptyConfig();
8221
+ const file = (0, import_yaml.parse)(raw) ?? {};
8222
+ return {
8223
+ providers: (file.providers ?? []).filter(
8224
+ (p3) => PROVIDER_IDS.includes(p3)
8225
+ ),
8226
+ sources: Object.entries(file.sources ?? {}).map(([id, s]) => ({
8227
+ id,
8228
+ ...s,
8229
+ settings: s.settings ?? {}
8230
+ })),
8231
+ aliases: Object.entries(file.aliases ?? {}).map(([name, a2]) => ({ name, ...a2 }))
8232
+ };
8233
+ }
8234
+ async save(config) {
8235
+ const file = {
8236
+ providers: config.providers,
8237
+ sources: Object.fromEntries(config.sources.map(({ id, ...rest }) => [id, rest])),
8238
+ aliases: Object.fromEntries(config.aliases.map(({ name, ...rest }) => [name, rest]))
8239
+ };
8240
+ await this.write(this.configPath, HEADER + (0, import_yaml.stringify)(file));
8241
+ }
8242
+ async loadTargets(sourceId) {
8243
+ return (await this.allTargets())[sourceId] ?? [];
8244
+ }
8245
+ async saveTargets(sourceId, targets) {
8246
+ const all = await this.allTargets();
8247
+ all[sourceId] = targets;
8248
+ await this.write(this.targetsPath, JSON.stringify(all, null, 2));
8249
+ }
8250
+ async removeTargets(sourceId) {
8251
+ const all = await this.allTargets();
8252
+ delete all[sourceId];
8253
+ await this.write(this.targetsPath, JSON.stringify(all, null, 2));
8254
+ }
8255
+ async reset() {
8256
+ await rm(this.dir, { recursive: true, force: true });
8257
+ }
8258
+ async allTargets() {
8259
+ const raw = await this.read(this.targetsPath);
8260
+ return raw ? JSON.parse(raw) : {};
8261
+ }
8262
+ async read(path2) {
8263
+ try {
8264
+ return await readFile(path2, "utf8");
8265
+ } catch (error) {
8266
+ if (error.code === "ENOENT") return void 0;
8267
+ throw error;
8268
+ }
8269
+ }
8270
+ /** Writes through a temp file so a crash never leaves a half-written config. */
8271
+ async write(path2, content) {
8272
+ await mkdir(this.dir, { recursive: true, mode: 448 });
8273
+ const tmp = `${path2}.${process.pid}.tmp`;
8274
+ await writeFile(tmp, content, { mode: 384 });
8275
+ await rename(tmp, path2);
8276
+ }
8277
+ };
8278
+
8279
+ // ../mcp/src/server.ts
8280
+ import { createInterface } from "readline";
8281
+
8282
+ // ../mcp/src/tools.ts
8283
+ var MAX_OUTPUT = 1e5;
8284
+ var cap = (text4) => text4.length > MAX_OUTPUT ? `${text4.slice(0, MAX_OUTPUT)}
8285
+ \u2026 [truncated: ${text4.length - MAX_OUTPUT} more characters. Narrow the command with --query or filters.]` : text4;
8286
+ var WINDOWS_SHELL_CHARS = /[&|<>^%!"\r\n]/;
8287
+ var text = (args, key) => {
8288
+ const value = args[key];
8289
+ if (typeof value !== "string" || !value.trim()) {
8290
+ throw new CloudhopError("INVALID_NAME", `"${key}" is required.`);
8291
+ }
8292
+ return value.trim();
8293
+ };
8294
+ var stringArray = (args, key) => {
8295
+ const value = args[key];
8296
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
8297
+ throw new CloudhopError("INVALID_NAME", `"${key}" must be an array of strings.`);
8298
+ }
8299
+ return value;
8300
+ };
8301
+ var aliasInput = {
8302
+ alias: {
8303
+ type: "string",
8304
+ description: "The cloudhop alias to use, as listed by list_aliases. Ask the user which one."
8305
+ }
8306
+ };
8307
+ function createTools(app) {
8308
+ return [
8309
+ {
8310
+ name: "list_aliases",
8311
+ title: "List cloudhop aliases",
8312
+ 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.",
8313
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
8314
+ annotations: { readOnlyHint: true, openWorldHint: false },
8315
+ run: () => app.overview()
8316
+ },
8317
+ {
8318
+ name: "alias_status",
8319
+ title: "Check an alias",
8320
+ 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".',
8321
+ inputSchema: {
8322
+ type: "object",
8323
+ properties: aliasInput,
8324
+ required: ["alias"],
8325
+ additionalProperties: false
8326
+ },
8327
+ annotations: { readOnlyHint: true, openWorldHint: false },
8328
+ run: (args) => app.aliasStatus(text(args, "alias"))
8329
+ },
8330
+ {
8331
+ name: "whoami",
8332
+ title: "Who am I in this alias",
8333
+ 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.",
8334
+ inputSchema: {
8335
+ type: "object",
8336
+ properties: aliasInput,
8337
+ required: ["alias"],
8338
+ additionalProperties: false
8339
+ },
8340
+ annotations: { readOnlyHint: true, openWorldHint: true },
8341
+ run: async (args) => {
8342
+ const alias = text(args, "alias");
8343
+ const result = await app.captureAsAlias(alias, await app.identityArgs(alias));
8344
+ return {
8345
+ alias,
8346
+ exitCode: result.exitCode,
8347
+ output: cap(result.stdout),
8348
+ errors: cap(result.stderr)
8349
+ };
8350
+ }
8351
+ },
8352
+ {
8353
+ name: "login",
8354
+ title: "Sign in to a source",
8355
+ 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.",
8356
+ inputSchema: {
8357
+ type: "object",
8358
+ properties: {
8359
+ source: { type: "string", description: "The source id, as shown in list_aliases." }
8360
+ },
8361
+ required: ["source"],
8362
+ additionalProperties: false
8363
+ },
8364
+ annotations: {
8365
+ readOnlyHint: false,
8366
+ destructiveHint: false,
8367
+ idempotentHint: true,
8368
+ openWorldHint: true
8369
+ },
8370
+ run: async (args) => {
8371
+ const source = text(args, "source");
8372
+ const report = await app.login(source);
8373
+ return { source, signedIn: true, targets: report.targets.length };
8374
+ }
8375
+ },
8376
+ {
8377
+ name: "run_cloud_cli",
8378
+ title: "Run the cloud CLI as an alias",
8379
+ 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.',
8380
+ inputSchema: {
8381
+ type: "object",
8382
+ properties: {
8383
+ ...aliasInput,
8384
+ args: {
8385
+ type: "array",
8386
+ items: { type: "string" },
8387
+ description: 'CLI arguments, e.g. ["ec2", "describe-instances", "--output", "json"].'
8388
+ },
8389
+ region: {
8390
+ type: "string",
8391
+ description: "Optional region for this call only; defaults to the alias' region."
8392
+ },
8393
+ confirm_production: {
8394
+ type: "boolean",
8395
+ description: "Only for production aliases (prod: true) and commands that may change resources: set it to true after the user explicitly approved this exact command."
8396
+ }
8397
+ },
8398
+ required: ["alias", "args"],
8399
+ additionalProperties: false
8400
+ },
8401
+ annotations: {
8402
+ readOnlyHint: false,
8403
+ destructiveHint: true,
8404
+ idempotentHint: false,
8405
+ openWorldHint: true
8406
+ },
8407
+ run: async (args) => {
8408
+ const alias = text(args, "alias");
8409
+ const cliArgs = stringArray(args, "args");
8410
+ if (cliArgs.includes("--profile")) {
8411
+ throw new CloudhopError(
8412
+ "INVALID_NAME",
8413
+ "Do not pass --profile: the alias already chooses the account and role."
8414
+ );
8415
+ }
8416
+ if (process.platform === "win32" && cliArgs.some((a2) => WINDOWS_SHELL_CHARS.test(a2))) {
8417
+ throw new CloudhopError(
8418
+ "INVALID_NAME",
8419
+ 'On Windows, arguments cannot contain & | < > ^ % ! " or line breaks.',
8420
+ "Ask the user to run this command in their terminal with hop instead."
8421
+ );
8422
+ }
8423
+ const region = typeof args.region === "string" && args.region.trim() ? args.region.trim() : void 0;
8424
+ if (args.confirm_production !== true && await app.needsProductionConfirmation(alias, cliArgs)) {
8425
+ const status = await app.aliasStatus(alias);
8426
+ throw new CloudhopError(
8427
+ "PROD_CONFIRMATION_REQUIRED",
8428
+ `${alias} is PRODUCTION (${status.label ?? status.target}, ${status.region ?? "default region"}) and this command may change resources.`,
8429
+ "Show the user the alias, account, region and the exact command, and ask for explicit approval. Only if they approve, call run_cloud_cli again with confirm_production: true."
8430
+ );
8431
+ }
8432
+ const result = await app.captureAsAlias(alias, cliArgs, region ? { region } : {});
8433
+ return {
8434
+ alias,
8435
+ exitCode: result.exitCode,
8436
+ stdout: cap(result.stdout),
8437
+ stderr: cap(result.stderr)
8438
+ };
8439
+ }
8440
+ }
8441
+ ];
8442
+ }
8443
+
8444
+ // ../mcp/src/server.ts
8445
+ var MCP_INSTRUCTIONS = `cloudhop gives each cloud account + role + region a short alias and signs the user in only when a session expired.
8446
+ 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.
8447
+ 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. Aliases with prod: true are production: run_cloud_cli refuses changes there until the user approves and you pass confirm_production: true.
8448
+ 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.`;
8449
+ var SUPPORTED_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
8450
+ function serveMcp({
8451
+ app,
8452
+ version,
8453
+ input = process.stdin,
8454
+ output = process.stdout
8455
+ }) {
8456
+ const tools = createTools(app);
8457
+ const byName = new Map(tools.map((t2) => [t2.name, t2]));
8458
+ const send2 = (message) => output.write(`${JSON.stringify(message)}
8459
+ `);
8460
+ const reply = (id, result) => send2({ jsonrpc: "2.0", id, result });
8461
+ const fail = (id, code, message) => send2({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
8462
+ const handle = async (request) => {
8463
+ const { id, method, params = {} } = request;
8464
+ const isNotification = id === void 0;
8465
+ switch (method) {
8466
+ case "initialize": {
8467
+ const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
8468
+ reply(id, {
8469
+ protocolVersion: SUPPORTED_VERSIONS.includes(asked) ? asked : SUPPORTED_VERSIONS[0],
8470
+ capabilities: { tools: { listChanged: false } },
8471
+ serverInfo: { name: "cloudhop", title: "cloudhop", version },
8472
+ instructions: MCP_INSTRUCTIONS
8473
+ });
8474
+ return;
8475
+ }
8476
+ case "ping":
8477
+ reply(id, {});
8478
+ return;
8479
+ case "tools/list":
8480
+ reply(id, {
8481
+ tools: tools.map(({ name, title, description, inputSchema, annotations }) => ({
8482
+ name,
8483
+ title,
8484
+ description,
8485
+ inputSchema,
8486
+ annotations
8487
+ }))
8488
+ });
8489
+ return;
8490
+ case "tools/call": {
8491
+ const tool = byName.get(String(params.name));
8492
+ if (!tool) {
8493
+ fail(id, -32602, `Unknown tool: ${String(params.name)}`);
8494
+ return;
8495
+ }
8496
+ const args = params.arguments ?? {};
8497
+ try {
8498
+ const result = await tool.run(args);
8499
+ reply(id, {
8500
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
8501
+ structuredContent: result
8502
+ });
8503
+ } catch (error) {
8504
+ const known = error instanceof CloudhopError;
8505
+ const body = {
8506
+ error: known ? error.code : "UNEXPECTED",
8507
+ message: error.message,
8508
+ ...known && error.hint ? { hint: error.hint } : {}
8509
+ };
8510
+ reply(id, {
8511
+ content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
8512
+ structuredContent: body,
8513
+ isError: true
8514
+ });
8515
+ }
8516
+ return;
8517
+ }
8518
+ default:
8519
+ if (!isNotification) fail(id, -32601, `Method not found: ${method}`);
8520
+ }
8521
+ };
8522
+ return new Promise((resolve) => {
8523
+ const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
8524
+ const pending = /* @__PURE__ */ new Set();
8525
+ lines.on("line", (line) => {
8526
+ if (!line.trim()) return;
8527
+ let request;
8528
+ try {
8529
+ request = JSON.parse(line);
8530
+ } catch {
8531
+ fail(null, -32700, "Parse error");
8532
+ return;
8533
+ }
8534
+ if (typeof request.method !== "string") return;
8535
+ const task = handle(request).catch((error) => {
8536
+ if (request.id !== void 0) fail(request.id, -32603, error.message);
8537
+ });
8538
+ pending.add(task);
8539
+ task.finally(() => pending.delete(task));
8540
+ });
8541
+ lines.on("close", () => {
8542
+ Promise.allSettled([...pending]).then(() => resolve());
8543
+ });
8544
+ });
8545
+ }
8546
+
8547
+ // src/flags.ts
8548
+ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
8549
+ "yes",
8550
+ "all",
8551
+ "list",
8552
+ "noOpen",
8553
+ "json",
8554
+ "noMcp",
8555
+ "force",
8556
+ "prod",
8557
+ "off"
8558
+ ]);
8559
+ var camel = (name) => name.replace(/-([a-z])/g, (_2, c3) => c3.toUpperCase());
8560
+ function parseArgs(argv) {
8561
+ const positional = [];
8562
+ const flags = {};
8563
+ for (let i2 = 0; i2 < argv.length; i2++) {
8564
+ const arg = argv[i2];
8565
+ if (!arg.startsWith("--")) {
8566
+ positional.push(arg);
8567
+ continue;
8568
+ }
8569
+ const [rawName, inline] = arg.slice(2).split(/=(.*)/s, 2);
8570
+ const name = camel(rawName);
8571
+ if (inline !== void 0) flags[name] = inline;
8572
+ else if (BOOLEAN_FLAGS.has(name)) flags[name] = "true";
8573
+ else flags[name] = argv[++i2] ?? "";
8574
+ }
8575
+ return { positional, flags };
8576
+ }
8577
+ var interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
8578
+
8579
+ // ../../node_modules/.pnpm/@clack+prompts@1.8.1/node_modules/@clack/prompts/dist/index.mjs
8580
+ var dist_exports = {};
7846
8581
  __export(dist_exports, {
7847
8582
  CANCEL_SYMBOL: () => CANCEL_SYMBOL,
7848
8583
  MULTISELECT_INSTRUCTIONS: () => MULTISELECT_INSTRUCTIONS,
@@ -7903,7 +8638,7 @@ __export(dist_exports, {
7903
8638
  symbolBar: () => symbolBar,
7904
8639
  taskLog: () => taskLog,
7905
8640
  tasks: () => tasks,
7906
- text: () => text,
8641
+ text: () => text2,
7907
8642
  unicode: () => unicode,
7908
8643
  unicodeOr: () => unicodeOr,
7909
8644
  updateSettings: () => updateSettings
@@ -9240,7 +9975,7 @@ import { styleText as styleText2, stripVTControlCharacters } from "util";
9240
9975
  import process$1 from "process";
9241
9976
  var import_sisteransi2 = __toESM(require_src(), 1);
9242
9977
  import { existsSync, lstatSync, readdirSync } from "fs";
9243
- import { dirname, join } from "path";
9978
+ import { dirname, join as join2 } from "path";
9244
9979
  function isUnicodeSupported() {
9245
9980
  if (process$1.platform !== "win32") {
9246
9981
  return process$1.env.TERM !== "linux";
@@ -10181,7 +10916,7 @@ var path = (e) => {
10181
10916
  existsSync(t2) ? lstatSync(t2).isDirectory() && (!e.directory || t2.endsWith("/")) ? i2 = t2 : i2 = dirname(t2) : i2 = dirname(t2);
10182
10917
  const c3 = t2.length > 1 && t2.endsWith("/") ? t2.slice(0, -1) : t2;
10183
10918
  return readdirSync(i2).map((r2) => {
10184
- const n3 = join(i2, r2), m3 = lstatSync(n3);
10919
+ const n3 = join2(i2, r2), m3 = lstatSync(n3);
10185
10920
  return {
10186
10921
  name: r2,
10187
10922
  path: n3,
@@ -10608,7 +11343,7 @@ ${e.value}` : e.value;
10608
11343
  }
10609
11344
  };
10610
11345
  };
10611
- var text = (t2) => new n2({
11346
+ var text2 = (t2) => new n2({
10612
11347
  validate: t2.validate,
10613
11348
  placeholder: t2.placeholder,
10614
11349
  defaultValue: t2.defaultValue,
@@ -10665,8 +11400,8 @@ function answer(value) {
10665
11400
  }
10666
11401
  var colorful = (stream2) => Boolean(stream2.isTTY) && !process.env.NO_COLOR;
10667
11402
  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;
11403
+ var dim = (text4) => colorful(process.stderr) ? `\x1B[2m${text4}\x1B[22m` : text4;
11404
+ var bold = (text4) => colorful(process.stdout) ? `\x1B[1m${text4}\x1B[22m` : text4;
10670
11405
  var STYLES = {
10671
11406
  dim: ["2", "22"],
10672
11407
  green: ["32", "39"],
@@ -10675,7 +11410,7 @@ var STYLES = {
10675
11410
  sky: [truecolor ? "38;2;59;184;245" : "38;5;75", "39"],
10676
11411
  coral: [truecolor ? "38;2;255;122;89" : "38;5;209", "39"]
10677
11412
  };
10678
- var paint = (style, text3) => colorful(process.stdout) ? `\x1B[${STYLES[style][0]}m${text3}\x1B[${STYLES[style][1]}m` : text3;
11413
+ var paint = (style, text4) => colorful(process.stdout) ? `\x1B[${STYLES[style][0]}m${text4}\x1B[${STYLES[style][1]}m` : text4;
10679
11414
  var BANNER = [
10680
11415
  " _ _ _",
10681
11416
  " ___ | | ___ _ _ __| || |__ ___ _ __",
@@ -10778,14 +11513,12 @@ async function chooseAliases(app, source, targets) {
10778
11513
  );
10779
11514
  const validateRegion2 = regionValidator(app, source);
10780
11515
  let defaultRegion = source.settings.region ?? "";
11516
+ const suggestions = suggestAliases(targets, source.id, existing);
10781
11517
  const aliases = [];
10782
11518
  for (const target of selected) {
10783
11519
  dist_exports.log.step(target.label);
10784
11520
  const suggestion = uniqueName(
10785
- targets.length === 1 && target.id === "console" ? source.id : suggestAliasName(
10786
- target.attributes.accountName ?? target.label,
10787
- target.attributes.roleName ?? ""
10788
- ),
11521
+ targets.length === 1 && target.id === "console" ? source.id : suggestions.get(target.id) ?? suggestAliasName(target.label),
10789
11522
  existing
10790
11523
  );
10791
11524
  const name = answer(
@@ -10804,13 +11537,20 @@ async function chooseAliases(app, source, targets) {
10804
11537
  validate: (v) => v?.trim() ? validateRegion2?.(v.trim()) : void 0
10805
11538
  })
10806
11539
  ).trim() || defaultRegion;
11540
+ const prod = answer(
11541
+ await dist_exports.confirm({
11542
+ message: `Is ${name} production? (cloudhop then asks before any change)`,
11543
+ initialValue: looksLikeProduction(name, target.label)
11544
+ })
11545
+ );
10807
11546
  defaultRegion = region;
10808
11547
  existing.add(name);
10809
11548
  aliases.push({
10810
11549
  name,
10811
11550
  source: source.id,
10812
11551
  target: target.id,
10813
- ...region && region !== source.settings.region ? { region } : {}
11552
+ ...region && region !== source.settings.region ? { region } : {},
11553
+ ...prod ? { prod: true } : {}
10814
11554
  });
10815
11555
  }
10816
11556
  return aliases;
@@ -10902,14 +11642,20 @@ async function aliasCommand(app, { positional, flags }) {
10902
11642
  `See the options with \`cloudhop alias ${id} --list\`.`
10903
11643
  );
10904
11644
  }
10905
- const name = flags.name ?? suggestAliasName(
10906
- target.attributes.accountName ?? target.label,
10907
- target.attributes.roleName ?? ""
10908
- );
11645
+ const taken = new Set((await app.config()).aliases.map((a2) => a2.name));
11646
+ const name = flags.name ?? suggestAliases(targets, id, taken).get(target.id) ?? id;
10909
11647
  await app.addAliases([
10910
- { name, source: id, target: target.id, ...flags.region ? { region: flags.region } : {} }
11648
+ {
11649
+ name,
11650
+ source: id,
11651
+ target: target.id,
11652
+ ...flags.region ? { region: flags.region } : {},
11653
+ ...flags.prod ? { prod: true } : {}
11654
+ }
10911
11655
  ]);
10912
- console.log(`Alias "${name}" \u2192 ${target.label}. Try: hop ${name}`);
11656
+ console.log(
11657
+ `Alias "${name}" \u2192 ${target.label}${flags.prod ? " (production)" : ""}. Try: hop ${name}`
11658
+ );
10913
11659
  return;
10914
11660
  }
10915
11661
  if (scripted) {
@@ -10922,65 +11668,803 @@ async function aliasCommand(app, { positional, flags }) {
10922
11668
  dist_exports.outro(`Created ${aliases.length} alias(es).`);
10923
11669
  }
10924
11670
 
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
- );
10939
- }
10940
- }
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;
10946
- }
10947
- for (const id of ids) {
10948
- await app.ensureSession(id);
10949
- printReport(await app.syncSource(id));
11671
+ // src/commands/agent.ts
11672
+ import { homedir as homedir2 } from "os";
11673
+ import { isAbsolute, relative } from "path";
11674
+
11675
+ // ../assistants/src/installer.ts
11676
+ import { createHash } from "crypto";
11677
+ import { existsSync as existsSync2 } from "fs";
11678
+ import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, rmdir, writeFile as writeFile2 } from "fs/promises";
11679
+ import { dirname as dirname2, join as join4 } from "path";
11680
+
11681
+ // ../assistants/src/assets/skill.md
11682
+ 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- **The same command in several aliases** (after the user chose them):\n `cloudhop each a,b,c --yes -- <aws arguments>`; add `--json` to parse the\n results.\n- **The AWS web console**: `cloudhop console <alias> [service]` opens it in\n the user's browser, already signed in. Never try to print or fetch its URL.\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- **Production aliases** have `\"prod\": true` in `cloudhop ls --json` (and\n `PROD` in `cloudhop ls`). Confirm every change there and suggest a dry run\n (`--dryrun`, `terraform plan`) first. cloudhop enforces it: without a\n terminal, `hop`/`cloudhop exec` refuse changes on production with\n `PROD_CONFIRMATION_REQUIRED`. After the user explicitly approves the exact\n command, rerun it with `CLOUDHOP_CONFIRM_PROD=1` in that command's\n environment (with MCP: `confirm_production: true`). Never set it on your own.\n- Treat aliases or accounts that merely look like production (`prd`, `live`)\n the same way, even if they are not marked.\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";
11683
+
11684
+ // ../assistants/src/targets.ts
11685
+ import { join as join3 } from "path";
11686
+ var ASSISTANT_IDS = [
11687
+ "claude",
11688
+ "kiro",
11689
+ "copilot",
11690
+ "cursor",
11691
+ "codex"
11692
+ ];
11693
+ var SKILL_NAME = "cloudhop";
11694
+ var MCP_COMMAND = "cloudhop";
11695
+ var MCP_ARGS = ["mcp"];
11696
+ var ASSISTANTS = {
11697
+ claude: {
11698
+ id: "claude",
11699
+ name: "Claude Code",
11700
+ markers: ({ home }) => [join3(home, ".claude")],
11701
+ skillDir: (scope, { home, project }) => join3(scope === "user" ? home : project, ".claude", "skills", SKILL_NAME),
11702
+ mcp: (scope, { project }) => scope === "project" ? { kind: "json", file: join3(project, ".mcp.json"), key: "mcpServers", withType: false } : {
11703
+ kind: "cli",
11704
+ command: "claude",
11705
+ add: ["mcp", "add", "--scope", "user", "cloudhop", "--", MCP_COMMAND, ...MCP_ARGS],
11706
+ remove: ["mcp", "remove", "--scope", "user", "cloudhop"],
11707
+ manual: "claude mcp add --scope user cloudhop -- cloudhop mcp"
11708
+ }
11709
+ },
11710
+ kiro: {
11711
+ id: "kiro",
11712
+ name: "Kiro",
11713
+ markers: ({ home }) => [join3(home, ".kiro")],
11714
+ skillDir: (scope, { home, project }) => join3(scope === "user" ? home : project, ".kiro", "skills", SKILL_NAME),
11715
+ mcp: (scope, { home, project }) => ({
11716
+ kind: "json",
11717
+ file: join3(scope === "user" ? home : project, ".kiro", "settings", "mcp.json"),
11718
+ key: "mcpServers",
11719
+ withType: false
11720
+ })
11721
+ },
11722
+ copilot: {
11723
+ id: "copilot",
11724
+ name: "GitHub Copilot (VS Code)",
11725
+ markers: ({ home }) => [join3(home, ".copilot"), join3(home, ".vscode")],
11726
+ skillDir: (scope, { home, project }) => scope === "user" ? join3(home, ".copilot", "skills", SKILL_NAME) : join3(project, ".github", "skills", SKILL_NAME),
11727
+ mcp: (scope, { project }) => scope === "project" ? {
11728
+ kind: "json",
11729
+ file: join3(project, ".vscode", "mcp.json"),
11730
+ key: "servers",
11731
+ withType: true
11732
+ } : {
11733
+ kind: "cli",
11734
+ command: "code",
11735
+ add: [
11736
+ "--add-mcp",
11737
+ JSON.stringify({
11738
+ name: "cloudhop",
11739
+ type: "stdio",
11740
+ command: MCP_COMMAND,
11741
+ args: MCP_ARGS
11742
+ })
11743
+ ],
11744
+ remove: void 0,
11745
+ manual: 'VS Code \u2192 "MCP: Add Server" \u2192 stdio \u2192 command `cloudhop mcp`'
11746
+ }
11747
+ },
11748
+ cursor: {
11749
+ id: "cursor",
11750
+ name: "Cursor",
11751
+ markers: ({ home }) => [join3(home, ".cursor")],
11752
+ skillDir: (scope, { home, project }) => join3(scope === "user" ? home : project, ".cursor", "skills", SKILL_NAME),
11753
+ mcp: (scope, { home, project }) => ({
11754
+ kind: "json",
11755
+ file: join3(scope === "user" ? home : project, ".cursor", "mcp.json"),
11756
+ key: "mcpServers",
11757
+ withType: true
11758
+ })
11759
+ },
11760
+ codex: {
11761
+ id: "codex",
11762
+ name: "Codex",
11763
+ markers: ({ home }) => [join3(home, ".codex"), join3(home, ".agents")],
11764
+ skillDir: (scope, { home, project }) => join3(scope === "user" ? home : project, ".agents", "skills", SKILL_NAME),
11765
+ mcp: (scope, { home, project }) => ({
11766
+ kind: "toml",
11767
+ file: join3(scope === "user" ? home : project, ".codex", "config.toml")
11768
+ })
10950
11769
  }
11770
+ };
11771
+
11772
+ // ../assistants/src/installer.ts
11773
+ var sha = (text4) => createHash("sha256").update(text4).digest("hex");
11774
+ async function writeAtomic(file, content) {
11775
+ await mkdir2(dirname2(file), { recursive: true });
11776
+ const tmp = `${file}.cloudhop-${process.pid}.tmp`;
11777
+ await writeFile2(tmp, content);
11778
+ await rename2(tmp, file);
10951
11779
  }
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));
11780
+ async function removeFileAndEmptyDir(file) {
11781
+ await rm2(file, { force: true });
11782
+ await rmdir(dirname2(file)).catch(() => {
11783
+ });
10961
11784
  }
10962
- var SESSION_LABEL = {
10963
- valid: paint("green", "\u25CF session valid"),
10964
- refreshable: paint("yellow", "\u25D0 renews automatically on next use"),
10965
- expired: paint("red", "\u25CB expired, opens the browser on next use"),
10966
- unknown: paint("dim", "\xB7 session managed by the cloud CLI")
10967
- };
10968
- async function listCommand(app, sourceId) {
10969
- const config = await app.config();
10970
- if (config.sources.length === 0) {
10971
- console.log("Nothing here yet. Run `cloudhop setup` to get started.");
10972
- return;
11785
+ var readText = (file) => readFile2(file, "utf8").catch(() => void 0);
11786
+ var AssistantsInstaller = class {
11787
+ constructor(options) {
11788
+ this.options = options;
10973
11789
  }
10974
- const sources = sourceId ? config.sources.filter((s) => s.id === sourceId) : config.sources;
10975
- if (sources.length === 0) {
10976
- throw new CloudhopError(
10977
- "SOURCE_NOT_FOUND",
10978
- `Source "${sourceId}" not found.`,
10979
- `Sources: ${config.sources.map((s) => s.id).join(", ")}.`
10980
- );
11790
+ options;
11791
+ /** The skill as written to disk: the bundled content plus a footer naming the version. */
11792
+ get skillContent() {
11793
+ return `${skill_default.replace(/\r\n/g, "\n").trimEnd()}
11794
+
11795
+ <!-- Installed by cloudhop ${this.options.cloudhopVersion}. Refresh with \`cloudhop agent update\`; edits here are kept. -->
11796
+ `;
10981
11797
  }
10982
- const aliases = await app.listAliases();
10983
- const nameWidth = Math.max(5, ...aliases.map((a2) => a2.alias.name.length)) + 2;
11798
+ /** Assistants that look installed on this machine. */
11799
+ detect() {
11800
+ const { places } = this.options;
11801
+ return ASSISTANT_IDS.filter((id) => ASSISTANTS[id].markers(places).some((p3) => existsSync2(p3)));
11802
+ }
11803
+ async status(scope) {
11804
+ const detected = new Set(this.detect());
11805
+ const manifest = await this.readManifest();
11806
+ return Promise.all(
11807
+ ASSISTANT_IDS.map(async (id) => {
11808
+ const assistant = ASSISTANTS[id];
11809
+ const skillPath = join4(assistant.skillDir(scope, this.options.places), "SKILL.md");
11810
+ const entry = manifest.find((e) => e.skillFile === skillPath);
11811
+ return {
11812
+ assistant: id,
11813
+ name: assistant.name,
11814
+ scope,
11815
+ detected: detected.has(id),
11816
+ skill: await this.skillState(skillPath, entry),
11817
+ skillPath,
11818
+ mcp: await this.mcpState(assistant.mcp(scope, this.options.places))
11819
+ };
11820
+ })
11821
+ );
11822
+ }
11823
+ async install(ids, scope, { mcp = true } = {}) {
11824
+ const results = [];
11825
+ const manifest = await this.readManifest();
11826
+ for (const id of ids) {
11827
+ const assistant = ASSISTANTS[id];
11828
+ const skillPath = join4(assistant.skillDir(scope, this.options.places), "SKILL.md");
11829
+ const content = this.skillContent;
11830
+ await writeAtomic(skillPath, content);
11831
+ const kept = manifest.filter((e) => e.skillFile !== skillPath);
11832
+ kept.push({
11833
+ assistant: id,
11834
+ scope,
11835
+ skillFile: skillPath,
11836
+ hash: sha(content),
11837
+ cloudhopVersion: this.options.cloudhopVersion
11838
+ });
11839
+ manifest.splice(0, manifest.length, ...kept);
11840
+ const target = assistant.mcp(scope, this.options.places);
11841
+ const outcome = mcp ? await this.addMcp(target) : { mcp: "skipped" };
11842
+ results.push({ assistant: id, name: assistant.name, scope, skillPath, ...outcome });
11843
+ }
11844
+ await this.writeManifest(manifest);
11845
+ return results;
11846
+ }
11847
+ async remove(ids, scope) {
11848
+ const results = [];
11849
+ let manifest = await this.readManifest();
11850
+ for (const id of ids) {
11851
+ const assistant = ASSISTANTS[id];
11852
+ const dir = assistant.skillDir(scope, this.options.places);
11853
+ const skillPath = join4(dir, "SKILL.md");
11854
+ await rm2(dir, { recursive: true, force: true });
11855
+ await rmdir(dirname2(dir)).then(() => rmdir(dirname2(dirname2(dir)))).catch(() => {
11856
+ });
11857
+ manifest = manifest.filter((e) => e.skillFile !== skillPath);
11858
+ const outcome = await this.removeMcp(assistant.mcp(scope, this.options.places));
11859
+ results.push({ assistant: id, name: assistant.name, scope, skillPath, ...outcome });
11860
+ }
11861
+ await this.writeManifest(manifest);
11862
+ return results;
11863
+ }
11864
+ /**
11865
+ * Refreshes skills written by an older cloudhop. Copies the user edited are
11866
+ * left alone unless `force`. Cheap when there is nothing to do: one small read.
11867
+ */
11868
+ async update({ force = false } = {}) {
11869
+ const manifest = await this.readManifest();
11870
+ const content = this.skillContent;
11871
+ const target = sha(content);
11872
+ const updated = [];
11873
+ const edited = [];
11874
+ let changed = false;
11875
+ for (const entry of manifest) {
11876
+ if (entry.hash === target) continue;
11877
+ const onDisk = await readText(entry.skillFile);
11878
+ if (onDisk === void 0) continue;
11879
+ if (sha(onDisk) !== entry.hash && !force) {
11880
+ edited.push(entry.skillFile);
11881
+ continue;
11882
+ }
11883
+ await writeAtomic(entry.skillFile, content);
11884
+ entry.hash = target;
11885
+ entry.cloudhopVersion = this.options.cloudhopVersion;
11886
+ updated.push(entry.skillFile);
11887
+ changed = true;
11888
+ }
11889
+ if (changed) await this.writeManifest(manifest);
11890
+ return { updated, edited };
11891
+ }
11892
+ // ───────────── skill state ─────────────
11893
+ async skillState(path2, entry) {
11894
+ const onDisk = await readText(path2);
11895
+ if (onDisk === void 0) return "missing";
11896
+ if (onDisk === this.skillContent) return "current";
11897
+ if (entry && sha(onDisk) !== entry.hash) return "modified";
11898
+ return "outdated";
11899
+ }
11900
+ // ───────────── MCP registration ─────────────
11901
+ async mcpState(target) {
11902
+ if (target.kind === "cli") return "unknown";
11903
+ const text4 = await readText(target.file);
11904
+ if (text4 === void 0) return "missing";
11905
+ if (target.kind === "toml")
11906
+ return /^\[mcp_servers\.cloudhop\]/m.test(text4) ? "registered" : "missing";
11907
+ try {
11908
+ const servers = JSON.parse(text4)[target.key];
11909
+ return servers?.cloudhop ? "registered" : "missing";
11910
+ } catch {
11911
+ return "unknown";
11912
+ }
11913
+ }
11914
+ async addMcp(target) {
11915
+ switch (target.kind) {
11916
+ case "json": {
11917
+ const config = await this.readJsonConfig(target.file);
11918
+ config[target.key] ??= {};
11919
+ const servers = config[target.key];
11920
+ const entry = {
11921
+ ...target.withType ? { type: "stdio" } : {},
11922
+ command: MCP_COMMAND,
11923
+ args: MCP_ARGS
11924
+ };
11925
+ if (JSON.stringify(servers.cloudhop) === JSON.stringify(entry)) return { mcp: "already" };
11926
+ servers.cloudhop = entry;
11927
+ await writeAtomic(target.file, `${JSON.stringify(config, null, 2)}
11928
+ `);
11929
+ return { mcp: "added" };
11930
+ }
11931
+ case "toml": {
11932
+ const text4 = await readText(target.file) ?? "";
11933
+ if (/^\[mcp_servers\.cloudhop\]/m.test(text4)) return { mcp: "already" };
11934
+ const block2 = `${TOML_MARK}
11935
+ [mcp_servers.cloudhop]
11936
+ command = "${MCP_COMMAND}"
11937
+ args = [${MCP_ARGS.map((a2) => `"${a2}"`).join(", ")}]
11938
+ `;
11939
+ await writeAtomic(
11940
+ target.file,
11941
+ `${text4}${text4 && !text4.endsWith("\n") ? "\n" : ""}${text4 ? "\n" : ""}${block2}`
11942
+ );
11943
+ return { mcp: "added" };
11944
+ }
11945
+ case "cli": {
11946
+ if (!(await this.options.runner.probe(target.command)).installed) {
11947
+ return { mcp: "manual", mcpManual: target.manual };
11948
+ }
11949
+ const result = await this.options.runner.capture(target.command, target.add);
11950
+ if (result.exitCode === 0) return { mcp: "added" };
11951
+ if (/already exists/i.test(`${result.stdout}${result.stderr}`)) return { mcp: "already" };
11952
+ return { mcp: "manual", mcpManual: target.manual };
11953
+ }
11954
+ }
11955
+ }
11956
+ async removeMcp(target) {
11957
+ switch (target.kind) {
11958
+ case "json": {
11959
+ const text4 = await readText(target.file);
11960
+ if (text4 === void 0) return { mcp: "already" };
11961
+ const config = await this.readJsonConfig(target.file);
11962
+ const servers = config[target.key];
11963
+ if (!servers?.cloudhop) return { mcp: "already" };
11964
+ delete servers.cloudhop;
11965
+ const onlyEmptyKey = Object.keys(servers).length === 0 && Object.keys(config).length === 1;
11966
+ if (onlyEmptyKey) await removeFileAndEmptyDir(target.file);
11967
+ else await writeAtomic(target.file, `${JSON.stringify(config, null, 2)}
11968
+ `);
11969
+ return { mcp: "removed" };
11970
+ }
11971
+ case "toml": {
11972
+ const text4 = await readText(target.file);
11973
+ if (!text4 || !/^\[mcp_servers\.cloudhop\]/m.test(text4)) return { mcp: "already" };
11974
+ 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");
11975
+ if (cleaned.trim()) await writeAtomic(target.file, `${cleaned.trimEnd()}
11976
+ `);
11977
+ else await removeFileAndEmptyDir(target.file);
11978
+ return { mcp: "removed" };
11979
+ }
11980
+ case "cli": {
11981
+ if (!target.remove || !(await this.options.runner.probe(target.command)).installed) {
11982
+ return {
11983
+ mcp: "manual",
11984
+ mcpManual: `Remove the "cloudhop" MCP server in ${target.command === "code" ? "VS Code (MCP: List Servers)" : target.command}.`
11985
+ };
11986
+ }
11987
+ await this.options.runner.capture(target.command, target.remove);
11988
+ return { mcp: "removed" };
11989
+ }
11990
+ }
11991
+ }
11992
+ async readJsonConfig(file) {
11993
+ const text4 = await readText(file);
11994
+ if (text4 === void 0 || !text4.trim()) return {};
11995
+ try {
11996
+ const value = JSON.parse(text4);
11997
+ if (value && typeof value === "object" && !Array.isArray(value))
11998
+ return value;
11999
+ } catch {
12000
+ }
12001
+ throw new CloudhopError(
12002
+ "INVALID_NAME",
12003
+ `${file} isn't plain JSON (it may have comments), so cloudhop won't rewrite it.`,
12004
+ `Add the server by hand: "cloudhop": { "command": "cloudhop", "args": ["mcp"] }, or rerun with --no-mcp.`
12005
+ );
12006
+ }
12007
+ // ───────────── manifest ─────────────
12008
+ get manifestPath() {
12009
+ return join4(this.options.configDir, "assistants.json");
12010
+ }
12011
+ async readManifest() {
12012
+ const text4 = await readText(this.manifestPath);
12013
+ if (!text4) return [];
12014
+ try {
12015
+ const parsed = JSON.parse(text4);
12016
+ return Array.isArray(parsed.installs) ? parsed.installs : [];
12017
+ } catch {
12018
+ return [];
12019
+ }
12020
+ }
12021
+ async writeManifest(installs) {
12022
+ await writeAtomic(this.manifestPath, `${JSON.stringify({ installs }, null, 2)}
12023
+ `);
12024
+ }
12025
+ };
12026
+ var TOML_MARK = "# Added by cloudhop (`cloudhop agent remove` deletes it)";
12027
+ var escapeRegExp = (text4) => text4.replace(/[.*+?^${}()|[\]\\`]/g, "\\$&");
12028
+
12029
+ // src/commands/agent.ts
12030
+ var MCP_TEXT = {
12031
+ added: paint("green", "MCP server added"),
12032
+ already: "MCP server already there",
12033
+ removed: "MCP server removed",
12034
+ manual: paint("yellow", "MCP server: add it by hand"),
12035
+ skipped: paint("dim", "MCP server skipped")
12036
+ };
12037
+ var tilde = (path2) => {
12038
+ const inProject = relative(process.cwd(), path2);
12039
+ if (!inProject.startsWith("..") && !isAbsolute(inProject)) return inProject;
12040
+ return path2.replace(homedir2(), "~");
12041
+ };
12042
+ function parseTools(value, fallback) {
12043
+ if (!value) return fallback;
12044
+ const ids = value.split(",").map((v) => v.trim().toLowerCase());
12045
+ const unknown = ids.filter((id) => !ASSISTANT_IDS.includes(id));
12046
+ if (unknown.length) {
12047
+ throw new CloudhopError(
12048
+ "INVALID_NAME",
12049
+ `Unknown assistant: ${unknown.join(", ")}.`,
12050
+ `Use any of: ${ASSISTANT_IDS.join(", ")}.`
12051
+ );
12052
+ }
12053
+ return ids;
12054
+ }
12055
+ function parseScope(value) {
12056
+ if (value === void 0) return void 0;
12057
+ if (value === "user" || value === "project") return value;
12058
+ throw new CloudhopError("INVALID_NAME", '--scope must be "user" or "project".');
12059
+ }
12060
+ function printResults(results) {
12061
+ for (const r2 of results) {
12062
+ console.log(`${paint("green", "\u2714")} ${bold(r2.name)}`);
12063
+ console.log(` skill ${tilde(r2.skillPath)}`);
12064
+ console.log(` ${MCP_TEXT[r2.mcp]}${r2.mcpManual ? `: ${r2.mcpManual}` : ""}`);
12065
+ }
12066
+ }
12067
+ async function agentCommand(installer, args) {
12068
+ const { positional, flags } = parseArgs(args);
12069
+ const action = positional[0] ?? "status";
12070
+ const scopeFlag = parseScope(flags.scope);
12071
+ switch (action) {
12072
+ case "status": {
12073
+ for (const scope of scopeFlag ? [scopeFlag] : ["user", "project"]) {
12074
+ console.log(
12075
+ bold(
12076
+ scope === "user" ? "For you (all projects)" : `This project (${tilde(process.cwd())})`
12077
+ )
12078
+ );
12079
+ for (const s of await installer.status(scope)) {
12080
+ 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(
12081
+ "yellow",
12082
+ "\u25D0 skill edited by you (`cloudhop agent update --force` replaces it)"
12083
+ ) : paint("dim", "\u25CB no skill");
12084
+ const mcp = s.mcp === "registered" ? ", MCP on" : s.mcp === "unknown" ? ", MCP: check in the app" : "";
12085
+ const seen = s.detected ? "" : paint("dim", " (not detected)");
12086
+ console.log(` ${s.name.padEnd(26)} ${skill}${s.skill === "missing" ? "" : mcp}${seen}`);
12087
+ }
12088
+ }
12089
+ return;
12090
+ }
12091
+ case "install": {
12092
+ const detected = installer.detect();
12093
+ let tools = parseTools(flags.tools, detected);
12094
+ let scope = scopeFlag ?? "user";
12095
+ let mcp = !flags.noMcp;
12096
+ if (!flags.tools && interactive() && !flags.yes) {
12097
+ dist_exports.intro("cloudhop agent install");
12098
+ dist_exports.note(
12099
+ "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).",
12100
+ "Teach your AI assistant cloudhop"
12101
+ );
12102
+ tools = answer(
12103
+ await dist_exports.multiselect({
12104
+ message: "Which assistants do you use?",
12105
+ options: ASSISTANT_IDS.map((id) => ({
12106
+ value: id,
12107
+ label: ASSISTANTS[id].name,
12108
+ ...detected.includes(id) ? { hint: "detected" } : {}
12109
+ })),
12110
+ initialValues: detected,
12111
+ required: true
12112
+ })
12113
+ );
12114
+ if (!scopeFlag) {
12115
+ scope = answer(
12116
+ await dist_exports.select({
12117
+ message: "Where should it go?",
12118
+ options: [
12119
+ { value: "user", label: "For me, in every project", hint: "your home folder" },
12120
+ {
12121
+ value: "project",
12122
+ label: "Only this project",
12123
+ hint: "commit it and your team gets it too"
12124
+ }
12125
+ ]
12126
+ })
12127
+ );
12128
+ }
12129
+ if (!flags.noMcp) {
12130
+ mcp = answer(
12131
+ await dist_exports.confirm({
12132
+ message: "Also register the cloudhop MCP server?",
12133
+ initialValue: true
12134
+ })
12135
+ );
12136
+ }
12137
+ }
12138
+ if (tools.length === 0) {
12139
+ throw new CloudhopError(
12140
+ "INVALID_NAME",
12141
+ "No AI assistant detected.",
12142
+ `Choose them with --tools ${ASSISTANT_IDS.join(",")}.`
12143
+ );
12144
+ }
12145
+ printResults(await installer.install(tools, scope, { mcp }));
12146
+ console.log(
12147
+ paint("dim", "\nStart a new chat (or restart the assistant) so it picks up the skill.")
12148
+ );
12149
+ return;
12150
+ }
12151
+ case "update": {
12152
+ const { updated, edited } = await installer.update({ force: Boolean(flags.force) });
12153
+ for (const file of updated) console.log(`${paint("green", "\u2714")} updated ${tilde(file)}`);
12154
+ for (const file of edited)
12155
+ console.log(
12156
+ `${paint("yellow", "\u25D0")} kept your edits in ${tilde(file)} (use --force to replace)`
12157
+ );
12158
+ if (!updated.length && !edited.length) console.log("Every installed skill is up to date.");
12159
+ return;
12160
+ }
12161
+ case "remove": {
12162
+ const scope = scopeFlag ?? "user";
12163
+ const statuses = await installer.status(scope);
12164
+ const installed = statuses.filter((s) => s.skill !== "missing" || s.mcp === "registered").map((s) => s.assistant);
12165
+ const tools = parseTools(flags.tools, installed);
12166
+ if (tools.length === 0) {
12167
+ console.log(`Nothing to remove for scope "${scope}".`);
12168
+ return;
12169
+ }
12170
+ printResults(await installer.remove(tools, scope));
12171
+ return;
12172
+ }
12173
+ default:
12174
+ throw new CloudhopError(
12175
+ "INVALID_NAME",
12176
+ `Unknown action "${action}".`,
12177
+ "Use: cloudhop agent [status | install | update | remove]"
12178
+ );
12179
+ }
12180
+ }
12181
+
12182
+ // src/picker.ts
12183
+ async function pickAlias(app, message = "Which alias?") {
12184
+ if (!interactive()) {
12185
+ throw new CloudhopError(
12186
+ "NOT_INTERACTIVE",
12187
+ "Say which alias to use.",
12188
+ "See them with `cloudhop ls`."
12189
+ );
12190
+ }
12191
+ const { aliases } = await app.overview();
12192
+ if (aliases.length === 0) {
12193
+ throw new CloudhopError(
12194
+ "ALIAS_NOT_FOUND",
12195
+ "No aliases yet.",
12196
+ "Create one with `cloudhop alias <source>`."
12197
+ );
12198
+ }
12199
+ const haystack = new Map(
12200
+ aliases.map((a2) => [
12201
+ a2.name,
12202
+ `${a2.name} ${a2.label ?? a2.target} ${a2.region ?? ""} ${a2.source}`.toLowerCase()
12203
+ ])
12204
+ );
12205
+ return answer(
12206
+ await dist_exports.autocomplete({
12207
+ message: `${message} ${paint("dim", "(type to filter)")}`,
12208
+ maxItems: 12,
12209
+ options: aliases.map((a2) => ({
12210
+ value: a2.name,
12211
+ label: a2.prod ? `${a2.name} ${paint("red", "PROD")}` : a2.name,
12212
+ hint: `${a2.label ?? a2.target} \xB7 ${a2.region ?? "no region"}`
12213
+ })),
12214
+ filter: (search, option) => {
12215
+ const text4 = haystack.get(option.value) ?? "";
12216
+ return search.toLowerCase().split(/\s+/).filter(Boolean).every((word) => text4.includes(word));
12217
+ }
12218
+ })
12219
+ );
12220
+ }
12221
+
12222
+ // src/commands/console.ts
12223
+ var HOW = {
12224
+ "signed-in": "signed in",
12225
+ portal: "through the SSO portal (AWS did not accept a direct sign-in)",
12226
+ "sign-in-page": "at the sign-in page (sign in with your console user)"
12227
+ };
12228
+ async function consoleCommand(app, args) {
12229
+ const { positional, flags } = parseArgs(args);
12230
+ const alias = positional[0] ?? await pickAlias(app, "Open the console as");
12231
+ const service = positional[1];
12232
+ const via = await app.openConsole(alias, {
12233
+ ...service ? { service } : {},
12234
+ ...flags.region ? { region: flags.region } : {}
12235
+ });
12236
+ notify(`Opened the ${service ? `${service} ` : ""}console for ${alias}, ${HOW[via]}.`);
12237
+ }
12238
+
12239
+ // src/commands/each.ts
12240
+ function matcher(selector) {
12241
+ const regex = /^\/(.+)\/([a-z]*)$/.exec(selector);
12242
+ if (regex) {
12243
+ try {
12244
+ const re = new RegExp(regex[1], regex[2]);
12245
+ return (name) => re.test(name);
12246
+ } catch {
12247
+ throw new CloudhopError("INVALID_NAME", `Invalid regular expression: ${selector}`);
12248
+ }
12249
+ }
12250
+ if (/[*?]/.test(selector)) {
12251
+ const escaped = selector.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
12252
+ const re = new RegExp(`^${escaped}$`);
12253
+ return (name) => re.test(name);
12254
+ }
12255
+ return (name) => name === selector;
12256
+ }
12257
+ function selectAliases(aliases, selectors, filters) {
12258
+ const patterns = selectors.flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean);
12259
+ const tests = patterns.map(matcher);
12260
+ for (const [i2, pattern] of patterns.entries()) {
12261
+ const plain = !/[*?]/.test(pattern) && !pattern.startsWith("/");
12262
+ if (plain && !aliases.some((a2) => tests[i2]?.(a2.name))) {
12263
+ throw new CloudhopError(
12264
+ "ALIAS_NOT_FOUND",
12265
+ `Alias "${pattern}" not found.`,
12266
+ "See your aliases with `cloudhop ls`."
12267
+ );
12268
+ }
12269
+ }
12270
+ return aliases.filter(
12271
+ (a2) => (tests.length === 0 || tests.some((t2) => t2(a2.name))) && (filters.source === void 0 || a2.source === filters.source) && (filters.prod === void 0 || a2.prod === filters.prod)
12272
+ );
12273
+ }
12274
+ async function mapLimit(items, limit, fn) {
12275
+ const results = new Array(items.length);
12276
+ let next = 0;
12277
+ const worker = async () => {
12278
+ while (next < items.length) {
12279
+ const i2 = next++;
12280
+ results[i2] = await fn(items[i2]);
12281
+ }
12282
+ };
12283
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
12284
+ return results;
12285
+ }
12286
+ async function eachCommand(app, args) {
12287
+ const separator = args.indexOf("--");
12288
+ if (separator === -1 || separator === args.length - 1) {
12289
+ throw new CloudhopError(
12290
+ "INVALID_NAME",
12291
+ "Usage: cloudhop each <alias|list|pattern|/regex/...> [--source <s>] [--prod|--no-prod] -- <command>",
12292
+ "Example: cloudhop each 'creci-*' -- s3 ls"
12293
+ );
12294
+ }
12295
+ const { positional, flags } = parseArgs(args.slice(0, separator));
12296
+ const cliArgs = args.slice(separator + 1);
12297
+ if (positional.length === 0 && !flags.source && !flags.prod && !flags.noProd) {
12298
+ throw new CloudhopError(
12299
+ "INVALID_NAME",
12300
+ 'Say which aliases: names, a pattern like "globex-*", /regex/, --source or --prod.'
12301
+ );
12302
+ }
12303
+ const prodFilter = flags.prod ? true : flags.noProd ? false : void 0;
12304
+ const { aliases } = await app.overview();
12305
+ const selected = selectAliases(aliases, positional, {
12306
+ ...flags.source ? { source: flags.source } : {},
12307
+ ...prodFilter !== void 0 ? { prod: prodFilter } : {}
12308
+ });
12309
+ if (selected.length === 0)
12310
+ throw new CloudhopError("ALIAS_NOT_FOUND", "No alias matches that selection.");
12311
+ const command = `aws ${cliArgs.join(" ")}`;
12312
+ const width = Math.max(...selected.map((a2) => a2.name.length)) + 2;
12313
+ const listing = selected.map(
12314
+ (a2) => ` ${a2.name.padEnd(width)}${(a2.region ?? "-").padEnd(12)}${a2.label ?? a2.target}${a2.prod ? ` ${paint("red", "PROD")}` : ""}`
12315
+ ).join("\n");
12316
+ const risky = [];
12317
+ for (const a2 of selected)
12318
+ if (await app.needsProductionConfirmation(a2.name, cliArgs)) risky.push(a2.name);
12319
+ const tty = interactive();
12320
+ if (!flags.json)
12321
+ process.stderr.write(
12322
+ `${bold(`${selected.length} alias(es)`)} will run ${paint("sky", command)}
12323
+ ${listing}
12324
+ `
12325
+ );
12326
+ if (!flags.yes) {
12327
+ if (!tty)
12328
+ throw new CloudhopError(
12329
+ "NOT_INTERACTIVE",
12330
+ "Confirm with --yes to run in several aliases without a terminal."
12331
+ );
12332
+ if (!answer(
12333
+ await dist_exports.confirm({
12334
+ message: `Run it in these ${selected.length} alias(es)?`,
12335
+ initialValue: false
12336
+ })
12337
+ ))
12338
+ return 130;
12339
+ }
12340
+ if (risky.length > 0 && process.env.CLOUDHOP_CONFIRM_PROD !== "1") {
12341
+ if (!tty) {
12342
+ throw new CloudhopError(
12343
+ "PROD_CONFIRMATION_REQUIRED",
12344
+ `This may change resources in production: ${risky.join(", ")}.`,
12345
+ "Run it in a terminal to confirm, or set CLOUDHOP_CONFIRM_PROD=1 once a person has approved it."
12346
+ );
12347
+ }
12348
+ dist_exports.log.warn(
12349
+ `${paint("red", bold("PRODUCTION"))} ${risky.join(", ")}: this may change resources.`
12350
+ );
12351
+ if (!answer(await dist_exports.confirm({ message: "Also run it in production?", initialValue: false })))
12352
+ return 130;
12353
+ }
12354
+ for (const source of new Set(selected.map((a2) => a2.source))) {
12355
+ await app.ensureSession(source, { interactive: tty });
12356
+ }
12357
+ const parallel = Math.max(1, Math.min(16, Number(flags.parallel) || 4));
12358
+ const outcomes = await mapLimit(selected, parallel, async (a2) => {
12359
+ try {
12360
+ const r2 = await app.captureAsAlias(a2.name, cliArgs);
12361
+ const outcome = { alias: a2.name, exitCode: r2.exitCode, stdout: r2.stdout, stderr: r2.stderr };
12362
+ if (!flags.json) printOutcome(outcome, width);
12363
+ return outcome;
12364
+ } catch (error) {
12365
+ const outcome = { alias: a2.name, exitCode: 1, stdout: "", stderr: error.message };
12366
+ if (!flags.json) printOutcome(outcome, width);
12367
+ return outcome;
12368
+ }
12369
+ });
12370
+ const failed = outcomes.filter((o2) => o2.exitCode !== 0);
12371
+ if (flags.json) {
12372
+ console.log(JSON.stringify({ version: 1, command: cliArgs, results: outcomes }, null, 2));
12373
+ } else {
12374
+ const summary = `${outcomes.length - failed.length} ok${failed.length ? `, ${failed.length} failed: ${failed.map((f2) => f2.alias).join(", ")}` : ""}`;
12375
+ process.stderr.write(`${failed.length ? paint("red", summary) : paint("green", summary)}
12376
+ `);
12377
+ }
12378
+ return failed.length ? 1 : 0;
12379
+ }
12380
+ function printOutcome({ alias, exitCode, stdout: stdout2, stderr }, width) {
12381
+ const tag = paint(exitCode === 0 ? "sky" : "red", `[${alias}]`.padEnd(width + 2));
12382
+ for (const line of stdout2.replace(/\n$/, "").split("\n"))
12383
+ if (line) process.stdout.write(`${tag} ${line}
12384
+ `);
12385
+ for (const line of stderr.replace(/\n$/, "").split("\n"))
12386
+ if (line) process.stderr.write(`${tag} ${paint("dim", line)}
12387
+ `);
12388
+ if (exitCode !== 0) process.stderr.write(`${tag} ${paint("red", `exit ${exitCode}`)}
12389
+ `);
12390
+ }
12391
+
12392
+ // src/commands/manage.ts
12393
+ function printReport(report) {
12394
+ const changes = [
12395
+ `${report.targets.length} available`,
12396
+ report.added.length ? `${report.added.length} new` : "",
12397
+ report.removed.length ? `${report.removed.length} removed` : ""
12398
+ ].filter(Boolean);
12399
+ dist_exports.log.success(`${report.sourceId}: ${changes.join(", ")}.`);
12400
+ for (const t2 of report.added) dist_exports.log.message(` + ${t2.label}`);
12401
+ for (const t2 of report.removed) dist_exports.log.message(` - ${t2.label}`);
12402
+ if (report.orphanedAliases.length) {
12403
+ dist_exports.log.warn(
12404
+ `No longer available: ${report.orphanedAliases.join(", ")}. Remove them with \`cloudhop rm <alias>\`.`
12405
+ );
12406
+ }
12407
+ }
12408
+ async function syncCommand(app, sourceId) {
12409
+ const ids = sourceId ? [sourceId] : (await app.config()).sources.map((s) => s.id);
12410
+ if (ids.length === 0) {
12411
+ dist_exports.log.info("No sources yet. Run `cloudhop add`.");
12412
+ return;
12413
+ }
12414
+ for (const id of ids) {
12415
+ await app.ensureSession(id);
12416
+ printReport(await app.syncSource(id));
12417
+ }
12418
+ }
12419
+ async function loginCommand(app, sourceId) {
12420
+ const { sources } = await app.config();
12421
+ const id = sourceId ?? answer(
12422
+ await dist_exports.select({
12423
+ message: "Log in to which source?",
12424
+ options: sources.map((s) => ({ value: s.id, label: s.id }))
12425
+ })
12426
+ );
12427
+ printReport(await app.login(id));
12428
+ }
12429
+ var SESSION_LABEL = {
12430
+ valid: paint("green", "\u25CF session valid"),
12431
+ refreshable: paint("yellow", "\u25D0 renews automatically on next use"),
12432
+ expired: paint("red", "\u25CB expired, opens the browser on next use"),
12433
+ unknown: paint("dim", "\xB7 session managed by the cloud CLI")
12434
+ };
12435
+ var JSON_VERSION = 1;
12436
+ var printJson = (value) => console.log(JSON.stringify({ version: JSON_VERSION, ...value }, null, 2));
12437
+ async function listCommand(app, args = []) {
12438
+ const { positional, flags } = parseArgs(args);
12439
+ const sourceId = positional[0];
12440
+ if (flags.json) {
12441
+ const overview = await app.overview();
12442
+ if (sourceId && !overview.sources.some((s) => s.id === sourceId)) {
12443
+ throw new CloudhopError("SOURCE_NOT_FOUND", `Source "${sourceId}" not found.`);
12444
+ }
12445
+ printJson(
12446
+ sourceId ? {
12447
+ sources: overview.sources.filter((s) => s.id === sourceId),
12448
+ aliases: overview.aliases.filter((a2) => a2.source === sourceId)
12449
+ } : overview
12450
+ );
12451
+ return;
12452
+ }
12453
+ const config = await app.config();
12454
+ if (config.sources.length === 0) {
12455
+ console.log("Nothing here yet. Run `cloudhop setup` to get started.");
12456
+ return;
12457
+ }
12458
+ const sources = sourceId ? config.sources.filter((s) => s.id === sourceId) : config.sources;
12459
+ if (sources.length === 0) {
12460
+ throw new CloudhopError(
12461
+ "SOURCE_NOT_FOUND",
12462
+ `Source "${sourceId}" not found.`,
12463
+ `Sources: ${config.sources.map((s) => s.id).join(", ")}.`
12464
+ );
12465
+ }
12466
+ const aliases = await app.listAliases();
12467
+ const nameWidth = Math.max(5, ...aliases.map((a2) => a2.alias.name.length)) + 2;
10984
12468
  const regionWidth = Math.max(6, ...aliases.map((a2) => (a2.region ?? "-").length)) + 2;
10985
12469
  for (const [index, source] of sources.entries()) {
10986
12470
  const provider = app.provider(source.provider);
@@ -11001,8 +12485,9 @@ async function listCommand(app, sourceId) {
11001
12485
  );
11002
12486
  for (const { alias, target, region } of own) {
11003
12487
  const label = target ? target.label : paint("red", `${alias.target} (no longer available)`);
12488
+ const tag = alias.prod ? ` ${paint("red", "PROD")}` : "";
11004
12489
  console.log(
11005
- ` ${alias.name.padEnd(nameWidth)}${(region ?? "-").padEnd(regionWidth)}${label}`
12490
+ ` ${alias.name.padEnd(nameWidth)}${(region ?? "-").padEnd(regionWidth)}${label}${tag}`
11006
12491
  );
11007
12492
  }
11008
12493
  }
@@ -11088,26 +12573,116 @@ async function regionCommand(app, args) {
11088
12573
  })
11089
12574
  );
11090
12575
  const current = await app.resolveAlias(name);
11091
- const validate = regionValidator(app, current.source);
12576
+ const validate2 = regionValidator(app, current.source);
11092
12577
  const region = regionArg?.trim() ?? (answer(
11093
12578
  await dist_exports.text({
11094
12579
  message: `Region for ${name}`,
11095
12580
  placeholder: current.region,
11096
12581
  defaultValue: current.region,
11097
- validate: (v) => v?.trim() ? validate?.(v.trim()) : void 0
12582
+ validate: (v) => v?.trim() ? validate2?.(v.trim()) : void 0
11098
12583
  })
11099
12584
  ).trim() || current.region);
11100
- const problem = region ? validate?.(region) : "Say which region.";
12585
+ const problem = region ? validate2?.(region) : "Say which region.";
11101
12586
  if (!region || problem) throw new CloudhopError("INVALID_NAME", problem ?? "Invalid region.");
11102
12587
  await app.setAliasRegion(name, region);
11103
12588
  console.log(`${name}: ${current.region ?? "(none)"} \u2192 ${region}`);
11104
12589
  }
12590
+ var STATE_SHORT = {
12591
+ valid: paint("green", "\u25CF session valid"),
12592
+ refreshable: paint("yellow", "\u25D0 session expired, renewable"),
12593
+ expired: paint("red", "\u25CB session expired"),
12594
+ unknown: paint("dim", "\xB7 session")
12595
+ };
12596
+ var NEXT_TEXT = {
12597
+ ready: "ready to run",
12598
+ "silent-renewal": "the next command renews the session silently",
12599
+ "browser-sign-in": "the next command opens the browser to sign in",
12600
+ unknown: "the cloud CLI manages this session"
12601
+ };
12602
+ async function statusCommand(app, args) {
12603
+ const { positional, flags } = parseArgs(args);
12604
+ const name = positional[0];
12605
+ if (name) {
12606
+ const status = await app.aliasStatus(name);
12607
+ if (flags.json) return printJson({ alias: status });
12608
+ const account = status.attributes.accountId ? ` (${status.attributes.accountId})` : "";
12609
+ console.log(
12610
+ `${bold(paint("sky", status.name))} \u2192 ${status.label ?? status.target}${account}, ${status.region ?? "no region"}${status.prod ? ` ${paint("red", "PROD")}` : ""}`
12611
+ );
12612
+ console.log(` ${STATE_SHORT[status.session]}: ${NEXT_TEXT[status.next]}`);
12613
+ if (status.next === "browser-sign-in") {
12614
+ console.log(paint("dim", ` Sign in now with \`cloudhop login ${status.source}\`.`));
12615
+ }
12616
+ if (!status.available) console.log(paint("red", " This account/role is no longer available."));
12617
+ return;
12618
+ }
12619
+ const overview = await app.overview();
12620
+ if (flags.json) return printJson({ sources: overview.sources });
12621
+ if (overview.sources.length === 0) {
12622
+ console.log("Nothing here yet. Run `cloudhop setup` to get started.");
12623
+ return;
12624
+ }
12625
+ for (const source of overview.sources) {
12626
+ console.log(
12627
+ `${bold(paint("sky", source.id))} ${STATE_SHORT[source.session]}: ${NEXT_TEXT[source.next]}`
12628
+ );
12629
+ }
12630
+ }
12631
+ async function prodCommand(app, args) {
12632
+ const { positional, flags } = parseArgs(args);
12633
+ if (positional.length === 0) {
12634
+ const prod = (await app.overview()).aliases.filter((a2) => a2.prod).map((a2) => a2.name);
12635
+ console.log(
12636
+ prod.length ? `Production aliases: ${prod.join(", ")}` : "No alias is marked as production."
12637
+ );
12638
+ console.log(paint("dim", "Mark one with `cloudhop prod <alias>`, unmark with `--off`."));
12639
+ return;
12640
+ }
12641
+ const on = !flags.off;
12642
+ await app.setProduction(positional, on);
12643
+ for (const name of positional) {
12644
+ console.log(
12645
+ on ? `${paint("red", "PROD")} ${name}: cloudhop asks before any change.` : `${name} is no longer marked as production.`
12646
+ );
12647
+ }
12648
+ }
12649
+ async function renameCommand(app, args) {
12650
+ const [from, to] = args;
12651
+ if (!from || !to)
12652
+ throw new CloudhopError("INVALID_NAME", "Usage: cloudhop mv <alias> <new-name>");
12653
+ await app.renameAlias(from, to);
12654
+ console.log(`${from} \u2192 ${to}`);
12655
+ }
11105
12656
 
11106
12657
  // src/commands/run.ts
12658
+ async function guardProduction(app, alias, args) {
12659
+ const status = await app.aliasStatus(alias).catch(() => void 0);
12660
+ if (!status?.prod) return;
12661
+ const where = `${status.label ?? status.target}${status.region ? `, ${status.region}` : ""}`;
12662
+ if (!await app.needsProductionConfirmation(alias, args)) {
12663
+ if (process.stderr.isTTY) notify(paint("red", `${alias} is production (${where})`));
12664
+ return;
12665
+ }
12666
+ if (process.env.CLOUDHOP_CONFIRM_PROD === "1") return;
12667
+ const what = args ? `aws ${args.join(" ")}` : "this command";
12668
+ if (!interactive()) {
12669
+ throw new CloudhopError(
12670
+ "PROD_CONFIRMATION_REQUIRED",
12671
+ `${alias} is production and ${what} may change resources.`,
12672
+ "Run it in a terminal to confirm, or set CLOUDHOP_CONFIRM_PROD=1 once a person has approved it."
12673
+ );
12674
+ }
12675
+ dist_exports.log.warn(
12676
+ `${paint("red", bold("PRODUCTION"))} ${bold(alias)} \u2192 ${where}
12677
+ ${paint("dim", "This may change resources:")} ${what}`
12678
+ );
12679
+ const ok = answer(await dist_exports.confirm({ message: `Run it in ${alias}?`, initialValue: false }));
12680
+ if (!ok) throw new CloudhopError("PROD_CONFIRMATION_REQUIRED", "Cancelled: nothing ran.");
12681
+ }
11107
12682
  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);
12683
+ const cliArgs = args.length > 0 ? args : await app.identityArgs(alias);
12684
+ await guardProduction(app, alias, cliArgs);
12685
+ return app.runAsAlias(alias, cliArgs);
11111
12686
  }
11112
12687
  async function execCommand(app, args) {
11113
12688
  const separator = args.indexOf("--");
@@ -11121,6 +12696,7 @@ async function execCommand(app, args) {
11121
12696
  const regionIndex = flags.indexOf("--region");
11122
12697
  if (regionIndex !== -1 && flags[regionIndex + 1])
11123
12698
  overrides.region = flags[regionIndex + 1];
12699
+ await guardProduction(app, alias);
11124
12700
  const [bin, ...rest] = command;
11125
12701
  return app.execAsAlias(alias, bin, rest, overrides);
11126
12702
  }
@@ -11154,7 +12730,7 @@ async function offerInstall(app, entry) {
11154
12730
  else dist_exports.log.error(`Could not install automatically. See ${recipe.manualUrl}`);
11155
12731
  return ok;
11156
12732
  }
11157
- async function setupCommand(app) {
12733
+ async function setupCommand(app, assistants) {
11158
12734
  requireInteractive(
11159
12735
  "setup",
11160
12736
  "Run it in a regular terminal, or script it: `cloudhop provider enable aws`, then `cloudhop add aws sso --start-url <url> --region <r>`."
@@ -11181,11 +12757,24 @@ async function setupCommand(app) {
11181
12757
  }
11182
12758
  }
11183
12759
  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;
12760
+ if (addNow) await addCommand(app, { positional: [], flags: {} });
12761
+ else dist_exports.log.info("Run `cloudhop add` whenever you are ready.");
12762
+ const detected = assistants.detect();
12763
+ if (detected.length > 0) {
12764
+ const names = detected.map((id) => ASSISTANTS[id].name).join(", ");
12765
+ const teach = answer(
12766
+ await dist_exports.confirm({
12767
+ message: `Teach your AI assistant (${names}) to use cloudhop?`,
12768
+ initialValue: true
12769
+ })
12770
+ );
12771
+ if (teach) {
12772
+ await agentCommand(assistants, ["install"]);
12773
+ return;
12774
+ }
12775
+ dist_exports.log.info("You can do it later with `cloudhop agent install`.");
11187
12776
  }
11188
- await addCommand(app, { positional: [], flags: {} });
12777
+ if (!addNow) dist_exports.outro("All set.");
11189
12778
  }
11190
12779
  async function doctorCommand(app) {
11191
12780
  dist_exports.intro("cloudhop doctor");
@@ -11195,253 +12784,422 @@ async function doctorCommand(app) {
11195
12784
  return;
11196
12785
  }
11197
12786
  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
- ]
12787
+ for (const entry of entries) {
12788
+ if (entry.tool.installed && !entry.outdated) {
12789
+ dist_exports.log.success(`${entry.displayName}: ${entry.cli.displayName} ${entry.tool.version ?? ""}`);
12790
+ } else {
12791
+ healthy = await offerInstall(app, entry) && healthy;
11330
12792
  }
11331
12793
  }
12794
+ dist_exports.outro(
12795
+ healthy ? "Everything looks good." : "Some tools are missing; login and `cloudhop exec` still work."
12796
+ );
12797
+ }
12798
+
12799
+ // src/commands/shell.ts
12800
+ import { existsSync as existsSync3 } from "fs";
12801
+ import { appendFile, mkdir as mkdir3, readFile as readFile3 } from "fs/promises";
12802
+ import { homedir as homedir3 } from "os";
12803
+ import { dirname as dirname3, join as join5 } from "path";
12804
+ var SHELLS = ["zsh", "bash", "fish", "pwsh"];
12805
+ var ALIAS_COMMANDS = "use status console region mv prod rm exec";
12806
+ var SOURCE_COMMANDS = "alias login sync ls";
12807
+ var SCRIPTS = {
12808
+ zsh: `# cloudhop shell integration (zsh). Load it after compinit: eval "$(cloudhop shell zsh)"
12809
+ cloudhop() {
12810
+ if [ "$1" = "use" ]; then
12811
+ local out; out="$(command cloudhop use --emit zsh "\${@:2}")" || return $?
12812
+ eval "$out"
12813
+ else
12814
+ command cloudhop "$@"
12815
+ fi
12816
+ }
12817
+ _cloudhop_words() { command cloudhop __complete "$1" 2>/dev/null }
12818
+ _hop() { (( CURRENT == 2 )) && { local -a a; a=(\${(f)"$(_cloudhop_words aliases)"}); compadd -a a; } }
12819
+ _cloudhop() {
12820
+ local -a w
12821
+ if (( CURRENT == 2 )); then w=(\${(f)"$(_cloudhop_words commands)"}); compadd -a w; return; fi
12822
+ (( CURRENT == 3 )) || return
12823
+ case " ${ALIAS_COMMANDS} " in *" $words[2] "*) w=(\${(f)"$(_cloudhop_words aliases)"});; esac
12824
+ case " ${SOURCE_COMMANDS} " in *" $words[2] "*) w=(\${(f)"$(_cloudhop_words sources)"});; esac
12825
+ compadd -a w
12826
+ }
12827
+ (( $+functions[compdef] )) && { compdef _hop hop; compdef _cloudhop cloudhop; }
12828
+ # Add $(cloudhop_prompt) to your prompt to see the alias set with \`cloudhop use\`.
12829
+ cloudhop_prompt() { [ -n "$CLOUDHOP_ALIAS" ] && printf '\u2601 %s%s' "$CLOUDHOP_ALIAS" "\${CLOUDHOP_PROD:+ PROD}"; }
12830
+ `,
12831
+ bash: `# cloudhop shell integration (bash): eval "$(cloudhop shell bash)"
12832
+ cloudhop() {
12833
+ if [ "$1" = "use" ]; then
12834
+ local out; out="$(command cloudhop use --emit bash "\${@:2}")" || return $?
12835
+ eval "$out"
12836
+ else
12837
+ command cloudhop "$@"
12838
+ fi
12839
+ }
12840
+ _cloudhop_complete() {
12841
+ local cur=\${COMP_WORDS[COMP_CWORD]} cmd=\${COMP_WORDS[0]##*/} kind=""
12842
+ if [ "$cmd" = hop ]; then [ "$COMP_CWORD" -eq 1 ] && kind=aliases
12843
+ elif [ "$COMP_CWORD" -eq 1 ]; then kind=commands
12844
+ elif [ "$COMP_CWORD" -eq 2 ]; then
12845
+ case " ${ALIAS_COMMANDS} " in *" \${COMP_WORDS[1]} "*) kind=aliases;; esac
12846
+ case " ${SOURCE_COMMANDS} " in *" \${COMP_WORDS[1]} "*) kind=sources;; esac
12847
+ fi
12848
+ [ -n "$kind" ] && COMPREPLY=($(compgen -W "$(command cloudhop __complete $kind 2>/dev/null)" -- "$cur"))
12849
+ }
12850
+ complete -o default -F _cloudhop_complete hop cloudhop
12851
+ # Add $(cloudhop_prompt) to PS1 to see the alias set with \`cloudhop use\`.
12852
+ cloudhop_prompt() { [ -n "$CLOUDHOP_ALIAS" ] && printf '\u2601 %s%s' "$CLOUDHOP_ALIAS" "\${CLOUDHOP_PROD:+ PROD}"; }
12853
+ `,
12854
+ fish: `# cloudhop shell integration (fish): cloudhop shell fish | source
12855
+ function cloudhop
12856
+ if test "$argv[1]" = use
12857
+ set -l out (command cloudhop use --emit fish $argv[2..-1]); or return $status
12858
+ printf '%s\\n' $out | source
12859
+ else
12860
+ command cloudhop $argv
12861
+ end
12862
+ end
12863
+ complete -c hop -f -n 'test (count (commandline -opc)) -eq 1' -a '(command cloudhop __complete aliases 2>/dev/null)'
12864
+ complete -c cloudhop -f -n 'test (count (commandline -opc)) -eq 1' -a '(command cloudhop __complete commands 2>/dev/null)'
12865
+ complete -c cloudhop -f -n '__fish_seen_subcommand_from ${ALIAS_COMMANDS}; and test (count (commandline -opc)) -eq 2' -a '(command cloudhop __complete aliases 2>/dev/null)'
12866
+ complete -c cloudhop -f -n '__fish_seen_subcommand_from ${SOURCE_COMMANDS}; and test (count (commandline -opc)) -eq 2' -a '(command cloudhop __complete sources 2>/dev/null)'
12867
+ # Call cloudhop_prompt from fish_prompt to see the alias set with \`cloudhop use\`.
12868
+ function cloudhop_prompt
12869
+ test -n "$CLOUDHOP_ALIAS"; and printf '\u2601 %s%s' $CLOUDHOP_ALIAS (test -n "$CLOUDHOP_PROD"; and echo ' PROD')
12870
+ end
12871
+ `,
12872
+ pwsh: `# cloudhop shell integration (PowerShell): cloudhop shell pwsh | Out-String | Invoke-Expression
12873
+ $script:CloudhopExe = (Get-Command cloudhop -CommandType Application, ExternalScript | Select-Object -First 1).Source
12874
+ function cloudhop {
12875
+ if ($args.Count -gt 0 -and $args[0] -eq 'use') {
12876
+ $out = & $script:CloudhopExe use --emit pwsh @($args | Select-Object -Skip 1)
12877
+ if ($LASTEXITCODE -eq 0) { $out | Out-String | Invoke-Expression }
12878
+ } else { & $script:CloudhopExe @args }
12879
+ }
12880
+ Register-ArgumentCompleter -Native -CommandName hop, cloudhop -ScriptBlock {
12881
+ param($word, $ast)
12882
+ $n = $ast.CommandElements.Count - [int]($word -eq '')
12883
+ $kind = if ($ast.CommandElements[0].Value -match 'hop$' -and $ast.CommandElements[0].Value -notmatch 'cloudhop') { if ($n -le 1) { 'aliases' } }
12884
+ elseif ($n -le 1) { 'commands' }
12885
+ elseif ($n -eq 2 -and ' ${ALIAS_COMMANDS} ' -match " $($ast.CommandElements[1].Value) ") { 'aliases' }
12886
+ elseif ($n -eq 2 -and ' ${SOURCE_COMMANDS} ' -match " $($ast.CommandElements[1].Value) ") { 'sources' }
12887
+ if ($kind) { & $script:CloudhopExe __complete $kind | Where-Object { $_ -like "$word*" } }
12888
+ }
12889
+ function cloudhop_prompt { if ($env:CLOUDHOP_ALIAS) { "\u2601 $env:CLOUDHOP_ALIAS$(if ($env:CLOUDHOP_PROD) { ' PROD' })" } }
12890
+ `
11332
12891
  };
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 };
12892
+ var quote = (shell, value) => shell === "pwsh" ? `'${value.replace(/'/g, "''")}'` : `'${value.replace(/'/g, "'\\''")}'`;
12893
+ function shellExports(shell, env) {
12894
+ return Object.entries(env).map(([key, value]) => {
12895
+ if (value === void 0) {
12896
+ if (shell === "fish") return `set -e ${key}`;
12897
+ if (shell === "pwsh") return `Remove-Item Env:${key} -ErrorAction SilentlyContinue`;
12898
+ return `unset ${key}`;
12899
+ }
12900
+ if (shell === "fish") return `set -gx ${key} ${quote(shell, value)}`;
12901
+ if (shell === "pwsh") return `$env:${key} = ${quote(shell, value)}`;
12902
+ return `export ${key}=${quote(shell, value)}`;
12903
+ }).join("\n");
12904
+ }
12905
+ function detectShell() {
12906
+ if (process.platform === "win32") return "pwsh";
12907
+ const name = (process.env.SHELL ?? "").split("/").pop();
12908
+ return SHELLS.includes(name) ? name : "bash";
12909
+ }
12910
+ function parseShell(value) {
12911
+ if (!value) return detectShell();
12912
+ const shell = value === "powershell" ? "pwsh" : value;
12913
+ if (!SHELLS.includes(shell)) {
12914
+ throw new CloudhopError(
12915
+ "INVALID_NAME",
12916
+ `Unknown shell "${value}".`,
12917
+ `Use one of: ${SHELLS.join(", ")}.`
12918
+ );
11346
12919
  }
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;
12920
+ return shell;
12921
+ }
12922
+ var RC_FILES = {
12923
+ zsh: (home) => join5(home, ".zshrc"),
12924
+ bash: (home) => join5(home, ".bashrc"),
12925
+ fish: (home) => join5(home, ".config", "fish", "config.fish"),
12926
+ pwsh: (home) => process.platform === "win32" ? join5(home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1") : join5(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1")
12927
+ };
12928
+ var LOAD_LINE = {
12929
+ zsh: 'eval "$(cloudhop shell zsh)"',
12930
+ bash: 'eval "$(cloudhop shell bash)"',
12931
+ fish: "cloudhop shell fish | source",
12932
+ pwsh: "cloudhop shell pwsh | Out-String | Invoke-Expression"
12933
+ };
12934
+ async function shellCommand(args) {
12935
+ const { positional, flags } = parseArgs(args);
12936
+ if (positional[0] !== "install") {
12937
+ process.stdout.write(SCRIPTS[parseShell(positional[0])]);
12938
+ return;
11352
12939
  }
11353
- async hasCommand(command) {
11354
- if (!this.available.has(command)) {
11355
- this.available.set(command, (await this.runner.probe(command)).installed);
11356
- }
11357
- return this.available.get(command) ?? false;
12940
+ const shell = parseShell(positional[1]);
12941
+ const file = RC_FILES[shell](homedir3());
12942
+ const line = LOAD_LINE[shell];
12943
+ const current = existsSync3(file) ? await readFile3(file, "utf8") : "";
12944
+ if (current.includes(line)) {
12945
+ console.log(`Already set up in ${file.replace(homedir3(), "~")}.`);
12946
+ return;
11358
12947
  }
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");
12948
+ if (!flags.yes) {
12949
+ if (!interactive())
12950
+ throw new CloudhopError(
12951
+ "NOT_INTERACTIVE",
12952
+ `Add this line to ${file} yourself, or rerun with --yes:
12953
+ ${line}`
12954
+ );
12955
+ const ok = answer(
12956
+ await dist_exports.confirm({
12957
+ message: `Add cloudhop to ${file.replace(homedir3(), "~")}?`,
12958
+ initialValue: true
12959
+ })
12960
+ );
12961
+ if (!ok) return;
12962
+ }
12963
+ await mkdir3(dirname3(file), { recursive: true });
12964
+ const note2 = shell === "zsh" ? " # after compinit" : "";
12965
+ await appendFile(
12966
+ file,
12967
+ `${current && !current.endsWith("\n") ? "\n" : ""}
12968
+ # cloudhop: \`cloudhop use\`, tab completion and cloudhop_prompt${note2}
12969
+ ${line}
12970
+ `
12971
+ );
12972
+ console.log(
12973
+ `${paint("green", "\u2714")} Added to ${file.replace(homedir3(), "~")}. Open a new terminal to use it.`
12974
+ );
11369
12975
  }
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;
12976
+ async function useCommand(app, args) {
12977
+ const { positional, flags } = parseArgs(args);
12978
+ const shell = flags.emit ? parseShell(flags.emit) : void 0;
12979
+ if (!shell) {
12980
+ throw new CloudhopError(
12981
+ "NOT_INTERACTIVE",
12982
+ "`cloudhop use` needs the shell integration to change your current shell.",
12983
+ `Set it up once with \`cloudhop shell install\` (or add ${LOAD_LINE[detectShell()]} to your shell's startup file), then open a new terminal.`
12984
+ );
11374
12985
  }
11375
- dir;
11376
- get configPath() {
11377
- return join2(this.dir, "config.yaml");
12986
+ const clear = {
12987
+ AWS_ACCESS_KEY_ID: void 0,
12988
+ AWS_SECRET_ACCESS_KEY: void 0,
12989
+ AWS_SESSION_TOKEN: void 0
12990
+ };
12991
+ if (flags.off) {
12992
+ process.stdout.write(
12993
+ `${shellExports(shell, { ...clear, AWS_PROFILE: void 0, AWS_REGION: void 0, AWS_DEFAULT_REGION: void 0, CLOUDHOP_ALIAS: void 0, CLOUDHOP_PROD: void 0 })}
12994
+ `
12995
+ );
12996
+ notify("No alias in this shell anymore.");
12997
+ return;
11378
12998
  }
11379
- get targetsPath() {
11380
- return join2(this.dir, "targets.json");
12999
+ const alias = positional[0] ?? await pickAlias(app, "Use which alias in this shell?");
13000
+ const env = await app.envForAlias(alias, "cli");
13001
+ const status = await app.aliasStatus(alias);
13002
+ process.stdout.write(
13003
+ `${shellExports(shell, { ...clear, ...env, CLOUDHOP_ALIAS: alias, CLOUDHOP_PROD: status.prod ? "1" : void 0 })}
13004
+ `
13005
+ );
13006
+ notify(
13007
+ `This shell now uses ${alias} (${status.label ?? status.target}, ${status.region ?? "no region"}).`
13008
+ );
13009
+ if (status.prod) {
13010
+ notify(
13011
+ paint(
13012
+ "red",
13013
+ `${alias} is production. Plain \`aws\` commands are not protected here: use \`hop ${alias}\` to be asked before changes.`
13014
+ )
13015
+ );
11381
13016
  }
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
- };
13017
+ }
13018
+ async function completeCommand(app, kind) {
13019
+ if (kind === "commands") {
13020
+ console.log(
13021
+ [...RESERVED_NAMES, "each", "shell", "use"].filter((c3, i2, a2) => a2.indexOf(c3) === i2).sort().join("\n")
13022
+ );
13023
+ return;
11397
13024
  }
11398
- async save(config) {
13025
+ const config = await app.config();
13026
+ const words = kind === "sources" ? config.sources.map((s) => s.id) : config.aliases.map((a2) => a2.name);
13027
+ if (words.length) console.log(words.join("\n"));
13028
+ }
13029
+
13030
+ // src/commands/team.ts
13031
+ import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
13032
+ var import_yaml2 = __toESM(require_dist(), 1);
13033
+ var MAX_BYTES = 256 * 1024;
13034
+ var HEADER2 = `# cloudhop team setup: import it with \`cloudhop team import <this file or its https URL>\`.
13035
+ # No secrets: portal URLs, regions, account ids and role names only.
13036
+ `;
13037
+ function validate(app, raw) {
13038
+ const bad = (why) => new CloudhopError("INVALID_NAME", `Not a cloudhop team file: ${why}.`);
13039
+ if (!raw || typeof raw !== "object") throw bad("it is empty");
13040
+ const file = raw;
13041
+ if (file.version !== 1) throw bad('unknown "version" (expected 1)');
13042
+ const sources = file.sources ?? {};
13043
+ const aliases = file.aliases ?? {};
13044
+ for (const [id, source] of Object.entries(sources)) {
13045
+ assertValidName("source", id);
13046
+ if (!PROVIDER_IDS.includes(source?.provider))
13047
+ throw bad(`source "${id}" has an unknown provider`);
13048
+ const kind = app.provider(source.provider).sourceKinds.find((k) => k.kind === source.kind);
13049
+ if (!kind) throw bad(`source "${id}" has an unknown kind`);
13050
+ for (const field of kind.fields) {
13051
+ const value = source.settings?.[field.key];
13052
+ if (typeof value !== "string" || !value) throw bad(`source "${id}" is missing ${field.key}`);
13053
+ const problem = field.validate?.(value);
13054
+ if (problem) throw bad(`source "${id}": ${problem}`);
13055
+ }
13056
+ }
13057
+ for (const [name, alias] of Object.entries(aliases)) {
13058
+ assertValidName("alias", name);
13059
+ if (!alias || typeof alias.target !== "string" || !sources[alias.source]) {
13060
+ throw bad(`alias "${name}" must name a source of this file and a target`);
13061
+ }
13062
+ }
13063
+ return { version: 1, sources, aliases };
13064
+ }
13065
+ async function load(location) {
13066
+ if (/^http:\/\//i.test(location)) throw new CloudhopError("INVALID_NAME", "Use an https:// URL.");
13067
+ if (/^https:\/\//i.test(location)) {
13068
+ const response = await fetch(location, { signal: AbortSignal.timeout(1e4) });
13069
+ if (!response.ok)
13070
+ throw new CloudhopError("INVALID_NAME", `Could not download it (HTTP ${response.status}).`);
13071
+ const text4 = await response.text();
13072
+ if (text4.length > MAX_BYTES)
13073
+ throw new CloudhopError("INVALID_NAME", "That file is too big for a team setup.");
13074
+ return text4;
13075
+ }
13076
+ return readFile4(location, "utf8");
13077
+ }
13078
+ async function teamCommand(app, args) {
13079
+ const { positional, flags } = parseArgs(args);
13080
+ const [action, location] = positional;
13081
+ if (action === "export") {
13082
+ const config2 = await app.config();
13083
+ const wanted = flags.sources?.split(",").map((s) => s.trim());
13084
+ const sources = config2.sources.filter(
13085
+ (s) => s.kind !== "console-login" && (!wanted || wanted.includes(s.id))
13086
+ );
13087
+ const ids = new Set(sources.map((s) => s.id));
11399
13088
  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]))
13089
+ version: 1,
13090
+ sources: Object.fromEntries(sources.map(({ id, ...rest }) => [id, rest])),
13091
+ aliases: Object.fromEntries(
13092
+ config2.aliases.filter((a2) => ids.has(a2.source)).map(({ name, ...rest }) => [name, rest])
13093
+ )
11403
13094
  };
11404
- await this.write(this.configPath, HEADER + (0, import_yaml.stringify)(file));
11405
- }
11406
- async loadTargets(sourceId) {
11407
- return (await this.allTargets())[sourceId] ?? [];
11408
- }
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));
13095
+ const text4 = HEADER2 + (0, import_yaml2.stringify)(file);
13096
+ if (flags.out) {
13097
+ await writeFile3(flags.out, text4);
13098
+ console.error(
13099
+ `${paint("green", "\u2714")} Wrote ${sources.length} source(s) and ${Object.keys(file.aliases).length} alias(es) to ${flags.out}.`
13100
+ );
13101
+ } else {
13102
+ process.stdout.write(text4);
13103
+ }
13104
+ return;
11413
13105
  }
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));
13106
+ if (action !== "import" || !location) {
13107
+ throw new CloudhopError(
13108
+ "INVALID_NAME",
13109
+ "Usage: cloudhop team export [--out file] | cloudhop team import <file|https URL>"
13110
+ );
11418
13111
  }
11419
- async reset() {
11420
- await rm(this.dir, { recursive: true, force: true });
13112
+ const team = validate(app, (0, import_yaml2.parse)(await load(location)));
13113
+ const config = await app.config();
13114
+ const existingSources = new Map(config.sources.map((s) => [s.id, s]));
13115
+ const sameUrl = (a2, b3) => a2.startUrl !== void 0 && a2.startUrl === b3.startUrl;
13116
+ const newSources = [];
13117
+ const sourceMap = /* @__PURE__ */ new Map();
13118
+ for (const [id, source] of Object.entries(team.sources)) {
13119
+ const match = config.sources.find(
13120
+ (s) => s.provider === source.provider && sameUrl(s.settings, source.settings)
13121
+ );
13122
+ if (match) {
13123
+ sourceMap.set(id, match.id);
13124
+ continue;
13125
+ }
13126
+ if (existingSources.has(id))
13127
+ throw new CloudhopError(
13128
+ "SOURCE_EXISTS",
13129
+ `You already have a different source named "${id}".`,
13130
+ "Rename yours with `cloudhop rm` and add it again, or edit the team file."
13131
+ );
13132
+ newSources.push({ id, ...source });
13133
+ sourceMap.set(id, id);
13134
+ }
13135
+ const takenAliases = new Set(config.aliases.map((a2) => a2.name));
13136
+ const newAliases = [];
13137
+ const skipped = [];
13138
+ for (const [name, alias] of Object.entries(team.aliases)) {
13139
+ if (takenAliases.has(name)) skipped.push(name);
13140
+ else newAliases.push({ name, ...alias, source: sourceMap.get(alias.source) });
13141
+ }
13142
+ if (newSources.length === 0 && newAliases.length === 0) {
13143
+ console.log("Nothing to import: you already have everything in this file.");
13144
+ return;
11421
13145
  }
11422
- async allTargets() {
11423
- const raw = await this.read(this.targetsPath);
11424
- return raw ? JSON.parse(raw) : {};
13146
+ console.log(bold("This team setup will add:"));
13147
+ for (const s of newSources)
13148
+ console.log(
13149
+ ` source ${bold(s.id)} ${paint("sky", s.settings.startUrl ?? s.kind)} ${s.settings.region ?? ""}`
13150
+ );
13151
+ for (const a2 of newAliases)
13152
+ console.log(` alias ${a2.name} ${a2.target}${a2.prod ? ` ${paint("red", "PROD")}` : ""}`);
13153
+ if (skipped.length)
13154
+ console.log(paint("dim", ` (skipping aliases you already have: ${skipped.join(", ")})`));
13155
+ if (newSources.length)
13156
+ console.log(
13157
+ paint("yellow", "Check that these portal URLs are your organization's before signing in.")
13158
+ );
13159
+ if (!flags.yes) {
13160
+ if (!interactive())
13161
+ throw new CloudhopError(
13162
+ "NOT_INTERACTIVE",
13163
+ "Review the list above and rerun with --yes to import it."
13164
+ );
13165
+ if (!answer(
13166
+ await dist_exports.confirm({
13167
+ message: "Import it? Each new portal opens your browser to sign in.",
13168
+ initialValue: true
13169
+ })
13170
+ ))
13171
+ return;
11425
13172
  }
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;
11432
- }
13173
+ const providers = new Set((await app.config()).providers);
13174
+ for (const s of newSources) if (!providers.has(s.provider)) await app.enableProvider(s.provider);
13175
+ for (const source of newSources) {
13176
+ const report = await app.connectSource(source);
13177
+ console.log(
13178
+ `${paint("green", "\u2714")} ${source.id}: signed in, ${report.targets.length} account/role(s).`
13179
+ );
11433
13180
  }
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);
13181
+ const usable = [];
13182
+ const missing = [];
13183
+ for (const alias of newAliases) {
13184
+ const targets = await app.listTargets(alias.source);
13185
+ if (targets.some((t2) => t2.id === alias.target)) usable.push(alias);
13186
+ else missing.push(alias.name);
11440
13187
  }
11441
- };
13188
+ if (usable.length) await app.addAliases(usable);
13189
+ console.log(
13190
+ `${paint("green", "\u2714")} Created ${usable.length} alias(es)${usable.length ? `: ${usable.map((a2) => a2.name).join(", ")}` : ""}.`
13191
+ );
13192
+ if (missing.length)
13193
+ console.log(
13194
+ paint(
13195
+ "yellow",
13196
+ `Not created, you don't have access to their account/role: ${missing.join(", ")}.`
13197
+ )
13198
+ );
13199
+ }
11442
13200
 
11443
13201
  // ../ui/src/api.ts
11444
- var text2 = (body, key) => {
13202
+ var text3 = (body, key) => {
11445
13203
  const value = body[key];
11446
13204
  return typeof value === "string" ? value.trim() : "";
11447
13205
  };
@@ -11460,10 +13218,26 @@ var reportView = (report) => ({
11460
13218
  removed: report.removed.length,
11461
13219
  orphanedAliases: report.orphanedAliases
11462
13220
  });
11463
- function createApi(app) {
13221
+ var SCOPES = /* @__PURE__ */ new Set(["user", "project"]);
13222
+ var scopeOf = (value) => {
13223
+ if (SCOPES.has(value)) return value;
13224
+ throw badRequest('Scope must be "user" or "project".');
13225
+ };
13226
+ var assistantIds = (value) => {
13227
+ const ids = Array.isArray(value) ? value : [];
13228
+ if (ids.length === 0 || !ids.every((id) => ASSISTANT_IDS.includes(id))) {
13229
+ throw badRequest("Choose at least one assistant.");
13230
+ }
13231
+ return ids;
13232
+ };
13233
+ function createApi(app, assistants) {
13234
+ const requireAssistants = () => {
13235
+ if (!assistants) throw badRequest("AI assistant setup is not available here.");
13236
+ return assistants;
13237
+ };
11464
13238
  const regionCheck = (source, region) => {
11465
- const validate = app.provider(source.provider).sourceKinds.find((k) => k.kind === source.kind)?.fields.find((f2) => f2.key === "region")?.validate;
11466
- const problem = region ? validate?.(region) : "Region is required.";
13239
+ const validate2 = app.provider(source.provider).sourceKinds.find((k) => k.kind === source.kind)?.fields.find((f2) => f2.key === "region")?.validate;
13240
+ const problem = region ? validate2?.(region) : "Region is required.";
11467
13241
  if (problem) throw badRequest(problem);
11468
13242
  };
11469
13243
  const findSource = async (id) => {
@@ -11514,10 +13288,31 @@ function createApi(app) {
11514
13288
  target: alias.target,
11515
13289
  label: target?.label,
11516
13290
  missing: !target,
11517
- region
13291
+ region,
13292
+ prod: alias.prod === true
11518
13293
  }))
11519
13294
  };
11520
13295
  },
13296
+ /** Which AI assistants know cloudhop, for both scopes. Paths are shown, never file contents. */
13297
+ "GET /api/assistants": async () => {
13298
+ const installer = requireAssistants();
13299
+ return {
13300
+ project: process.cwd(),
13301
+ user: await installer.status("user"),
13302
+ projectScope: await installer.status("project")
13303
+ };
13304
+ },
13305
+ "POST /api/assistants": async (_2, body) => {
13306
+ const tools = assistantIds(body.tools);
13307
+ const scope = scopeOf(body.scope);
13308
+ return {
13309
+ results: await requireAssistants().install(tools, scope, { mcp: body.mcp !== false })
13310
+ };
13311
+ },
13312
+ "POST /api/assistants/remove": async (_2, body) => ({
13313
+ results: await requireAssistants().remove(assistantIds(body.tools), scopeOf(body.scope))
13314
+ }),
13315
+ "POST /api/assistants/update": async (_2, body) => requireAssistants().update({ force: body.force === true }),
11521
13316
  "GET /api/doctor": async () => (await app.doctor()).map((entry) => ({
11522
13317
  provider: entry.provider,
11523
13318
  cli: entry.cli.displayName,
@@ -11536,19 +13331,19 @@ function createApi(app) {
11536
13331
  },
11537
13332
  /** Adds a source and logs in right away, like `cloudhop add`; a failed login adds nothing. */
11538
13333
  "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"));
13334
+ const provider = app.provider(text3(body, "provider"));
13335
+ const kind = provider.sourceKinds.find((k) => k.kind === text3(body, "kind"));
11541
13336
  if (!kind) throw badRequest("Choose what you want to add.");
11542
13337
  const input = body.settings ?? {};
11543
13338
  const settings2 = {};
11544
13339
  for (const field of kind.fields) {
11545
- const value = text2(input, field.key) || field.defaultValue || "";
13340
+ const value = text3(input, field.key) || field.defaultValue || "";
11546
13341
  if (!value) throw badRequest(`${field.label} is required.`);
11547
13342
  const problem = field.validate?.(value);
11548
13343
  if (problem) throw badRequest(`${field.label}: ${problem}`);
11549
13344
  settings2[field.key] = value;
11550
13345
  }
11551
- const id = text2(body, "id");
13346
+ const id = text3(body, "id");
11552
13347
  return reportView(
11553
13348
  await app.connectSource({ id, provider: provider.id, kind: kind.kind, settings: settings2 })
11554
13349
  );
@@ -11576,25 +13371,41 @@ function createApi(app) {
11576
13371
  if (items.length === 0) throw badRequest("Pick at least one account/role.");
11577
13372
  const aliases = [];
11578
13373
  for (const item of items) {
11579
- const source = await findSource(text2(item, "source"));
11580
- const region = text2(item, "region");
13374
+ const source = await findSource(text3(item, "source"));
13375
+ const region = text3(item, "region");
11581
13376
  if (region) regionCheck(source, region);
11582
13377
  aliases.push({
11583
- name: text2(item, "name"),
13378
+ name: text3(item, "name"),
11584
13379
  source: source.id,
11585
- target: text2(item, "target"),
13380
+ target: text3(item, "target"),
11586
13381
  ...region && region !== source.settings.region ? { region } : {}
11587
13382
  });
11588
13383
  }
11589
13384
  await app.addAliases(aliases);
11590
13385
  return { created: aliases.map((a2) => a2.name) };
11591
13386
  },
13387
+ /** Changes an alias: any of `region`, `prod` (production mark) and `name` (rename, applied last). */
11592
13388
  "PATCH /api/aliases/:name": async ([name], body) => {
11593
- const resolved = await app.resolveAlias(name);
11594
- const region = text2(body, "region");
11595
- regionCheck(resolved.source, region);
11596
- await app.setAliasRegion(name, region);
11597
- return { name, region };
13389
+ let current = name;
13390
+ if ("region" in body) {
13391
+ const resolved = await app.resolveAlias(current);
13392
+ const region = text3(body, "region");
13393
+ regionCheck(resolved.source, region);
13394
+ await app.setAliasRegion(current, region);
13395
+ }
13396
+ if (typeof body.prod === "boolean") await app.setProduction([current], body.prod);
13397
+ if ("name" in body) {
13398
+ const to = text3(body, "name");
13399
+ await app.renameAlias(current, to);
13400
+ current = to;
13401
+ }
13402
+ return { name: current };
13403
+ },
13404
+ /** Opens the web console in the user's browser; the sign-in URL never reaches the page. */
13405
+ "POST /api/aliases/:name/console": async ([name], body) => {
13406
+ const service = text3(body, "service");
13407
+ const via = await app.openConsole(name, service ? { service } : {});
13408
+ return { via };
11598
13409
  },
11599
13410
  "DELETE /api/aliases/:name": async ([name]) => {
11600
13411
  await app.removeAlias(name);
@@ -11631,7 +13442,7 @@ import { randomBytes, timingSafeEqual } from "crypto";
11631
13442
  import { createServer } from "http";
11632
13443
 
11633
13444
  // ../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";
13445
+ 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// ---------- pages ----------\n\nconst PAGES = {\n aliases: 'Aliases',\n sources: 'Sources',\n assistants: 'AI assistants',\n tools: 'Tools',\n};\nlet route = 'aliases';\nconst aliasFilter = { text: '', source: '', prodOnly: false };\n\n/** `#/aliases?source=acme` \u2192 route \"aliases\", filtered by that source. */\nfunction readRoute() {\n const match = /^#\\/([a-z]+)(?:\\?(.*))?$/.exec(location.hash);\n route = match && PAGES[match[1]] ? match[1] : 'aliases';\n const query = new URLSearchParams(match?.[2] ?? '');\n if (query.has('source')) aliasFilter.source = query.get('source');\n}\n\nfunction go(page, query) {\n location.hash = `#/${page}${query ? `?${new URLSearchParams(query)}` : ''}`;\n}\n\n// A fresh link opened in this same tab carries a new token: reload to pick it up.\n// Any other fragment is a page change.\naddEventListener('hashchange', () => {\n if (new URLSearchParams(location.hash.slice(1)).has('token')) {\n location.reload();\n return;\n }\n readRoute();\n if (state) render();\n});\n\nconst $main = document.getElementById('main');\nconst $dialog = document.getElementById('dialog');\nconst $toasts = document.getElementById('toasts');\nconst $addSource = document.getElementById('add-source');\nconst $nav = document.getElementById('nav');\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))?|aliases\\/[^/]+\\/console)$/;\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 $nav.hidden = enabled.length === 0;\n if (enabled.length === 0) return renderChooseClouds();\n for (const link of $nav.querySelectorAll('a')) {\n if (link.dataset.page === route) link.setAttribute('aria-current', 'page');\n else link.removeAttribute('aria-current');\n }\n document.title = `${PAGES[route]} \xB7 cloudhop`;\n const pages = {\n aliases: aliasesPage,\n sources: sourcesPage,\n assistants: assistantsPage,\n tools: toolsPage,\n };\n $main.replaceChildren(...pages[route]());\n if (route === 'assistants') loadAssistants();\n if (route === 'tools') loadTools();\n}\n\nfunction pageHead(title, subtitle, ...actions) {\n return h(\n 'div',\n { class: 'page-head' },\n h('div', {}, h('h1', {}, title), subtitle ? h('p', {}, subtitle) : null),\n h('div', { class: 'actions' }, actions),\n );\n}\n\nfunction firstSourceHero() {\n return 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\n// ---------- Aliases page ----------\n\nfunction aliasesPage() {\n if (state.sources.length === 0) return [firstSourceHero()];\n if (aliasFilter.source && !state.sources.some((s) => s.id === aliasFilter.source))\n aliasFilter.source = '';\n\n const addAliases = () => {\n const source =\n state.sources.find((s) => s.id === aliasFilter.source) ??\n (state.sources.length === 1 ? state.sources[0] : undefined);\n if (source) openAliasPicker(source);\n else openSourceChooser();\n };\n const head = pageHead(\n 'Aliases',\n 'Each alias is one account, role and region. Run it with hop <alias>.',\n h('button', { onclick: addAliases }, '+ Aliases'),\n );\n if (state.aliases.length === 0) {\n return [\n head,\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'No aliases yet'),\n h('p', {}, 'Pick the accounts and roles you use and give each one a short name.'),\n h('button', { class: 'primary', onclick: addAliases }, 'Pick accounts'),\n ),\n ];\n }\n\n const rows = h('tbody', { id: 'alias-rows' });\n const count = h('span', { class: 'count' });\n const fill = () => {\n const words = aliasFilter.text.toLowerCase().split(/\\s+/).filter(Boolean);\n const visible = state.aliases.filter((a) => {\n if (aliasFilter.source && a.source !== aliasFilter.source) return false;\n if (aliasFilter.prodOnly && !a.prod) return false;\n const text = `${a.name} ${a.label ?? a.target} ${a.region ?? ''} ${a.source}`.toLowerCase();\n return words.every((w) => text.includes(w));\n });\n rows.replaceChildren(\n ...(visible.length\n ? visible.map(aliasRow)\n : [\n h(\n 'tr',\n {},\n h('td', { colspan: '5', class: 'empty' }, 'No alias matches these filters.'),\n ),\n ]),\n );\n count.textContent = `${visible.length} of ${state.aliases.length}`;\n };\n\n const search = h('input', {\n type: 'search',\n placeholder: 'Filter: name, account, role or region',\n value: aliasFilter.text,\n 'aria-label': 'Filter aliases',\n oninput: (e) => {\n aliasFilter.text = e.currentTarget.value;\n fill();\n },\n });\n const sourceSelect = h(\n 'select',\n {\n 'aria-label': 'Source',\n onchange: (e) => {\n aliasFilter.source = e.currentTarget.value;\n fill();\n },\n },\n h('option', { value: '' }, 'All sources'),\n state.sources.map((s) =>\n h('option', { value: s.id, selected: s.id === aliasFilter.source }, s.id),\n ),\n );\n const prodOnly = h(\n 'label',\n { class: 'prod-toggle' },\n h('input', {\n type: 'checkbox',\n checked: aliasFilter.prodOnly,\n onchange: (e) => {\n aliasFilter.prodOnly = e.currentTarget.checked;\n fill();\n },\n }),\n 'Production only',\n );\n\n const table = h(\n 'table',\n { class: 'aliases' },\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Alias'),\n h('th', {}, 'Source'),\n h('th', {}, 'Region'),\n h('th', {}, 'Account / role'),\n h('th'),\n ),\n ),\n rows,\n );\n fill();\n return [\n head,\n h(\n 'div',\n { class: 'toolbar' },\n search,\n sourceSelect,\n prodOnly,\n h('div', { class: 'spacer' }),\n count,\n ),\n h('section', { class: 'card' }, table),\n ];\n}\n\n/** When several sources exist, ask which one to pick accounts from. */\nfunction openSourceChooser() {\n showDialog(\n 'Add aliases from',\n h(\n 'div',\n { class: 'choices' },\n state.sources.map((source) =>\n h(\n 'button',\n { class: 'choice', onclick: () => openAliasPicker(source) },\n h(\n 'span',\n {},\n h('strong', {}, source.id),\n h('small', {}, source.settings.startUrl ?? source.kindLabel),\n ),\n ),\n ),\n ),\n );\n}\n\n// ---------- Sources page ----------\n\nfunction sourcesPage() {\n const head = pageHead('Sources', 'The SSO portals and personal accounts you sign in to.');\n if (state.sources.length === 0) return [head, firstSourceHero()];\n return [head, ...state.sources.map(sourceCard)];\n}\n\n// ---------- AI assistants page ----------\n\nfunction assistantsPage() {\n return [assistantsCard()];\n}\n\n// ---------- Tools page ----------\n\nfunction snippet(command) {\n return h(\n 'div',\n { class: 'snippet' },\n h('code', {}, command),\n h(\n 'button',\n {\n class: 'link',\n title: 'Copy',\n 'aria-label': `Copy ${command}`,\n onclick: () =>\n navigator.clipboard\n ?.writeText(command)\n .then(() => toast('Copied.'))\n .catch(() =>\n toast('Copy it by hand: the browser blocked the clipboard.', { error: true }),\n ),\n },\n 'Copy',\n ),\n );\n}\n\nfunction toolCard(title, text, ...commands) {\n return h(\n 'section',\n { class: 'card' },\n h('div', { class: 'card-head' }, h('h2', {}, title)),\n h(\n 'div',\n { class: 'card-body padded' },\n h('p', { class: 'card-help' }, text),\n commands.map(snippet),\n ),\n );\n}\n\nfunction toolsPage() {\n const sample = state.aliases[0]?.name ?? 'acme-dev';\n return [\n pageHead('Tools', 'More ways to use cloudhop from your terminal.'),\n h(\n 'section',\n { class: 'card', id: 'tools-cli' },\n h('div', { class: 'card-head' }, h('h2', {}, 'Cloud CLIs')),\n h('div', { class: 'card-body padded' }, h('p', { class: 'card-help' }, 'Checking\u2026')),\n ),\n toolCard(\n 'Shell integration',\n 'Pick aliases from a list, tab-complete them, and use one in your shell so plain aws and Terraform work without hop.',\n 'cloudhop shell install',\n `cloudhop use ${sample}`,\n 'hop',\n ),\n toolCard(\n 'Run in many accounts',\n 'Run one command in many aliases at once: names, lists, wildcards or /regex/. It asks before running.',\n `cloudhop each '${sample.split('-')[0]}-*' -- s3 ls`,\n ),\n toolCard(\n 'Share with your team',\n 'Export your portals and aliases (no secrets) and let teammates import them in one command.',\n 'cloudhop team export --out team.yaml',\n 'cloudhop team import team.yaml',\n ),\n ];\n}\n\nasync function loadTools() {\n const card = document.querySelector('#tools-cli .card-body');\n if (!card) return;\n try {\n const entries = await api('GET', '/api/doctor');\n card.replaceChildren(\n ...entries.map((e) =>\n h(\n 'div',\n { class: 'tool-row' },\n h('span', { class: `badge ${e.installed && !e.outdated ? 'valid' : 'expired'}` }, e.cli),\n h(\n 'span',\n {},\n !e.installed\n ? 'Not installed.'\n : e.outdated\n ? `${e.version} is older than ${e.minVersion}.`\n : `Version ${e.version}.`,\n ),\n e.installed && !e.outdated ? null : snippet('cloudhop doctor'),\n ),\n ),\n );\n } catch (error) {\n card.replaceChildren(h('p', { class: 'card-help' }, error.message));\n }\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 prodCount = aliases.filter((a) => a.prod).length;\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 'div',\n { class: 'source-summary' },\n h(\n 'span',\n {},\n `${aliases.length} alias(es)`,\n prodCount ? h('span', { class: 'prod-tag' }, `${prodCount} PROD`) : null,\n ),\n h(\n 'button',\n { class: 'link', onclick: () => go('aliases', { source: source.id }) },\n 'View aliases \u2192',\n ),\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 const patch = (body, message) =>\n api('PATCH', `/api/aliases/${encodeURIComponent(alias.name)}`, body)\n .then(() => message && toast(message))\n .catch(fail)\n .then(refresh);\n const iconButton = (label, title, onclick, extra = {}) =>\n h('button', { class: 'link', title, 'aria-label': title, onclick, ...extra }, label);\n\n const actions = h(\n 'div',\n { class: 'row-actions' },\n h(\n 'button',\n {\n title: `Open the AWS console as ${alias.name}, already signed in`,\n disabled: alias.missing,\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const result = await api(\n 'POST',\n `/api/aliases/${encodeURIComponent(alias.name)}/console`,\n {},\n ).catch(fail);\n if (result) {\n toast(\n result.via === 'signed-in'\n ? `Opened the console for ${alias.name}.`\n : result.via === 'portal'\n ? `Opened ${alias.name} through the SSO portal.`\n : `Opened the console sign-in page for ${alias.name}.`,\n );\n }\n }),\n },\n 'Console',\n ),\n h(\n 'label',\n { class: 'prod-toggle', title: 'Production: hop and AI assistants ask before any change' },\n h('input', {\n type: 'checkbox',\n checked: alias.prod,\n 'aria-label': `${alias.name} is production`,\n onchange: (e) =>\n patch(\n { prod: e.currentTarget.checked },\n e.currentTarget.checked\n ? `${alias.name} is now production.`\n : `${alias.name} is no longer production.`,\n ),\n }),\n 'Prod',\n ),\n iconButton('\u270E', `Rename ${alias.name}`, () => {\n const to = prompt(`New name for \"${alias.name}\"`, alias.name)?.trim();\n if (to && to !== alias.name) patch({ name: to }, `${alias.name} is now ${to}.`);\n }),\n iconButton(\n '\u2715',\n `Remove ${alias.name}`,\n (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 { class: 'link danger-hover' },\n ),\n );\n\n return h(\n 'tr',\n { class: alias.prod ? 'prod' : undefined },\n h(\n 'td',\n { class: 'alias', title: `hop ${alias.name} <command>` },\n alias.name,\n alias.prod ? h('span', { class: 'prod-tag' }, 'PROD') : null,\n ),\n h('td', { class: 'muted' }, alias.source),\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('td', { style: 'text-align:right' }, actions),\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 readRoute();\n refresh();\n}\n";
11635
13446
 
11636
13447
  // ../ui/src/assets/index.html
11637
13448
  var assets_default = `<!doctype html>
@@ -11762,9 +13573,29 @@ var assets_default = `<!doctype html>
11762
13573
  }
11763
13574
  /* Fixed columns so every source's table lines up with the others. */
11764
13575
  table { width: 100%; border-collapse: collapse; table-layout: fixed; }
11765
- th:nth-child(1) { width: 26%; }
11766
- th:nth-child(2) { width: 190px; }
11767
- th:nth-child(4) { width: 56px; }
13576
+ table.aliases th:nth-child(1) { width: 22%; }
13577
+ table.aliases th:nth-child(2) { width: 15%; }
13578
+ table.aliases th:nth-child(3) { width: 180px; }
13579
+ table.aliases th:nth-child(5) { width: 264px; }
13580
+ td.muted { color: var(--muted); font-size: 14px; }
13581
+ .nav { display: flex; gap: 4px; margin-left: 12px; }
13582
+ .nav a { color: var(--muted); text-decoration: none; font-size: 14px; font-weight: 500; padding: 6px 12px; border-radius: 8px; }
13583
+ .nav a:hover { color: var(--text); background: var(--surface-2); }
13584
+ .nav a[aria-current="page"] { color: var(--accent-strong); background: var(--accent-soft); font-weight: 600; }
13585
+ .page-head { display: flex; align-items: flex-end; gap: 16px; margin: 4px 0 16px; flex-wrap: wrap; }
13586
+ .page-head > div:first-child { flex: 1; min-width: 220px; }
13587
+ .page-head h1 { font-size: 22px; margin: 0; letter-spacing: -0.01em; }
13588
+ .page-head p { margin: 4px 0 0; color: var(--muted); font-size: 14px; }
13589
+ .toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
13590
+ .toolbar input[type="search"] { flex: 1 1 260px; }
13591
+ .toolbar .count { color: var(--muted); font-size: 13px; }
13592
+ .source-summary { display: flex; align-items: center; gap: 12px; padding: 12px 16px; color: var(--muted); }
13593
+ .card-body.padded { padding: 4px 16px 14px; }
13594
+ .snippet { display: flex; align-items: center; gap: 8px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 6px 6px 6px 12px; margin-bottom: 8px; }
13595
+ .snippet code { flex: 1; overflow-x: auto; white-space: nowrap; }
13596
+ .tool-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 6px 0; }
13597
+ .tool-row .snippet { margin: 0; flex: 1 1 220px; }
13598
+ button.choice { width: 100%; text-align: left; background: var(--surface); }
11768
13599
  td { overflow-wrap: anywhere; }
11769
13600
  th {
11770
13601
  text-align: left;
@@ -11877,14 +13708,40 @@ var assets_default = `<!doctype html>
11877
13708
  margin-bottom: 18px;
11878
13709
  }
11879
13710
  .notice.error { border-left-color: var(--bad); }
13711
+ .row-actions { display: flex; gap: 6px; align-items: center; justify-content: flex-end; white-space: nowrap; }
13712
+ .prod-toggle { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: var(--muted); cursor: pointer; }
13713
+ .prod-tag { display: inline-block; white-space: nowrap; margin-left: 8px; font-size: 11px; font-weight: 700; letter-spacing: 0.04em; color: var(--bad); border: 1px solid currentColor; border-radius: 4px; padding: 0 4px; vertical-align: 1px; }
13714
+ tr.prod td:first-child { box-shadow: inset 3px 0 0 var(--bad); }
13715
+ button.link.danger-hover:hover { color: var(--bad); }
13716
+ .seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
13717
+ .seg button { border: 0; border-radius: 0; }
13718
+ .seg button + button { border-left: 1px solid var(--border); }
13719
+ .seg button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-strong); font-weight: 600; }
13720
+ .card-help { color: var(--muted); font-size: 14px; margin: 10px 16px 6px; }
13721
+ .card-body.padded .card-help { margin: 8px 0 10px; }
13722
+ .muted-inline { color: var(--muted); font-weight: 400; font-size: 12px; }
13723
+ table.assistants th:nth-child(2) { width: 170px; }
13724
+ table.assistants th:nth-child(3) { width: 140px; }
13725
+ table.assistants th:nth-child(4) { width: 230px; }
11880
13726
  @media (max-width: 640px) {
11881
13727
  .tagline { display: none; }
11882
- th:nth-child(3), td:nth-child(3) { display: none; }
13728
+ .nav { margin-left: 4px; overflow-x: auto; }
13729
+ .nav a { padding: 6px 8px; }
13730
+ table.aliases th:nth-child(2), table.aliases td:nth-child(2),
13731
+ table.aliases th:nth-child(4), table.aliases td:nth-child(4),
13732
+ table.assistants th:nth-child(3), table.assistants td:nth-child(3) { display: none; }
11883
13733
  td, th { padding: 8px 10px; }
11884
13734
  td.alias { white-space: normal; overflow-wrap: anywhere; }
11885
13735
  .region input { width: 96px; }
11886
- th:nth-child(1) { width: auto; }
11887
- th:nth-child(2) { width: 124px; }
13736
+ table.aliases th:nth-child(1) { width: auto; }
13737
+ table.aliases th:nth-child(3) { width: 124px; }
13738
+ table.aliases th:nth-child(5) { width: 132px; }
13739
+ .bar { flex-wrap: wrap; }
13740
+ .row-actions { flex-wrap: wrap; }
13741
+ table.assistants th:nth-child(2) { width: 112px; }
13742
+ table.assistants th:nth-child(4) { width: 96px; }
13743
+ table.assistants .actions { flex-direction: column; align-items: flex-end; }
13744
+ .card-help { margin: 10px 10px 6px; }
11888
13745
  }
11889
13746
  </style>
11890
13747
  </head>
@@ -11892,7 +13749,12 @@ var assets_default = `<!doctype html>
11892
13749
  <header>
11893
13750
  <div class="bar">
11894
13751
  <div class="logo"><img src="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22 role=%22img%22 aria-label=%22cloudhop%22%3E%3Cdefs%3E%3ClinearGradient id=%22ch-bg%22 x1=%220%22 y1=%220%22 x2=%221%22 y2=%221%22%3E%3Cstop offset=%220%22 stop-color=%22%233BB8F5%22/%3E%3Cstop offset=%221%22 stop-color=%22%231450A8%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width=%2264%22 height=%2264%22 rx=%2215%22 fill=%22url(%23ch-bg)%22/%3E%3Cpath d=%22M8.5 47.5a5.5 5.5 0 0 1 5-5.5 6.8 6.8 0 0 1 13 1.6 4.4 4.4 0 0 1-.9 8.7H13.4a4.9 4.9 0 0 1-4.9-4.8z%22 fill=%22%23fff%22 fill-opacity=%22.55%22/%3E%3Cpath d=%22M31 44.6a7.6 7.6 0 0 1 7-7.6 9.6 9.6 0 0 1 18.2 2.6 6.3 6.3 0 0 1-1.3 12.5H38.3A7.4 7.4 0 0 1 31 44.6z%22 fill=%22%23fff%22/%3E%3Cpath d=%22M18 37.5C19 21 33 13.5 40.6 23%22 fill=%22none%22 stroke=%22%23fff%22 stroke-width=%223.6%22 stroke-linecap=%22round%22 stroke-dasharray=%220.01 6.3%22/%3E%3Cpath d=%22M46 30.2l-7.4-2.1 5.4-4.3z%22 fill=%22%23fff%22 stroke=%22%23fff%22 stroke-width=%222.4%22 stroke-linejoin=%22round%22/%3E%3C/svg%3E" alt="" width="28" height="28" /><span>cloud<span>hop</span></span></div>
11895
- <div class="tagline">Hop between cloud accounts, roles and regions</div>
13752
+ <nav id="nav" class="nav" aria-label="Sections" hidden>
13753
+ <a href="#/aliases" data-page="aliases">Aliases</a>
13754
+ <a href="#/sources" data-page="sources">Sources</a>
13755
+ <a href="#/assistants" data-page="assistants">AI assistants</a>
13756
+ <a href="#/tools" data-page="tools">Tools</a>
13757
+ </nav>
11896
13758
  <div class="spacer"></div>
11897
13759
  <button type="button" class="primary" id="add-source" hidden>+ Add source</button>
11898
13760
  </div>
@@ -11932,9 +13794,13 @@ async function readJson(req) {
11932
13794
  return raw ? JSON.parse(raw) : {};
11933
13795
  }
11934
13796
  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 }) {
13797
+ async function startUiServer({
13798
+ app,
13799
+ assistants,
13800
+ port = 0
13801
+ }) {
11936
13802
  const token = randomBytes(24).toString("base64url");
11937
- const handle = createApi(app);
13803
+ const handle = createApi(app, assistants);
11938
13804
  let allowedHosts = /* @__PURE__ */ new Set();
11939
13805
  const server = createServer(async (req, res) => {
11940
13806
  try {
@@ -11994,12 +13860,12 @@ async function startUiServer({ app, port = 0 }) {
11994
13860
  }
11995
13861
 
11996
13862
  // src/commands/ui.ts
11997
- async function uiCommand(app, { flags }) {
13863
+ async function uiCommand(app, assistants, { flags }) {
11998
13864
  const port = flags.port ? Number(flags.port) : 0;
11999
13865
  if (!Number.isInteger(port) || port < 0 || port > 65535) {
12000
13866
  throw new CloudhopError("INVALID_NAME", `--port must be a number between 1 and 65535.`);
12001
13867
  }
12002
- const server = await startUiServer({ app, port });
13868
+ const server = await startUiServer({ app, assistants, port });
12003
13869
  console.log(`cloudhop UI running at ${bold(server.url)}`);
12004
13870
  console.log("Only this computer can open it, and the link works only while this runs.");
12005
13871
  console.log("Press Ctrl-C to stop.");
@@ -12012,15 +13878,15 @@ async function uiCommand(app, { flags }) {
12012
13878
  }
12013
13879
 
12014
13880
  // src/container.ts
12015
- import { homedir as homedir2 } from "os";
12016
- import { join as join5 } from "path";
13881
+ import { homedir as homedir4 } from "os";
13882
+ import { join as join8 } from "path";
12017
13883
 
12018
13884
  // ../provider-aws/src/aws-provider.ts
12019
- import { join as join4 } from "path";
13885
+ import { join as join7 } from "path";
12020
13886
 
12021
13887
  // ../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";
13888
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
13889
+ import { dirname as dirname4 } from "path";
12024
13890
  var BLOCK_START = "# >>> cloudhop managed >>> (edit with `cloudhop`, not by hand)";
12025
13891
  var BLOCK_END = "# <<< cloudhop managed <<<";
12026
13892
  function renderBlock(sections) {
@@ -12067,13 +13933,64 @@ function replaceManagedBlock(content, sections) {
12067
13933
  async function writeManagedBlock(path2, sections) {
12068
13934
  let content = "";
12069
13935
  try {
12070
- content = await readFile2(path2, "utf8");
13936
+ content = await readFile5(path2, "utf8");
12071
13937
  } catch {
12072
13938
  }
12073
13939
  const next = replaceManagedBlock(content, sections);
12074
13940
  if (next === content) return;
12075
- await mkdir2(dirname2(path2), { recursive: true, mode: 448 });
12076
- await writeFile2(path2, next, { mode: 384 });
13941
+ await mkdir4(dirname4(path2), { recursive: true, mode: 448 });
13942
+ await writeFile4(path2, next, { mode: 384 });
13943
+ }
13944
+
13945
+ // ../provider-aws/src/console.ts
13946
+ var ISSUER = "https://dayepesb.github.io/cloudhop/";
13947
+ function partitionHosts(region) {
13948
+ if (region?.startsWith("cn-")) {
13949
+ return { signin: "https://signin.amazonaws.cn", console: "https://console.amazonaws.cn" };
13950
+ }
13951
+ if (region?.startsWith("us-gov-")) {
13952
+ return {
13953
+ signin: "https://signin.amazonaws-us-gov.com",
13954
+ console: "https://console.amazonaws-us-gov.com"
13955
+ };
13956
+ }
13957
+ return { signin: "https://signin.aws.amazon.com", console: "https://console.aws.amazon.com" };
13958
+ }
13959
+ function consoleDestination(region, service) {
13960
+ const { console: console2 } = partitionHosts(region);
13961
+ const path2 = service ? `${service}/home` : "console/home";
13962
+ return `${console2}/${path2}${region ? `?region=${encodeURIComponent(region)}` : ""}`;
13963
+ }
13964
+ async function federatedConsoleUrl(credentials, destination, region, fetchFn) {
13965
+ const {
13966
+ AWS_ACCESS_KEY_ID: id,
13967
+ AWS_SECRET_ACCESS_KEY: key,
13968
+ AWS_SESSION_TOKEN: token
13969
+ } = credentials;
13970
+ if (!id || !key || !token) return void 0;
13971
+ const { signin } = partitionHosts(region);
13972
+ const response = await fetchFn(`${signin}/federation`, {
13973
+ method: "POST",
13974
+ headers: { "content-type": "application/x-www-form-urlencoded" },
13975
+ body: new URLSearchParams({
13976
+ Action: "getSigninToken",
13977
+ Session: JSON.stringify({ sessionId: id, sessionKey: key, sessionToken: token })
13978
+ }).toString()
13979
+ }).catch(() => void 0);
13980
+ if (!response?.ok) return void 0;
13981
+ const signinToken = (await response.json().catch(() => ({}))).SigninToken;
13982
+ if (!signinToken) return void 0;
13983
+ const params = new URLSearchParams({
13984
+ Action: "login",
13985
+ Issuer: ISSUER,
13986
+ Destination: destination,
13987
+ SigninToken: signinToken
13988
+ });
13989
+ return `${signin}/federation?${params}`;
13990
+ }
13991
+ function portalShortcutUrl(startUrl, accountId, roleName, destination) {
13992
+ const params = new URLSearchParams({ account_id: accountId, role_name: roleName, destination });
13993
+ return `${startUrl.replace(/\/+$/, "")}/#/console?${params}`;
12077
13994
  }
12078
13995
 
12079
13996
  // ../provider-aws/src/http.ts
@@ -12087,12 +14004,12 @@ var AwsHttpError = class extends Error {
12087
14004
  status;
12088
14005
  code;
12089
14006
  };
12090
- async function parse2(response) {
12091
- const text3 = await response.text();
12092
- const body = text3 ? JSON.parse(text3) : {};
14007
+ async function parse3(response) {
14008
+ const text4 = await response.text();
14009
+ const body = text4 ? JSON.parse(text4) : {};
12093
14010
  if (!response.ok) {
12094
14011
  const code = body.error ?? body.__type ?? body.code;
12095
- const description = body.error_description ?? body.message ?? text3;
14012
+ const description = body.error_description ?? body.message ?? text4;
12096
14013
  throw new AwsHttpError(
12097
14014
  response.status,
12098
14015
  code,
@@ -12107,10 +14024,10 @@ async function postJson(fetchFn, url, body) {
12107
14024
  headers: { "content-type": "application/json" },
12108
14025
  body: JSON.stringify(body)
12109
14026
  });
12110
- return parse2(response);
14027
+ return parse3(response);
12111
14028
  }
12112
14029
  async function getJson(fetchFn, url, headers) {
12113
- return parse2(await fetchFn(url, { headers }));
14030
+ return parse3(await fetchFn(url, { headers }));
12114
14031
  }
12115
14032
 
12116
14033
  // ../provider-aws/src/sso/portal.ts
@@ -12160,7 +14077,7 @@ var PortalClient = class {
12160
14077
  };
12161
14078
 
12162
14079
  // ../provider-aws/src/sso/sso-login.ts
12163
- import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
14080
+ import { createHash as createHash3, randomBytes as randomBytes2 } from "crypto";
12164
14081
 
12165
14082
  // ../provider-aws/src/sso/loopback.ts
12166
14083
  import { createServer as createServer2 } from "http";
@@ -12293,24 +14210,24 @@ var OidcClient = class {
12293
14210
  };
12294
14211
 
12295
14212
  // ../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";
14213
+ import { createHash as createHash2 } from "crypto";
14214
+ import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
14215
+ import { join as join6 } from "path";
12299
14216
  var EXPIRY_MARGIN_MS = 5 * 60 * 1e3;
12300
14217
  function tokenCachePath(awsDir, sessionName) {
12301
- const hash = createHash("sha1").update(sessionName).digest("hex");
12302
- return join3(awsDir, "sso", "cache", `${hash}.json`);
14218
+ const hash = createHash2("sha1").update(sessionName).digest("hex");
14219
+ return join6(awsDir, "sso", "cache", `${hash}.json`);
12303
14220
  }
12304
14221
  async function readToken(awsDir, sessionName) {
12305
14222
  try {
12306
- return JSON.parse(await readFile3(tokenCachePath(awsDir, sessionName), "utf8"));
14223
+ return JSON.parse(await readFile6(tokenCachePath(awsDir, sessionName), "utf8"));
12307
14224
  } catch {
12308
14225
  return void 0;
12309
14226
  }
12310
14227
  }
12311
14228
  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), {
14229
+ await mkdir5(join6(awsDir, "sso", "cache"), { recursive: true, mode: 448 });
14230
+ await writeFile5(tokenCachePath(awsDir, sessionName), JSON.stringify(token, null, 2), {
12314
14231
  mode: 384
12315
14232
  });
12316
14233
  }
@@ -12353,7 +14270,7 @@ async function authorizationCodeLogin(options) {
12353
14270
  clientId: client.clientId,
12354
14271
  redirectUri: server.redirectUri,
12355
14272
  state,
12356
- codeChallenge: base64url(createHash2("sha256").update(codeVerifier).digest())
14273
+ codeChallenge: base64url(createHash3("sha256").update(codeVerifier).digest())
12357
14274
  });
12358
14275
  options.interaction.notify("Opening your browser to log in\u2026");
12359
14276
  options.interaction.notify(`If it doesn't open, visit: ${url}`);
@@ -12449,6 +14366,42 @@ var CONSOLE_LOGIN_KIND = "console-login";
12449
14366
  var CONSOLE_TARGET = { id: "console", label: "Console sign-in", attributes: {} };
12450
14367
  var ssoSessionName = (sourceId) => `cloudhop-${sourceId}`;
12451
14368
  var consoleProfileName = (sourceId) => `cloudhop-${sourceId}`;
14369
+ var GLOBAL_WITH_VALUE = /* @__PURE__ */ new Set([
14370
+ "--region",
14371
+ "--output",
14372
+ "--profile",
14373
+ "--endpoint-url",
14374
+ "--query",
14375
+ "--color",
14376
+ "--ca-bundle",
14377
+ "--cli-read-timeout",
14378
+ "--cli-connect-timeout",
14379
+ "--cli-binary-format"
14380
+ ]);
14381
+ var READ_VERBS = /^(describe|list|get|head|lookup|search|scan|query|select|filter|tail|batch-get|check|preview|simulate|validate|estimate|test|wait|help|download|ls|presign)(-|$)/;
14382
+ function isAwsReadOnly(args) {
14383
+ if (args.includes("--dryrun") || args.includes("--dry-run") || args.includes("help")) return true;
14384
+ const words = [];
14385
+ for (let i2 = 0; i2 < args.length; i2++) {
14386
+ const arg = args[i2];
14387
+ if (arg.startsWith("--")) {
14388
+ if (GLOBAL_WITH_VALUE.has(arg) && words.length < 2) i2++;
14389
+ continue;
14390
+ }
14391
+ words.push(arg);
14392
+ }
14393
+ const [service, operation] = words;
14394
+ if (!service || !operation) return true;
14395
+ if (service === "sts" || service === "configure") return true;
14396
+ if (service === "s3") {
14397
+ if (operation === "cp" || operation === "sync") {
14398
+ const destination = words[words.length - 1] ?? "";
14399
+ return !destination.startsWith("s3://");
14400
+ }
14401
+ return operation === "ls" || operation === "presign";
14402
+ }
14403
+ return READ_VERBS.test(operation);
14404
+ }
12452
14405
  var AwsProvider = class {
12453
14406
  constructor(options) {
12454
14407
  this.options = options;
@@ -12458,6 +14411,7 @@ var AwsProvider = class {
12458
14411
  id = "aws";
12459
14412
  displayName = "AWS";
12460
14413
  cli = { command: "aws", displayName: "AWS CLI v2", minVersion: "2.32.0" };
14414
+ identityArgs = ["sts", "get-caller-identity"];
12461
14415
  sourceKinds = [
12462
14416
  {
12463
14417
  kind: SSO_KIND,
@@ -12566,7 +14520,7 @@ var AwsProvider = class {
12566
14520
  }
12567
14521
  });
12568
14522
  }
12569
- await writeManagedBlock(join4(this.options.awsDir, "config"), sections);
14523
+ await writeManagedBlock(join7(this.options.awsDir, "config"), sections);
12570
14524
  }
12571
14525
  async processEnv(resolved, mode) {
12572
14526
  const region = resolved.region;
@@ -12582,6 +14536,25 @@ var AwsProvider = class {
12582
14536
  const credentials = resolved.source.kind === CONSOLE_LOGIN_KIND ? await this.exportedCredentials(profile) : await this.roleCredentials(resolved);
12583
14537
  return { AWS_PROFILE: void 0, ...credentials, ...regionEnv };
12584
14538
  }
14539
+ async consoleUrl(resolved, service) {
14540
+ const destination = consoleDestination(resolved.region, service);
14541
+ const sso = resolved.source.kind !== CONSOLE_LOGIN_KIND;
14542
+ const credentials = sso ? await this.roleCredentials(resolved) : await this.exportedCredentials(consoleProfileName(resolved.source.id)).catch(() => ({}));
14543
+ const url = await federatedConsoleUrl(credentials, destination, resolved.region, this.fetchFn);
14544
+ if (url) return { url, via: "signed-in" };
14545
+ const attrs = resolved.target?.attributes ?? {};
14546
+ const startUrl = resolved.source.settings.startUrl;
14547
+ if (sso && startUrl && attrs.accountId && attrs.roleName) {
14548
+ return {
14549
+ url: portalShortcutUrl(startUrl, attrs.accountId, attrs.roleName, destination),
14550
+ via: "portal"
14551
+ };
14552
+ }
14553
+ return { url: destination, via: "sign-in-page" };
14554
+ }
14555
+ isReadOnly(args) {
14556
+ return isAwsReadOnly(args);
14557
+ }
12585
14558
  isSessionError(output) {
12586
14559
  return SESSION_ERROR_PATTERNS.some((pattern) => pattern.test(output));
12587
14560
  }
@@ -12629,9 +14602,10 @@ var AwsProvider = class {
12629
14602
  };
12630
14603
 
12631
14604
  // src/container.ts
14605
+ var browser = new NodeBrowserOpener();
12632
14606
  function createApp(env = process.env) {
12633
14607
  const runner = new NodeProcessRunner();
12634
- const awsDir = env.CLOUDHOP_AWS_DIR ?? join5(homedir2(), ".aws");
14608
+ const awsDir = env.CLOUDHOP_AWS_DIR ?? join8(homedir4(), ".aws");
12635
14609
  return new CloudhopApp({
12636
14610
  config: new YamlConfigStore(),
12637
14611
  providers: [
@@ -12643,18 +14617,72 @@ function createApp(env = process.env) {
12643
14617
  // Azure and Google Cloud providers plug in here.
12644
14618
  ],
12645
14619
  runner,
12646
- browser: new NodeBrowserOpener(),
14620
+ browser,
12647
14621
  installer: new SystemToolInstaller(runner),
12648
14622
  notify
12649
14623
  });
12650
14624
  }
14625
+ function createAssistants(version, env = process.env) {
14626
+ return new AssistantsInstaller({
14627
+ places: { home: homedir4(), project: process.cwd() },
14628
+ configDir: defaultConfigDir(env),
14629
+ runner: new NodeProcessRunner(),
14630
+ cloudhopVersion: version
14631
+ });
14632
+ }
14633
+
14634
+ // src/update-check.ts
14635
+ import { mkdir as mkdir6, readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
14636
+ import { join as join9 } from "path";
14637
+ var DAY_MS = 24 * 60 * 60 * 1e3;
14638
+ var REGISTRY_URL = "https://registry.npmjs.org/cloudhop/latest";
14639
+ function startUpdateCheck(configDir, current) {
14640
+ const env = process.env;
14641
+ if (env.CLOUDHOP_NO_UPDATE_CHECK === "1" || env.CI || !process.stderr.isTTY)
14642
+ return async () => {
14643
+ };
14644
+ const file = join9(configDir, "update-check.json");
14645
+ const work = (async () => {
14646
+ let cache = { checkedAt: 0 };
14647
+ try {
14648
+ cache = JSON.parse(await readFile7(file, "utf8"));
14649
+ } catch {
14650
+ }
14651
+ if (cache.latest && compareVersions(cache.latest, current) > 0) {
14652
+ process.stderr.write(
14653
+ `\x1B[2mcloudhop \u203A\x1B[22m cloudhop ${cache.latest} is available (you have ${current}): npm i -g cloudhop
14654
+ `
14655
+ );
14656
+ }
14657
+ if (Date.now() - cache.checkedAt < DAY_MS) return;
14658
+ const response = await fetch(REGISTRY_URL, {
14659
+ headers: { accept: "application/json" },
14660
+ signal: AbortSignal.timeout(1500)
14661
+ });
14662
+ if (!response.ok) return;
14663
+ const { version } = await response.json();
14664
+ if (typeof version !== "string") return;
14665
+ await mkdir6(configDir, { recursive: true });
14666
+ await writeFile6(file, JSON.stringify({ checkedAt: Date.now(), latest: version }));
14667
+ })().catch(() => {
14668
+ });
14669
+ return () => Promise.race([work, new Promise((resolve) => setTimeout(resolve, 300))]);
14670
+ }
12651
14671
 
12652
14672
  // src/cli.ts
12653
- var HELP = `cloudhop ${"1.0.0"}
14673
+ var HELP = `cloudhop ${"1.2.0"}
12654
14674
  Hop between cloud accounts, roles and regions with one command.
12655
14675
 
12656
14676
  Usage
12657
14677
  hop <alias> [args...] Run the cloud CLI as <alias> (no args: who am I)
14678
+ hop Pick the alias from a list, then type the command
14679
+ hop - [args...] Pick the alias from a list and run [args...]
14680
+ cloudhop console [alias] [service] [--region <r>]
14681
+ Open the web console as <alias>, already signed in
14682
+ cloudhop each <selection...> [--source <s>] [--prod|--no-prod] [--yes] [--json] -- <args...>
14683
+ Run one command in many aliases (names, a,b lists, 'globex-*', /regex/)
14684
+ cloudhop use [alias] | --off Use an alias in this shell (aws, terraform... without hop)
14685
+ cloudhop shell [install] Shell integration: cloudhop use, tab completion, cloudhop_prompt
12658
14686
  cloudhop exec <alias> [--region <r>] -- <cmd...>
12659
14687
  Run any tool with the alias' temporary credentials
12660
14688
 
@@ -12663,16 +14691,26 @@ Setup
12663
14691
  cloudhop add Add an SSO portal or a personal account
12664
14692
  cloudhop add aws sso --start-url <url> [--region <r>] [--name <id>]
12665
14693
  cloudhop add aws console-login [--region <r>] [--name <id>]
14694
+ cloudhop team export [--out <file>] | team import <file|https URL>
14695
+ Share portals and aliases with your team (no secrets)
12666
14696
  cloudhop alias [source] Create more aliases from a source
12667
14697
  cloudhop alias <source> --list | --target <id> [--name <alias>] [--region <r>]
14698
+ cloudhop mv <alias> <new-name> Rename an alias
14699
+ cloudhop prod [alias...] [--off]
14700
+ Mark aliases as production (asks before changes)
12668
14701
  cloudhop region <alias> [region]
12669
14702
  Change the region an alias runs in
12670
14703
  cloudhop login [source] Log in again and refresh its accounts
12671
14704
  cloudhop sync [source] Refresh the accounts/roles you have access to
12672
- cloudhop ls [source] List sources and their aliases
14705
+ cloudhop ls [source] [--json] List sources and their aliases
14706
+ cloudhop status [alias] [--json]
14707
+ Can it run now, or will it need a sign-in?
12673
14708
  cloudhop rm <name...> [--yes] Remove aliases or sources (a source takes its aliases)
12674
14709
  cloudhop provider [ls|enable|disable] <provider>
12675
14710
  cloudhop doctor Check (and install) the cloud CLIs
14711
+ cloudhop agent [status|install|update|remove] [--tools <list>] [--scope user|project] [--no-mcp]
14712
+ Teach Claude Code, Kiro, Copilot, Cursor or Codex to use cloudhop
14713
+ cloudhop mcp Model Context Protocol server for AI assistants (stdio)
12676
14714
  cloudhop ui [--port <n>] [--no-open]
12677
14715
  Manage sources and aliases in a local web page
12678
14716
 
@@ -12691,19 +14729,67 @@ function takeGlobalFlags(argv) {
12691
14729
  }
12692
14730
  async function main(argv) {
12693
14731
  const [command, ...args] = argv;
14732
+ const invokedAsHop = /^hop(\.js|\.cmd)?$/i.test(basename(process.argv[1] ?? ""));
14733
+ if (!command && invokedAsHop && interactive() || command === "-") {
14734
+ const app2 = createApp();
14735
+ const alias = await pickAlias(app2);
14736
+ let rest = args;
14737
+ if (!command) {
14738
+ const typed = answer(
14739
+ await dist_exports.text({
14740
+ message: `aws command to run as ${alias}`,
14741
+ placeholder: "empty = who am I, e.g. s3 ls"
14742
+ })
14743
+ ).trim();
14744
+ rest = typed ? (typed.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((w) => w.replace(/^(["'])(.*)\1$/, "$2")) : [];
14745
+ }
14746
+ return proxyCommand(app2, alias, rest);
14747
+ }
12694
14748
  if (!command || command === "help" || command === "--help" || command === "-h") {
12695
14749
  console.log(HELP);
12696
14750
  return 0;
12697
14751
  }
12698
14752
  if (command === "--version" || command === "-v" || command === "version") {
12699
- console.log("1.0.0");
14753
+ console.log("1.2.0");
12700
14754
  return 0;
12701
14755
  }
12702
14756
  const app = createApp();
12703
14757
  const local = RESERVED_NAMES.has(command) ? takeGlobalFlags(args) : args;
14758
+ if (command === "__complete") {
14759
+ await completeCommand(app, args[0]);
14760
+ return 0;
14761
+ }
14762
+ if (command === "shell") {
14763
+ await shellCommand(local);
14764
+ return 0;
14765
+ }
14766
+ if (command === "mcp") {
14767
+ await serveMcp({ app, version: "1.2.0" });
14768
+ return 0;
14769
+ }
14770
+ const assistants = createAssistants("1.2.0");
14771
+ await refreshAssistantSkills(assistants, command);
14772
+ const finishUpdateCheck = startUpdateCheck(defaultConfigDir(), "1.2.0");
14773
+ try {
14774
+ return await dispatch(app, assistants, command, args, local);
14775
+ } finally {
14776
+ await finishUpdateCheck();
14777
+ }
14778
+ }
14779
+ async function refreshAssistantSkills(assistants, command) {
14780
+ if (command === "agent") return;
14781
+ try {
14782
+ const { updated } = await assistants.update();
14783
+ if (updated.length > 0) {
14784
+ notify(`Updated the cloudhop skill for your AI assistants (${updated.length} file(s)).`);
14785
+ }
14786
+ } catch {
14787
+ }
14788
+ }
14789
+ async function dispatch(app, assistants, command, args, local) {
12704
14790
  switch (command) {
12705
14791
  case "setup":
12706
- await setupCommand(app);
14792
+ await setupCommand(app, assistants);
12707
14793
  return 0;
12708
14794
  case "add":
12709
14795
  await addCommand(app, parseArgs(local));
@@ -12711,6 +14797,16 @@ async function main(argv) {
12711
14797
  case "alias":
12712
14798
  await aliasCommand(app, parseArgs(local));
12713
14799
  return 0;
14800
+ case "status":
14801
+ await statusCommand(app, local);
14802
+ return 0;
14803
+ case "prod":
14804
+ await prodCommand(app, local);
14805
+ return 0;
14806
+ case "mv":
14807
+ case "rename":
14808
+ await renameCommand(app, local);
14809
+ return 0;
12714
14810
  case "region":
12715
14811
  await regionCommand(app, local);
12716
14812
  return 0;
@@ -12722,7 +14818,7 @@ async function main(argv) {
12722
14818
  return 0;
12723
14819
  case "ls":
12724
14820
  case "list":
12725
- await listCommand(app, local[0]);
14821
+ await listCommand(app, local);
12726
14822
  return 0;
12727
14823
  case "rm":
12728
14824
  case "remove":
@@ -12736,8 +14832,22 @@ async function main(argv) {
12736
14832
  return 0;
12737
14833
  case "exec":
12738
14834
  return execCommand(app, local);
14835
+ case "agent":
14836
+ await agentCommand(assistants, local);
14837
+ return 0;
14838
+ case "team":
14839
+ await teamCommand(app, local);
14840
+ return 0;
14841
+ case "use":
14842
+ await useCommand(app, local);
14843
+ return 0;
14844
+ case "each":
14845
+ return eachCommand(app, args);
14846
+ case "console":
14847
+ await consoleCommand(app, local);
14848
+ return 0;
12739
14849
  case "ui":
12740
- await uiCommand(app, parseArgs(local));
14850
+ await uiCommand(app, assistants, parseArgs(local));
12741
14851
  return 0;
12742
14852
  default:
12743
14853
  return proxyCommand(app, command, args);