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.
- package/package.json +1 -1
- package/src/__tests__/alias-adopt.test.ts +364 -0
- package/src/__tests__/alias-parser.test.ts +92 -0
- package/src/__tests__/alias-shell-writer.test.ts +7 -0
- package/src/__tests__/alias-store.test.ts +77 -0
- package/src/__tests__/plugin-setup.test.ts +111 -0
- package/src/data/alias-flags.js +10 -1
- package/src/data/alias-flags.ts +11 -1
- package/src/services/alias-shell-writer.js +262 -8
- package/src/services/alias-shell-writer.ts +382 -8
- package/src/services/alias-store.js +52 -0
- package/src/services/alias-store.ts +60 -0
- package/src/services/plugin-setup.js +59 -4
- package/src/services/plugin-setup.ts +61 -4
- package/src/ui/App.js +16 -7
- package/src/ui/App.tsx +16 -7
- package/src/ui/components/FlagDetailEditor.js +0 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/modals/ConfirmModal.js +1 -1
- package/src/ui/components/modals/ConfirmModal.tsx +1 -1
- package/src/ui/screens/AliasScreen.js +380 -277
- package/src/ui/screens/AliasScreen.tsx +491 -359
- package/src/ui/screens/PluginsScreen.js +4 -1
- package/src/ui/screens/PluginsScreen.tsx +3 -1
- package/src/ui/state/reducer.js +5 -1
- package/src/ui/state/reducer.ts +6 -1
- package/src/ui/state/types.ts +8 -0
|
@@ -16,7 +16,14 @@ import {
|
|
|
16
16
|
getFlagById,
|
|
17
17
|
type AliasFlag,
|
|
18
18
|
} from "../data/alias-flags.js";
|
|
19
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
731
|
-
//
|
|
732
|
-
//
|
|
818
|
+
// Unknown token — record 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("-"))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
if (!(await isBinaryAvailable(bin))) {
|
|
481
|
+
if (await goDepNeedsInstall(pkg)) {
|
|
427
482
|
missingGo.push(pkg);
|
|
428
483
|
}
|
|
429
484
|
}
|