claudeup 4.18.0 → 4.19.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.
@@ -16,7 +16,14 @@ import {
16
16
  getFlagById,
17
17
  type AliasFlag,
18
18
  } from "../data/alias-flags.js";
19
- import type { AliasConfig, FlagValue } from "./alias-store.js";
19
+ import {
20
+ CHANNELS_FLAG_ID,
21
+ defaultValueFor,
22
+ derivedChannelValues,
23
+ listValues,
24
+ type AliasConfig,
25
+ type FlagValue,
26
+ } from "./alias-store.js";
20
27
 
21
28
  export type ShellKind = "zsh" | "bash" | "fish";
22
29
 
@@ -128,7 +135,16 @@ export function renderArgs(config: AliasConfig): Segment[] {
128
135
  const value = config.flags[flag.id];
129
136
  if (!value) continue;
130
137
 
131
- const rendered = renderFlag(flag, value);
138
+ // `--channels` is the union (own ∪ dev-load-if-enabled), computed here at
139
+ // render time — the union is NOT stored, so own/derived stay distinct in
140
+ // the UI. A dev-loaded channel must run, so derived values force `--channels`
141
+ // to emit even when the user's own channels list is disabled/empty.
142
+ const renderValue =
143
+ flag.id === CHANNELS_FLAG_ID
144
+ ? channelsUnionValue(config.flags, value)
145
+ : value;
146
+
147
+ const rendered = renderFlag(flag, renderValue);
132
148
  if (rendered.length === 0) continue;
133
149
  out.push(...rendered);
134
150
  if (flag.xorGroup) seenXor.add(flag.xorGroup);
@@ -184,6 +200,26 @@ function templatedSegment(value: string): Segment {
184
200
  return { kind: "composite", parts };
185
201
  }
186
202
 
203
+ /**
204
+ * Build the effective `--channels` value for rendering: the user's own
205
+ * channels unioned with the dev-load flag's values (when dev-load is enabled),
206
+ * deduped, own-first. Enabled when EITHER source contributes a value, so a
207
+ * dev-loaded channel still forces `--channels` to emit even if the user never
208
+ * enabled their own channels list. The union lives here, at render time — it
209
+ * is never written back to `channels.values`, so provenance is preserved.
210
+ */
211
+ function channelsUnionValue(
212
+ flags: Record<string, FlagValue>,
213
+ own: FlagValue,
214
+ ): FlagValue {
215
+ const ownValues = own.kind === "text-list" && own.enabled
216
+ ? listValues(own)
217
+ : [];
218
+ const derived = derivedChannelValues(flags);
219
+ const merged = dedupe([...ownValues, ...derived]).filter((v) => v.length > 0);
220
+ return { kind: "text-list", enabled: merged.length > 0, values: merged };
221
+ }
222
+
187
223
  function renderFlag(flag: AliasFlag, value: FlagValue): Segment[] {
188
224
  switch (value.kind) {
189
225
  case "boolean":
@@ -455,9 +491,21 @@ export interface WriteResult {
455
491
  action: "created" | "updated";
456
492
  }
457
493
 
494
+ export interface WriteOptions {
495
+ /**
496
+ * Line range [start, end] (zero-based, inclusive) of a hand-written alias
497
+ * to REPLACE in place with the managed block — the adoption path. Only
498
+ * honored when the file has no managed block yet (a first write after
499
+ * adoption); subsequent writes find the markers and ignore this. Guarantees
500
+ * the adopted line is removed so no duplicate alias survives.
501
+ */
502
+ adoptLineRange?: [number, number];
503
+ }
504
+
458
505
  export async function writeAliasToShell(
459
506
  config: AliasConfig,
460
507
  target: ShellTarget,
508
+ options: WriteOptions = {},
461
509
  ): Promise<WriteResult> {
462
510
  const args = renderArgs(config);
463
511
  const unquotable = findUnquotableTokens(args);
@@ -472,7 +520,17 @@ export async function writeAliasToShell(
472
520
  }
473
521
  const rendered = renderAlias(config, target.kind);
474
522
  const existing = target.exists ? await readFile(target.path, "utf8") : "";
475
- const next = spliceManagedBlock(existing, rendered.block);
523
+
524
+ // Adoption: when a line range was supplied AND the file has no managed block
525
+ // yet, replace the adopted alias line in place — the managed block takes its
526
+ // slot, so the original can't survive as a duplicate. Once a managed block
527
+ // exists, marker-based splicing is position-independent and takes over.
528
+ const hasBlock = existing.includes(BLOCK_BEGIN);
529
+ const next =
530
+ options.adoptLineRange && !hasBlock
531
+ ? spliceManagedBlockAtRange(existing, rendered.block, options.adoptLineRange)
532
+ : spliceManagedBlock(existing, rendered.block);
533
+
476
534
  await writeFile(target.path, next, "utf8");
477
535
  return {
478
536
  shell: target.kind,
@@ -712,6 +770,35 @@ function unparseSubstitution(posixCode: string): string {
712
770
  * scan is sufficient. We pre-index by flag string for `--foo` lookups.
713
771
  */
714
772
  export function argsToFlagValues(args: string[]): Record<string, FlagValue> {
773
+ return argsToFlagValuesWithLeftovers(args).flags;
774
+ }
775
+
776
+ /**
777
+ * Result of {@link argsToFlagValuesWithLeftovers}: the parsed flag map plus the
778
+ * argv tokens that didn't map to any catalog flag.
779
+ *
780
+ * `leftovers` is the lossless gate for alias adoption: a hand-written alias is
781
+ * safe to absorb into the managed block ONLY when `leftovers` is empty, i.e.
782
+ * every token was recognized. Any leftover token would be silently lost if we
783
+ * deleted the original line and re-rendered from the parsed flags, so a
784
+ * non-empty `leftovers` means "import the flags for the user to see, but do NOT
785
+ * take ownership / do NOT remove their line".
786
+ */
787
+ export interface ParsedFlagsWithLeftovers {
788
+ flags: Record<string, FlagValue>;
789
+ /** argv tokens that matched no catalog flag (and so would be dropped). */
790
+ leftovers: string[];
791
+ }
792
+
793
+ /**
794
+ * Like {@link argsToFlagValues}, but reports every token it couldn't map to a
795
+ * catalog flag. Unknown `--flag value` pairs contribute BOTH tokens to
796
+ * `leftovers` (the flag and its consumed value) so the adoption gate sees the
797
+ * full extent of what wouldn't round-trip.
798
+ */
799
+ export function argsToFlagValuesWithLeftovers(
800
+ args: string[],
801
+ ): ParsedFlagsWithLeftovers {
715
802
  // Index every flag by its primary `flag` string AND its triStateOff variant.
716
803
  const byFlag = new Map<string, { flag: AliasFlag; off?: boolean }>();
717
804
  for (const flag of ALIAS_FLAGS) {
@@ -722,16 +809,22 @@ export function argsToFlagValues(args: string[]): Record<string, FlagValue> {
722
809
  }
723
810
 
724
811
  const out: Record<string, FlagValue> = {};
812
+ const leftovers: string[] = [];
725
813
  let i = 0;
726
814
  while (i < args.length) {
727
815
  const tok = args[i];
728
816
  const entry = byFlag.get(tok);
729
817
  if (!entry) {
730
- // Unknown flagskip this token AND its likely value, if next looks
731
- // like a value (doesn't start with --). This is a best-effort skip
732
- // so unknown flag pairs don't cascade-corrupt the rest of the parse.
818
+ // Unknown tokenrecord it. If the next token looks like its value
819
+ // (doesn't start with `-`), consume and record that too so unknown
820
+ // flag/value pairs don't cascade-corrupt the rest of the parse, and so
821
+ // the adoption gate sees the full unrecognized span.
822
+ leftovers.push(tok);
733
823
  i += 1;
734
- if (i < args.length && !args[i].startsWith("-")) i += 1;
824
+ if (i < args.length && !args[i].startsWith("-")) {
825
+ leftovers.push(args[i]);
826
+ i += 1;
827
+ }
735
828
  continue;
736
829
  }
737
830
  const { flag, off } = entry;
@@ -852,7 +945,26 @@ export function argsToFlagValues(args: string[]): Record<string, FlagValue> {
852
945
  }
853
946
  }
854
947
  }
855
- return out;
948
+
949
+ // Restore channels provenance: the writer emits `--channels` as
950
+ // (own ∪ dev-load), so a naive parse folds derived values back into
951
+ // channels.values. Subtract the dev-load values (when that flag was
952
+ // emitted/enabled) so channels.values holds only the user's OWN channels —
953
+ // the derived ones come from the dev-load flag at display/render time.
954
+ const channels = out[CHANNELS_FLAG_ID];
955
+ const derived = derivedChannelValues(out);
956
+ if (channels && channels.kind === "text-list" && derived.length > 0) {
957
+ const ownOnly = channels.values.filter((v) => !derived.includes(v));
958
+ out[CHANNELS_FLAG_ID] = {
959
+ kind: "text-list",
960
+ // Keep enabled only if the user has their own channels; a channels list
961
+ // that was purely derived shouldn't appear enabled after the subtraction.
962
+ enabled: ownOnly.length > 0,
963
+ values: ownOnly,
964
+ };
965
+ }
966
+
967
+ return { flags: out, leftovers };
856
968
  }
857
969
 
858
970
  /**
@@ -871,3 +983,265 @@ export function parseAliasFromRc(rcText: string): {
871
983
  flags: argsToFlagValues(parsed.args),
872
984
  };
873
985
  }
986
+
987
+ // ─── Adoption (detect & offer to import a hand-written alias) ───────────────
988
+ //
989
+ // When the screen opens and there is NO managed block, the user may still have
990
+ // a hand-written `alias c='claude …'` line elsewhere in the rc file. Rather
991
+ // than ignore it (and look broken), we DETECT it and OFFER to adopt it: import
992
+ // its flags into the editor and, on the next write, replace that line in place
993
+ // with the managed block so ownership transfers cleanly with no duplicate.
994
+ //
995
+ // Safety is built on a single invariant: we only mark an alias `lossless` —
996
+ // and therefore safe to remove — when every token round-trips. Two checks:
997
+ // 1. Leftover-empty: every argv token mapped to a catalog flag.
998
+ // 2. Fixpoint: re-rendering the parsed flags and re-parsing yields the same
999
+ // flag map (catches peek-based mis-maps that consume a token into the
1000
+ // wrong bucket without surfacing as a leftover).
1001
+ // Both are order- and quote-insensitive by construction. A NON-lossless alias
1002
+ // is still offered (the user sees its recognized flags) but its original line
1003
+ // is never removed — we keep what we couldn't fully understand.
1004
+
1005
+ /**
1006
+ * A hand-written `claude`-wrapping alias found OUTSIDE the managed block.
1007
+ */
1008
+ export interface AdoptableAlias {
1009
+ /** The alias name (e.g. `c`). */
1010
+ name: string;
1011
+ /** Parsed flag values from the alias body. */
1012
+ flags: Record<string, FlagValue>;
1013
+ /**
1014
+ * Zero-based line indices [start, end] of the alias line in the rc file.
1015
+ * Single-line today (start === end), but kept as a range so the splice
1016
+ * helper has the exact span to replace.
1017
+ */
1018
+ lineRange: [number, number];
1019
+ /** argv tokens that didn't map to any catalog flag (data we'd drop). */
1020
+ leftovers: string[];
1021
+ /**
1022
+ * True when the alias round-trips exactly: leftovers empty AND the parsed
1023
+ * flags survive a render→reparse fixpoint. Only a lossless alias may have
1024
+ * its original line removed on adoption.
1025
+ */
1026
+ lossless: boolean;
1027
+ /** The raw alias line, verbatim (for display / preservation). */
1028
+ rawLine: string;
1029
+ /**
1030
+ * Other top-level `claude`-wrapping alias lines we found but did NOT pick.
1031
+ * Surfaced so the UI can note "also found N other claude aliases" rather
1032
+ * than silently choosing the first.
1033
+ */
1034
+ others: string[];
1035
+ }
1036
+
1037
+ const ANY_ALIAS_LINE_RE =
1038
+ /^alias\s+([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(['"])(.*)\2\s*$/;
1039
+
1040
+ /**
1041
+ * Tokenize a general (hand-written) POSIX alias body into argv strings.
1042
+ *
1043
+ * Unlike {@link tokenizePosixAliasBody} — which only undoes OUR writer's escape
1044
+ * scheme — this handles the shapes a human would type: bare words, single-
1045
+ * quoted runs (literal, no escapes), and double-quoted runs (we treat the
1046
+ * content literally; we do NOT expand `$VAR` or `$(cmd)`). Adjacent quoted and
1047
+ * bare pieces with no whitespace between them concatenate into one argv token,
1048
+ * matching shell word-joining (`foo' bar'` → `foo bar`).
1049
+ *
1050
+ * Returns `null` for anything we can't cleanly tokenize: unterminated quotes,
1051
+ * or a body containing a shell substitution (`$(`, backtick) or variable (`$`)
1052
+ * — those can't be faithfully represented as a static flag value, so the
1053
+ * caller treats the whole alias as not-adoptable rather than guessing.
1054
+ */
1055
+ export function tokenizeGeneralAliasBody(body: string): string[] | null {
1056
+ const tokens: string[] = [];
1057
+ let current = "";
1058
+ let inToken = false;
1059
+ let i = 0;
1060
+ const len = body.length;
1061
+
1062
+ const flush = () => {
1063
+ if (inToken) {
1064
+ tokens.push(current);
1065
+ current = "";
1066
+ inToken = false;
1067
+ }
1068
+ };
1069
+
1070
+ while (i < len) {
1071
+ const ch = body[i];
1072
+
1073
+ if (ch === " " || ch === "\t") {
1074
+ flush();
1075
+ i += 1;
1076
+ continue;
1077
+ }
1078
+
1079
+ // Bail on dynamic content — we can't round-trip a variable/substitution.
1080
+ if (ch === "$" || ch === "`") return null;
1081
+
1082
+ if (ch === "'") {
1083
+ // Single-quoted run: literal until the next single quote.
1084
+ const end = body.indexOf("'", i + 1);
1085
+ if (end === -1) return null; // unterminated
1086
+ current += body.slice(i + 1, end);
1087
+ inToken = true;
1088
+ i = end + 1;
1089
+ continue;
1090
+ }
1091
+
1092
+ if (ch === '"') {
1093
+ // Double-quoted run: literal until the next double quote. We reject any
1094
+ // `$` or backtick inside (caught at the top of the loop on the next
1095
+ // pass only if unquoted — so scan the run explicitly here).
1096
+ const end = body.indexOf('"', i + 1);
1097
+ if (end === -1) return null; // unterminated
1098
+ const inner = body.slice(i + 1, end);
1099
+ if (inner.includes("$") || inner.includes("`")) return null;
1100
+ current += inner;
1101
+ inToken = true;
1102
+ i = end + 1;
1103
+ continue;
1104
+ }
1105
+
1106
+ // Bare character.
1107
+ current += ch;
1108
+ inToken = true;
1109
+ i += 1;
1110
+ }
1111
+ flush();
1112
+ return tokens;
1113
+ }
1114
+
1115
+ /**
1116
+ * Parse a single hand-written `alias <name>=<quote><body><quote>` line into an
1117
+ * AdoptableAlias (sans `lineRange`/`others`, which the scanner fills in), or
1118
+ * null when the line isn't a `claude`-wrapping alias we can tokenize.
1119
+ */
1120
+ function parseAdoptableLine(line: string): Omit<
1121
+ AdoptableAlias,
1122
+ "lineRange" | "others"
1123
+ > | null {
1124
+ const match = ANY_ALIAS_LINE_RE.exec(line.trim());
1125
+ if (!match) return null;
1126
+ const [, name, , body] = match;
1127
+ const tokens = tokenizeGeneralAliasBody(body);
1128
+ if (!tokens || tokens.length === 0 || tokens[0] !== "claude") return null;
1129
+
1130
+ const args = tokens.slice(1);
1131
+ const { flags, leftovers } = argsToFlagValuesWithLeftovers(args);
1132
+
1133
+ // Fixpoint: render the parsed flags back to tokens and re-parse. If the
1134
+ // result differs, a token was mis-bucketed (e.g. a select value mistaken
1135
+ // for bare). Only an exact fixpoint with no leftovers is lossless.
1136
+ const config: AliasConfig = { aliasName: name, flags: withDefaults(flags) };
1137
+ const reparsed = argsToFlagValues(renderArgsAsTokens(config));
1138
+ const fixpoint = flagsDeepEqual(withDefaults(flags), withDefaults(reparsed));
1139
+ const lossless = leftovers.length === 0 && fixpoint;
1140
+
1141
+ return { name, flags, leftovers, lossless, rawLine: line };
1142
+ }
1143
+
1144
+ /**
1145
+ * Scan rc text for a top-level, hand-written `claude`-wrapping alias OUTSIDE
1146
+ * the managed block. Returns the first such alias (with any others noted), or
1147
+ * null when none is found.
1148
+ *
1149
+ * "Top-level" = the line's indentation is zero. We deliberately skip indented
1150
+ * lines: an alias nested in a function or `if` block can't be replaced by the
1151
+ * managed block without dragging the block into that scope.
1152
+ *
1153
+ * Lines inside an existing managed block are ignored — adoption is only for
1154
+ * the no-managed-block case (the screen's mount checks that separately, but we
1155
+ * guard here too so the function is correct in isolation).
1156
+ */
1157
+ export function findAdoptableAlias(rcText: string): AdoptableAlias | null {
1158
+ const lines = rcText.split("\n");
1159
+
1160
+ // Compute the [begin, end] line span of the managed block, if present, so we
1161
+ // can exclude any alias inside it.
1162
+ let blockStart = -1;
1163
+ let blockEnd = -1;
1164
+ for (let i = 0; i < lines.length; i++) {
1165
+ if (lines[i].includes(BLOCK_BEGIN)) blockStart = i;
1166
+ else if (lines[i].includes(BLOCK_END)) {
1167
+ blockEnd = i;
1168
+ break;
1169
+ }
1170
+ }
1171
+ const insideBlock = (i: number) =>
1172
+ blockStart !== -1 && blockEnd !== -1 && i >= blockStart && i <= blockEnd;
1173
+
1174
+ let picked: AdoptableAlias | null = null;
1175
+ const others: string[] = [];
1176
+
1177
+ for (let i = 0; i < lines.length; i++) {
1178
+ const line = lines[i];
1179
+ if (insideBlock(i)) continue;
1180
+ // Top-level only: no leading whitespace.
1181
+ if (/^\s/.test(line)) continue;
1182
+ const parsed = parseAdoptableLine(line);
1183
+ if (!parsed) continue;
1184
+ if (picked === null) {
1185
+ picked = { ...parsed, lineRange: [i, i], others: [] };
1186
+ } else {
1187
+ others.push(line.trim());
1188
+ }
1189
+ }
1190
+
1191
+ if (picked) picked.others = others;
1192
+ return picked;
1193
+ }
1194
+
1195
+ /**
1196
+ * Replace a line range in `existing` (inclusive, zero-based) with `block`.
1197
+ * Used to absorb an adopted hand-written alias: the original line is removed
1198
+ * and the managed block takes its place, guaranteeing no duplicate alias.
1199
+ * Pure function — no I/O. Falls back to {@link spliceManagedBlock} semantics
1200
+ * (append) when the range is out of bounds.
1201
+ */
1202
+ export function spliceManagedBlockAtRange(
1203
+ existing: string,
1204
+ block: string,
1205
+ range: [number, number],
1206
+ ): string {
1207
+ const lines = existing.split("\n");
1208
+ const [start, end] = range;
1209
+ if (start < 0 || end >= lines.length || start > end) {
1210
+ return spliceManagedBlock(existing, block);
1211
+ }
1212
+ // `block` ends with a newline; splice it as its own line(s) where the old
1213
+ // alias line was. Rejoin and drop the duplicate trailing newline `block`
1214
+ // would introduce when followed by more lines.
1215
+ const before = lines.slice(0, start);
1216
+ const after = lines.slice(end + 1);
1217
+ const blockLines = block.replace(/\n$/, "").split("\n");
1218
+ return [...before, ...blockLines, ...after].join("\n");
1219
+ }
1220
+
1221
+ /** Fill any catalog flags missing from a partial map with their defaults. */
1222
+ function withDefaults(
1223
+ partial: Record<string, FlagValue>,
1224
+ ): Record<string, FlagValue> {
1225
+ const out: Record<string, FlagValue> = {};
1226
+ for (const flag of ALIAS_FLAGS) {
1227
+ out[flag.id] = partial[flag.id] ?? defaultValueFor(flag);
1228
+ }
1229
+ return out;
1230
+ }
1231
+
1232
+ /** Deep-equal two flag maps via per-key JSON compare (small maps). */
1233
+ function flagsDeepEqual(
1234
+ a: Record<string, FlagValue>,
1235
+ b: Record<string, FlagValue>,
1236
+ ): boolean {
1237
+ const aKeys = Object.keys(a).sort();
1238
+ const bKeys = Object.keys(b).sort();
1239
+ if (aKeys.length !== bKeys.length) return false;
1240
+ for (let i = 0; i < aKeys.length; i++) {
1241
+ if (aKeys[i] !== bKeys[i]) return false;
1242
+ }
1243
+ for (const k of aKeys) {
1244
+ if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return false;
1245
+ }
1246
+ return true;
1247
+ }
@@ -44,6 +44,58 @@ export function defaultAliasConfig() {
44
44
  }
45
45
  return { aliasName: DEFAULT_ALIAS_NAME, flags };
46
46
  }
47
+ // ─── Channels ⊇ dev-load (derive-on-display) ───────────────────────────
48
+ //
49
+ // `--channels` is the approved-channel allowlist; `--dangerously-load-
50
+ // development-channels` sideloads channels for local dev. A dev-loaded
51
+ // channel must also run, so at the COMMAND LINE the channels list is the
52
+ // union of both. We do NOT store that union — provenance would be lost
53
+ // (you couldn't tell an own channel from a derived one). Instead:
54
+ // - `channels.values` holds the user's OWN approved channels only.
55
+ // - The dev-load flag is the single source of truth for derived channels.
56
+ // - The editor shows them as a separate read-only section (display).
57
+ // - The writer unions `own ∪ dev-load(if enabled)` at RENDER time.
58
+ // This keeps the two lists distinct everywhere except the emitted alias.
59
+ /** Id of the channels allowlist flag. */
60
+ export const CHANNELS_FLAG_ID = "channels";
61
+ /** Id of the dev-load flag whose values are derived into channels. */
62
+ export const DEVLOAD_FLAG_ID = "dangerously-load-development-channels";
63
+ /** Read the value list off a flag value, or `[]` for non-list kinds. */
64
+ export function listValues(v) {
65
+ return v && v.kind === "text-list" ? v.values : [];
66
+ }
67
+ /**
68
+ * Channels that `--channels` derives from the dev-load flag for DISPLAY and
69
+ * for the render-time union: the dev-load flag's values, but only when that
70
+ * flag is enabled (a disabled dev-load contributes nothing to the alias).
71
+ */
72
+ export function derivedChannelValues(flags) {
73
+ const devload = flags[DEVLOAD_FLAG_ID];
74
+ if (!devload || devload.kind !== "text-list" || !devload.enabled)
75
+ return [];
76
+ return devload.values;
77
+ }
78
+ /**
79
+ * Return a new flags map with `value` removed from the dev-load flag (the
80
+ * SOURCE of derived channels). Used when the user deletes a "Dev-loaded
81
+ * channels" row inside the --channels editor: the deletion must land on the
82
+ * source flag, never on `channels.values`. Disables dev-load when emptied so
83
+ * it stops emitting. Pure — leaves `channels.values` untouched.
84
+ */
85
+ export function withoutDerivedChannel(flags, value) {
86
+ const cur = flags[DEVLOAD_FLAG_ID];
87
+ if (!cur || cur.kind !== "text-list")
88
+ return flags;
89
+ const values = cur.values.filter((v) => v !== value);
90
+ return {
91
+ ...flags,
92
+ [DEVLOAD_FLAG_ID]: {
93
+ kind: "text-list",
94
+ enabled: values.length > 0 && cur.enabled,
95
+ values,
96
+ },
97
+ };
98
+ }
47
99
  export function defaultValueFor(flag) {
48
100
  switch (flag.kind) {
49
101
  case "boolean":
@@ -79,6 +79,66 @@ export function defaultAliasConfig(): AliasConfig {
79
79
  return { aliasName: DEFAULT_ALIAS_NAME, flags };
80
80
  }
81
81
 
82
+ // ─── Channels ⊇ dev-load (derive-on-display) ───────────────────────────
83
+ //
84
+ // `--channels` is the approved-channel allowlist; `--dangerously-load-
85
+ // development-channels` sideloads channels for local dev. A dev-loaded
86
+ // channel must also run, so at the COMMAND LINE the channels list is the
87
+ // union of both. We do NOT store that union — provenance would be lost
88
+ // (you couldn't tell an own channel from a derived one). Instead:
89
+ // - `channels.values` holds the user's OWN approved channels only.
90
+ // - The dev-load flag is the single source of truth for derived channels.
91
+ // - The editor shows them as a separate read-only section (display).
92
+ // - The writer unions `own ∪ dev-load(if enabled)` at RENDER time.
93
+ // This keeps the two lists distinct everywhere except the emitted alias.
94
+
95
+ /** Id of the channels allowlist flag. */
96
+ export const CHANNELS_FLAG_ID = "channels";
97
+ /** Id of the dev-load flag whose values are derived into channels. */
98
+ export const DEVLOAD_FLAG_ID = "dangerously-load-development-channels";
99
+
100
+ /** Read the value list off a flag value, or `[]` for non-list kinds. */
101
+ export function listValues(v: FlagValue | undefined): string[] {
102
+ return v && v.kind === "text-list" ? v.values : [];
103
+ }
104
+
105
+ /**
106
+ * Channels that `--channels` derives from the dev-load flag for DISPLAY and
107
+ * for the render-time union: the dev-load flag's values, but only when that
108
+ * flag is enabled (a disabled dev-load contributes nothing to the alias).
109
+ */
110
+ export function derivedChannelValues(
111
+ flags: Record<string, FlagValue>,
112
+ ): string[] {
113
+ const devload = flags[DEVLOAD_FLAG_ID];
114
+ if (!devload || devload.kind !== "text-list" || !devload.enabled) return [];
115
+ return devload.values;
116
+ }
117
+
118
+ /**
119
+ * Return a new flags map with `value` removed from the dev-load flag (the
120
+ * SOURCE of derived channels). Used when the user deletes a "Dev-loaded
121
+ * channels" row inside the --channels editor: the deletion must land on the
122
+ * source flag, never on `channels.values`. Disables dev-load when emptied so
123
+ * it stops emitting. Pure — leaves `channels.values` untouched.
124
+ */
125
+ export function withoutDerivedChannel(
126
+ flags: Record<string, FlagValue>,
127
+ value: string,
128
+ ): Record<string, FlagValue> {
129
+ const cur = flags[DEVLOAD_FLAG_ID];
130
+ if (!cur || cur.kind !== "text-list") return flags;
131
+ const values = cur.values.filter((v) => v !== value);
132
+ return {
133
+ ...flags,
134
+ [DEVLOAD_FLAG_ID]: {
135
+ kind: "text-list",
136
+ enabled: values.length > 0 && cur.enabled,
137
+ values,
138
+ },
139
+ };
140
+ }
141
+
82
142
  export function defaultValueFor(flag: AliasFlag): FlagValue {
83
143
  switch (flag.kind) {
84
144
  case "boolean":
@@ -241,6 +241,63 @@ export function extractGoBinaryName(pkg) {
241
241
  const withoutVersion = pkg.replace(/@[^/]*$/, "");
242
242
  return withoutVersion.split("/").pop() || pkg;
243
243
  }
244
+ /**
245
+ * Extract the pinned version from a Go module path, or null when unpinned.
246
+ * e.g., "github.com/MadAppGang/tmux-mcp@v1.6.2" → "v1.6.2"
247
+ * "github.com/MadAppGang/tmux-mcp@latest" → null (a moving target)
248
+ * "github.com/user/tool" → null
249
+ */
250
+ export function extractGoVersion(pkg) {
251
+ const at = pkg.lastIndexOf("@");
252
+ if (at === -1)
253
+ return null;
254
+ const version = pkg.slice(at + 1);
255
+ if (version === "" || version === "latest")
256
+ return null;
257
+ return version;
258
+ }
259
+ /** Normalize a semver-ish string for comparison: trim and drop a leading "v". */
260
+ function normalizeVersion(v) {
261
+ return v.trim().replace(/^v/, "");
262
+ }
263
+ /**
264
+ * Read an installed binary's version via `<binary> --version`, normalized, or
265
+ * null if it produced no usable version string. Used to tell whether a pinned Go
266
+ * dependency is already at the right version.
267
+ */
268
+ async function getInstalledBinaryVersion(binaryName) {
269
+ const { ok, stdout } = await run(binaryName, ["--version"], 10000);
270
+ if (!ok || !stdout)
271
+ return null;
272
+ const match = stdout.split("\n")[0].match(/v?\d+\.\d+\.\d+[^\s]*/);
273
+ return match ? normalizeVersion(match[0]) : null;
274
+ }
275
+ /**
276
+ * Decide whether a Go dependency needs (re)installing. This is what makes a
277
+ * plugin *update* actually update its binary: without a version check an
278
+ * already-present binary is skipped forever, so a bumped plugin never pulls the
279
+ * new binary.
280
+ *
281
+ * - binary absent → yes
282
+ * - pinned version, installed reports the same one → no
283
+ * - pinned version, installed reports a different one → yes
284
+ * - pinned version, installed version can't be read → yes (can't confirm the
285
+ * pin, so reinstall to be safe)
286
+ * - unpinned (@latest) and present → no (a moving target
287
+ * can't be verified without a network round-trip)
288
+ */
289
+ export async function goDepNeedsInstall(pkg) {
290
+ const binaryName = extractGoBinaryName(pkg);
291
+ if (!(await isBinaryAvailable(binaryName)))
292
+ return true;
293
+ const pinned = extractGoVersion(pkg);
294
+ if (!pinned)
295
+ return false;
296
+ const installed = await getInstalledBinaryVersion(binaryName);
297
+ if (installed === null)
298
+ return true;
299
+ return normalizeVersion(pinned) !== installed;
300
+ }
244
301
  /**
245
302
  * Install Go packages via `go install`
246
303
  */
@@ -253,8 +310,7 @@ async function installGoPackages(packages, result) {
253
310
  return;
254
311
  }
255
312
  for (const pkg of packages) {
256
- const binaryName = extractGoBinaryName(pkg);
257
- if (await isBinaryAvailable(binaryName)) {
313
+ if (!(await goDepNeedsInstall(pkg))) {
258
314
  result.skipped.push(`go:${pkg}`);
259
315
  continue;
260
316
  }
@@ -422,8 +478,7 @@ export async function checkMissingDeps(setup) {
422
478
  if (setup.go?.length) {
423
479
  const missingGo = [];
424
480
  for (const pkg of setup.go) {
425
- const bin = extractGoBinaryName(pkg);
426
- if (!(await isBinaryAvailable(bin))) {
481
+ if (await goDepNeedsInstall(pkg)) {
427
482
  missingGo.push(pkg);
428
483
  }
429
484
  }