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
|
@@ -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
|
// ---------------------------------------------------------------------------
|
package/src/data/alias-flags.js
CHANGED
|
@@ -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
|
|
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
|
{
|
package/src/data/alias-flags.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
616
|
-
//
|
|
617
|
-
//
|
|
656
|
+
// Unknown token — record 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
|
-
|
|
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
|
+
}
|