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.
@@ -73,6 +73,8 @@ mock.module("node:child_process", () => ({
73
73
  // Import AFTER mocks are set up
74
74
  const {
75
75
  extractGoBinaryName,
76
+ extractGoVersion,
77
+ goDepNeedsInstall,
76
78
  installPluginDeps,
77
79
  checkMissingDeps,
78
80
  } = await import("../services/plugin-setup.js");
@@ -259,6 +261,115 @@ describe("checkMissingDeps — go packages", () => {
259
261
  });
260
262
  });
261
263
 
264
+ // ---------------------------------------------------------------------------
265
+ // 4b. Version-aware go dependencies — a bumped plugin must pull the new binary
266
+ // ---------------------------------------------------------------------------
267
+
268
+ describe("extractGoVersion", () => {
269
+ it("returns the pinned version", () => {
270
+ expect(
271
+ extractGoVersion("github.com/MadAppGang/tmux-mcp@v1.6.2"),
272
+ ).toBe("v1.6.2");
273
+ });
274
+ it("returns null for @latest (a moving target)", () => {
275
+ expect(
276
+ extractGoVersion("github.com/MadAppGang/tmux-mcp@latest"),
277
+ ).toBeNull();
278
+ });
279
+ it("returns null when unpinned", () => {
280
+ expect(extractGoVersion("github.com/user/tool")).toBeNull();
281
+ });
282
+ });
283
+
284
+ describe("goDepNeedsInstall — version awareness", () => {
285
+ beforeEach(() => {
286
+ availableBinaries = new Set(["go"]);
287
+ execResults = new Map();
288
+ });
289
+
290
+ it("reinstalls when the pinned version differs from the installed one", async () => {
291
+ availableBinaries.add("tmux-mcp");
292
+ execResults.set("tmux-mcp --version", { stdout: "v1.6.1", stderr: "" });
293
+
294
+ expect(
295
+ await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@v1.6.2"),
296
+ ).toBe(true);
297
+ });
298
+
299
+ it("skips when the installed version matches the pin (v-prefix insensitive)", async () => {
300
+ availableBinaries.add("tmux-mcp");
301
+ execResults.set("tmux-mcp --version", { stdout: "1.6.2", stderr: "" });
302
+
303
+ expect(
304
+ await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@v1.6.2"),
305
+ ).toBe(false);
306
+ });
307
+
308
+ it("reinstalls when the binary can't report its version", async () => {
309
+ availableBinaries.add("tmux-mcp");
310
+ execResults.set("tmux-mcp --version", {
311
+ stdout: "",
312
+ stderr: "flag provided but not defined: -version",
313
+ error: true,
314
+ });
315
+
316
+ expect(
317
+ await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@v1.6.2"),
318
+ ).toBe(true);
319
+ });
320
+
321
+ it("installs when the binary is absent", async () => {
322
+ expect(
323
+ await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@v1.6.2"),
324
+ ).toBe(true);
325
+ });
326
+
327
+ it("leaves an unpinned (@latest) present binary alone", async () => {
328
+ availableBinaries.add("tmux-mcp");
329
+ expect(
330
+ await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@latest"),
331
+ ).toBe(false);
332
+ });
333
+ });
334
+
335
+ describe("checkMissingDeps — pinned go version mismatch", () => {
336
+ beforeEach(() => {
337
+ availableBinaries = new Set(["go", "tmux-mcp"]);
338
+ execResults = new Map();
339
+ });
340
+
341
+ it("reports a present-but-outdated pinned binary as missing", async () => {
342
+ execResults.set("tmux-mcp --version", { stdout: "v1.6.1", stderr: "" });
343
+
344
+ const missing = await checkMissingDeps({
345
+ go: ["github.com/MadAppGang/tmux-mcp@v1.6.2"],
346
+ });
347
+
348
+ expect(missing.go).toEqual(["github.com/MadAppGang/tmux-mcp@v1.6.2"]);
349
+ });
350
+ });
351
+
352
+ describe("installPluginDeps — reinstalls an outdated pinned go binary", () => {
353
+ beforeEach(() => {
354
+ availableBinaries = new Set(["go", "tmux-mcp"]);
355
+ execResults = new Map();
356
+ });
357
+
358
+ it("runs go install when the pinned version is newer than installed", async () => {
359
+ execResults.set("tmux-mcp --version", { stdout: "v1.6.1", stderr: "" });
360
+ execResults.set("/usr/bin/go install", { stdout: "", stderr: "" });
361
+
362
+ const result = await installPluginDeps({
363
+ go: ["github.com/MadAppGang/tmux-mcp@v1.6.2"],
364
+ });
365
+
366
+ expect(result.installed).toContain(
367
+ "go:github.com/MadAppGang/tmux-mcp@v1.6.2",
368
+ );
369
+ expect(result.skipped).toHaveLength(0);
370
+ });
371
+ });
372
+
262
373
  // ---------------------------------------------------------------------------
263
374
  // 5. checkMissingDeps tests — required binaries
264
375
  // ---------------------------------------------------------------------------
@@ -131,13 +131,22 @@ export const ALIAS_FLAGS = [
131
131
  group: "context",
132
132
  description: "Path to a file whose contents are appended to the default system prompt.",
133
133
  },
134
+ {
135
+ id: "channels",
136
+ label: "Channels (approved)",
137
+ flag: "--channels",
138
+ kind: "text-list",
139
+ group: "channels",
140
+ description: "Approved channel plugins/MCP servers to run this session (the allowlist). Channels derived from --dangerously-load appear in a separate section here and are unioned in on write; add your own approved channels on top.",
141
+ defaultValues: [],
142
+ },
134
143
  {
135
144
  id: "dangerously-load-development-channels",
136
145
  label: "Load development channels",
137
146
  flag: "--dangerously-load-development-channels",
138
147
  kind: "text-list",
139
148
  group: "channels",
140
- description: "Sideload development channels from magus plugins. Default seed: claudish (the only magus plugin currently shipping channels).",
149
+ description: "Sideload development channels not on the approved allowlist (local dev only). Also shown under --channels and unioned into it on write. Default seed: claudish (the only magus plugin currently shipping channels).",
141
150
  defaultValues: ["plugin:claudish@magus"],
142
151
  },
143
152
  {
@@ -222,6 +222,16 @@ export const ALIAS_FLAGS: AliasFlag[] = [
222
222
  description:
223
223
  "Path to a file whose contents are appended to the default system prompt.",
224
224
  },
225
+ {
226
+ id: "channels",
227
+ label: "Channels (approved)",
228
+ flag: "--channels",
229
+ kind: "text-list",
230
+ group: "channels",
231
+ description:
232
+ "Approved channel plugins/MCP servers to run this session (the allowlist). Channels derived from --dangerously-load appear in a separate section here and are unioned in on write; add your own approved channels on top.",
233
+ defaultValues: [],
234
+ },
225
235
  {
226
236
  id: "dangerously-load-development-channels",
227
237
  label: "Load development channels",
@@ -229,7 +239,7 @@ export const ALIAS_FLAGS: AliasFlag[] = [
229
239
  kind: "text-list",
230
240
  group: "channels",
231
241
  description:
232
- "Sideload development channels from magus plugins. Default seed: claudish (the only magus plugin currently shipping channels).",
242
+ "Sideload development channels not on the approved allowlist (local dev only). Also shown under --channels and unioned into it on write. Default seed: claudish (the only magus plugin currently shipping channels).",
233
243
  defaultValues: ["plugin:claudish@magus"],
234
244
  },
235
245
  {
@@ -11,6 +11,7 @@ import { existsSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { homedir } from "node:os";
13
13
  import { ALIAS_FLAGS, getFlagById, } from "../data/alias-flags.js";
14
+ import { CHANNELS_FLAG_ID, defaultValueFor, derivedChannelValues, listValues, } from "./alias-store.js";
14
15
  const BLOCK_BEGIN = "# >>> claudeup managed alias >>>";
15
16
  const BLOCK_END = "# <<< claudeup managed alias <<<";
16
17
  /**
@@ -83,7 +84,14 @@ export function renderArgs(config) {
83
84
  const value = config.flags[flag.id];
84
85
  if (!value)
85
86
  continue;
86
- const rendered = renderFlag(flag, value);
87
+ // `--channels` is the union (own ∪ dev-load-if-enabled), computed here at
88
+ // render time — the union is NOT stored, so own/derived stay distinct in
89
+ // the UI. A dev-loaded channel must run, so derived values force `--channels`
90
+ // to emit even when the user's own channels list is disabled/empty.
91
+ const renderValue = flag.id === CHANNELS_FLAG_ID
92
+ ? channelsUnionValue(config.flags, value)
93
+ : value;
94
+ const rendered = renderFlag(flag, renderValue);
87
95
  if (rendered.length === 0)
88
96
  continue;
89
97
  out.push(...rendered);
@@ -137,6 +145,22 @@ function templatedSegment(value) {
137
145
  }
138
146
  return { kind: "composite", parts };
139
147
  }
148
+ /**
149
+ * Build the effective `--channels` value for rendering: the user's own
150
+ * channels unioned with the dev-load flag's values (when dev-load is enabled),
151
+ * deduped, own-first. Enabled when EITHER source contributes a value, so a
152
+ * dev-loaded channel still forces `--channels` to emit even if the user never
153
+ * enabled their own channels list. The union lives here, at render time — it
154
+ * is never written back to `channels.values`, so provenance is preserved.
155
+ */
156
+ function channelsUnionValue(flags, own) {
157
+ const ownValues = own.kind === "text-list" && own.enabled
158
+ ? listValues(own)
159
+ : [];
160
+ const derived = derivedChannelValues(flags);
161
+ const merged = dedupe([...ownValues, ...derived]).filter((v) => v.length > 0);
162
+ return { kind: "text-list", enabled: merged.length > 0, values: merged };
163
+ }
140
164
  function renderFlag(flag, value) {
141
165
  switch (value.kind) {
142
166
  case "boolean":
@@ -379,7 +403,7 @@ export function spliceManagedBlock(existing, block) {
379
403
  cutEnd += 1;
380
404
  return existing.slice(0, beginIdx) + block + existing.slice(cutEnd);
381
405
  }
382
- export async function writeAliasToShell(config, target) {
406
+ export async function writeAliasToShell(config, target, options = {}) {
383
407
  const args = renderArgs(config);
384
408
  const unquotable = findUnquotableTokens(args);
385
409
  if (unquotable.length > 0) {
@@ -389,7 +413,14 @@ export async function writeAliasToShell(config, target) {
389
413
  }
390
414
  const rendered = renderAlias(config, target.kind);
391
415
  const existing = target.exists ? await readFile(target.path, "utf8") : "";
392
- const next = spliceManagedBlock(existing, rendered.block);
416
+ // Adoption: when a line range was supplied AND the file has no managed block
417
+ // yet, replace the adopted alias line in place — the managed block takes its
418
+ // slot, so the original can't survive as a duplicate. Once a managed block
419
+ // exists, marker-based splicing is position-independent and takes over.
420
+ const hasBlock = existing.includes(BLOCK_BEGIN);
421
+ const next = options.adoptLineRange && !hasBlock
422
+ ? spliceManagedBlockAtRange(existing, rendered.block, options.adoptLineRange)
423
+ : spliceManagedBlock(existing, rendered.block);
393
424
  await writeFile(target.path, next, "utf8");
394
425
  return {
395
426
  shell: target.kind,
@@ -598,6 +629,15 @@ function unparseSubstitution(posixCode) {
598
629
  * scan is sufficient. We pre-index by flag string for `--foo` lookups.
599
630
  */
600
631
  export function argsToFlagValues(args) {
632
+ return argsToFlagValuesWithLeftovers(args).flags;
633
+ }
634
+ /**
635
+ * Like {@link argsToFlagValues}, but reports every token it couldn't map to a
636
+ * catalog flag. Unknown `--flag value` pairs contribute BOTH tokens to
637
+ * `leftovers` (the flag and its consumed value) so the adoption gate sees the
638
+ * full extent of what wouldn't round-trip.
639
+ */
640
+ export function argsToFlagValuesWithLeftovers(args) {
601
641
  // Index every flag by its primary `flag` string AND its triStateOff variant.
602
642
  const byFlag = new Map();
603
643
  for (const flag of ALIAS_FLAGS) {
@@ -607,17 +647,22 @@ export function argsToFlagValues(args) {
607
647
  }
608
648
  }
609
649
  const out = {};
650
+ const leftovers = [];
610
651
  let i = 0;
611
652
  while (i < args.length) {
612
653
  const tok = args[i];
613
654
  const entry = byFlag.get(tok);
614
655
  if (!entry) {
615
- // Unknown flagskip this token AND its likely value, if next looks
616
- // like a value (doesn't start with --). This is a best-effort skip
617
- // so unknown flag pairs don't cascade-corrupt the rest of the parse.
656
+ // Unknown tokenrecord it. If the next token looks like its value
657
+ // (doesn't start with `-`), consume and record that too so unknown
658
+ // flag/value pairs don't cascade-corrupt the rest of the parse, and so
659
+ // the adoption gate sees the full unrecognized span.
660
+ leftovers.push(tok);
618
661
  i += 1;
619
- if (i < args.length && !args[i].startsWith("-"))
662
+ if (i < args.length && !args[i].startsWith("-")) {
663
+ leftovers.push(args[i]);
620
664
  i += 1;
665
+ }
621
666
  continue;
622
667
  }
623
668
  const { flag, off } = entry;
@@ -746,7 +791,24 @@ export function argsToFlagValues(args) {
746
791
  }
747
792
  }
748
793
  }
749
- return out;
794
+ // Restore channels provenance: the writer emits `--channels` as
795
+ // (own ∪ dev-load), so a naive parse folds derived values back into
796
+ // channels.values. Subtract the dev-load values (when that flag was
797
+ // emitted/enabled) so channels.values holds only the user's OWN channels —
798
+ // the derived ones come from the dev-load flag at display/render time.
799
+ const channels = out[CHANNELS_FLAG_ID];
800
+ const derived = derivedChannelValues(out);
801
+ if (channels && channels.kind === "text-list" && derived.length > 0) {
802
+ const ownOnly = channels.values.filter((v) => !derived.includes(v));
803
+ out[CHANNELS_FLAG_ID] = {
804
+ kind: "text-list",
805
+ // Keep enabled only if the user has their own channels; a channels list
806
+ // that was purely derived shouldn't appear enabled after the subtraction.
807
+ enabled: ownOnly.length > 0,
808
+ values: ownOnly,
809
+ };
810
+ }
811
+ return { flags: out, leftovers };
750
812
  }
751
813
  /**
752
814
  * High-level entry point: read shell rc text, parse the managed block, and
@@ -762,3 +824,195 @@ export function parseAliasFromRc(rcText) {
762
824
  flags: argsToFlagValues(parsed.args),
763
825
  };
764
826
  }
827
+ const ANY_ALIAS_LINE_RE = /^alias\s+([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(['"])(.*)\2\s*$/;
828
+ /**
829
+ * Tokenize a general (hand-written) POSIX alias body into argv strings.
830
+ *
831
+ * Unlike {@link tokenizePosixAliasBody} — which only undoes OUR writer's escape
832
+ * scheme — this handles the shapes a human would type: bare words, single-
833
+ * quoted runs (literal, no escapes), and double-quoted runs (we treat the
834
+ * content literally; we do NOT expand `$VAR` or `$(cmd)`). Adjacent quoted and
835
+ * bare pieces with no whitespace between them concatenate into one argv token,
836
+ * matching shell word-joining (`foo' bar'` → `foo bar`).
837
+ *
838
+ * Returns `null` for anything we can't cleanly tokenize: unterminated quotes,
839
+ * or a body containing a shell substitution (`$(`, backtick) or variable (`$`)
840
+ * — those can't be faithfully represented as a static flag value, so the
841
+ * caller treats the whole alias as not-adoptable rather than guessing.
842
+ */
843
+ export function tokenizeGeneralAliasBody(body) {
844
+ const tokens = [];
845
+ let current = "";
846
+ let inToken = false;
847
+ let i = 0;
848
+ const len = body.length;
849
+ const flush = () => {
850
+ if (inToken) {
851
+ tokens.push(current);
852
+ current = "";
853
+ inToken = false;
854
+ }
855
+ };
856
+ while (i < len) {
857
+ const ch = body[i];
858
+ if (ch === " " || ch === "\t") {
859
+ flush();
860
+ i += 1;
861
+ continue;
862
+ }
863
+ // Bail on dynamic content — we can't round-trip a variable/substitution.
864
+ if (ch === "$" || ch === "`")
865
+ return null;
866
+ if (ch === "'") {
867
+ // Single-quoted run: literal until the next single quote.
868
+ const end = body.indexOf("'", i + 1);
869
+ if (end === -1)
870
+ return null; // unterminated
871
+ current += body.slice(i + 1, end);
872
+ inToken = true;
873
+ i = end + 1;
874
+ continue;
875
+ }
876
+ if (ch === '"') {
877
+ // Double-quoted run: literal until the next double quote. We reject any
878
+ // `$` or backtick inside (caught at the top of the loop on the next
879
+ // pass only if unquoted — so scan the run explicitly here).
880
+ const end = body.indexOf('"', i + 1);
881
+ if (end === -1)
882
+ return null; // unterminated
883
+ const inner = body.slice(i + 1, end);
884
+ if (inner.includes("$") || inner.includes("`"))
885
+ return null;
886
+ current += inner;
887
+ inToken = true;
888
+ i = end + 1;
889
+ continue;
890
+ }
891
+ // Bare character.
892
+ current += ch;
893
+ inToken = true;
894
+ i += 1;
895
+ }
896
+ flush();
897
+ return tokens;
898
+ }
899
+ /**
900
+ * Parse a single hand-written `alias <name>=<quote><body><quote>` line into an
901
+ * AdoptableAlias (sans `lineRange`/`others`, which the scanner fills in), or
902
+ * null when the line isn't a `claude`-wrapping alias we can tokenize.
903
+ */
904
+ function parseAdoptableLine(line) {
905
+ const match = ANY_ALIAS_LINE_RE.exec(line.trim());
906
+ if (!match)
907
+ return null;
908
+ const [, name, , body] = match;
909
+ const tokens = tokenizeGeneralAliasBody(body);
910
+ if (!tokens || tokens.length === 0 || tokens[0] !== "claude")
911
+ return null;
912
+ const args = tokens.slice(1);
913
+ const { flags, leftovers } = argsToFlagValuesWithLeftovers(args);
914
+ // Fixpoint: render the parsed flags back to tokens and re-parse. If the
915
+ // result differs, a token was mis-bucketed (e.g. a select value mistaken
916
+ // for bare). Only an exact fixpoint with no leftovers is lossless.
917
+ const config = { aliasName: name, flags: withDefaults(flags) };
918
+ const reparsed = argsToFlagValues(renderArgsAsTokens(config));
919
+ const fixpoint = flagsDeepEqual(withDefaults(flags), withDefaults(reparsed));
920
+ const lossless = leftovers.length === 0 && fixpoint;
921
+ return { name, flags, leftovers, lossless, rawLine: line };
922
+ }
923
+ /**
924
+ * Scan rc text for a top-level, hand-written `claude`-wrapping alias OUTSIDE
925
+ * the managed block. Returns the first such alias (with any others noted), or
926
+ * null when none is found.
927
+ *
928
+ * "Top-level" = the line's indentation is zero. We deliberately skip indented
929
+ * lines: an alias nested in a function or `if` block can't be replaced by the
930
+ * managed block without dragging the block into that scope.
931
+ *
932
+ * Lines inside an existing managed block are ignored — adoption is only for
933
+ * the no-managed-block case (the screen's mount checks that separately, but we
934
+ * guard here too so the function is correct in isolation).
935
+ */
936
+ export function findAdoptableAlias(rcText) {
937
+ const lines = rcText.split("\n");
938
+ // Compute the [begin, end] line span of the managed block, if present, so we
939
+ // can exclude any alias inside it.
940
+ let blockStart = -1;
941
+ let blockEnd = -1;
942
+ for (let i = 0; i < lines.length; i++) {
943
+ if (lines[i].includes(BLOCK_BEGIN))
944
+ blockStart = i;
945
+ else if (lines[i].includes(BLOCK_END)) {
946
+ blockEnd = i;
947
+ break;
948
+ }
949
+ }
950
+ const insideBlock = (i) => blockStart !== -1 && blockEnd !== -1 && i >= blockStart && i <= blockEnd;
951
+ let picked = null;
952
+ const others = [];
953
+ for (let i = 0; i < lines.length; i++) {
954
+ const line = lines[i];
955
+ if (insideBlock(i))
956
+ continue;
957
+ // Top-level only: no leading whitespace.
958
+ if (/^\s/.test(line))
959
+ continue;
960
+ const parsed = parseAdoptableLine(line);
961
+ if (!parsed)
962
+ continue;
963
+ if (picked === null) {
964
+ picked = { ...parsed, lineRange: [i, i], others: [] };
965
+ }
966
+ else {
967
+ others.push(line.trim());
968
+ }
969
+ }
970
+ if (picked)
971
+ picked.others = others;
972
+ return picked;
973
+ }
974
+ /**
975
+ * Replace a line range in `existing` (inclusive, zero-based) with `block`.
976
+ * Used to absorb an adopted hand-written alias: the original line is removed
977
+ * and the managed block takes its place, guaranteeing no duplicate alias.
978
+ * Pure function — no I/O. Falls back to {@link spliceManagedBlock} semantics
979
+ * (append) when the range is out of bounds.
980
+ */
981
+ export function spliceManagedBlockAtRange(existing, block, range) {
982
+ const lines = existing.split("\n");
983
+ const [start, end] = range;
984
+ if (start < 0 || end >= lines.length || start > end) {
985
+ return spliceManagedBlock(existing, block);
986
+ }
987
+ // `block` ends with a newline; splice it as its own line(s) where the old
988
+ // alias line was. Rejoin and drop the duplicate trailing newline `block`
989
+ // would introduce when followed by more lines.
990
+ const before = lines.slice(0, start);
991
+ const after = lines.slice(end + 1);
992
+ const blockLines = block.replace(/\n$/, "").split("\n");
993
+ return [...before, ...blockLines, ...after].join("\n");
994
+ }
995
+ /** Fill any catalog flags missing from a partial map with their defaults. */
996
+ function withDefaults(partial) {
997
+ const out = {};
998
+ for (const flag of ALIAS_FLAGS) {
999
+ out[flag.id] = partial[flag.id] ?? defaultValueFor(flag);
1000
+ }
1001
+ return out;
1002
+ }
1003
+ /** Deep-equal two flag maps via per-key JSON compare (small maps). */
1004
+ function flagsDeepEqual(a, b) {
1005
+ const aKeys = Object.keys(a).sort();
1006
+ const bKeys = Object.keys(b).sort();
1007
+ if (aKeys.length !== bKeys.length)
1008
+ return false;
1009
+ for (let i = 0; i < aKeys.length; i++) {
1010
+ if (aKeys[i] !== bKeys[i])
1011
+ return false;
1012
+ }
1013
+ for (const k of aKeys) {
1014
+ if (JSON.stringify(a[k]) !== JSON.stringify(b[k]))
1015
+ return false;
1016
+ }
1017
+ return true;
1018
+ }