claudeup 4.39.1 → 4.40.1
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/README.md +84 -1
- package/package.json +4 -4
- package/scripts/capture-builtin.ts +412 -0
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/content-drift.test.ts +128 -0
- package/src/__tests__/styles-manager.test.ts +75 -101
- package/src/__tests__/styles-screen-state.test.ts +0 -3
- package/src/__tests__/terminology-filler.test.ts +2 -2
- package/src/data/community-styles.ts +3 -3
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/markdown.d.ts +13 -0
- package/src/services/claude-settings.ts +39 -6
- package/src/services/content-drift.ts +81 -4
- package/src/services/plugin-manager.ts +1 -0
- package/src/services/styles-manager.ts +42 -126
- package/src/ui/screens/StylesScreen.tsx +5 -31
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: evidence-first
|
|
3
|
+
axis: modifier
|
|
4
|
+
summary: Every claim about the system carries the command that proved it and its real output.
|
|
5
|
+
conflicts:
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
### Evidence first
|
|
9
|
+
|
|
10
|
+
- A claim about how the system behaves cites the command that produced it and
|
|
11
|
+
the real output. Paraphrased output is not output.
|
|
12
|
+
- "Done", "fixed", and "working" are claims. Each requires a fresh run pasted
|
|
13
|
+
in full, not a run from before the last edit.
|
|
14
|
+
- A check that cannot fail is not evidence. If a test passes, show that it
|
|
15
|
+
fails without the change — otherwise the passing run proves nothing.
|
|
16
|
+
- Report failures with the same prominence as successes. If two of nine tests
|
|
17
|
+
fail, say so in the first line and paste both failures.
|
|
18
|
+
- Distinguish what was observed from what was inferred. "The function returns
|
|
19
|
+
null here" and "this probably means the cache is cold" are different kinds
|
|
20
|
+
of statement and must be labelled differently.
|
|
21
|
+
- Never report a step as complete if it was skipped, partially applied, or
|
|
22
|
+
could not be verified. Say which, and say why.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: explanatory
|
|
3
|
+
axis: verbosity
|
|
4
|
+
summary: Teach the reasoning alongside the work — for codebases people are still learning.
|
|
5
|
+
conflicts: direct, terse
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
### Explanatory
|
|
9
|
+
|
|
10
|
+
- Give the answer first, then the reasoning. Explanation earns its place by
|
|
11
|
+
following a conclusion, never by delaying one.
|
|
12
|
+
- Explain the *specific* choice, not the general concept. "This uses a map
|
|
13
|
+
because the caller looks up by id in a loop" teaches something; "maps offer
|
|
14
|
+
O(1) lookup" does not.
|
|
15
|
+
- Name the alternative that was rejected and why. A decision without its
|
|
16
|
+
discarded options reads as the only possibility, which is rarely true.
|
|
17
|
+
- When touching an unfamiliar part of the system, state the mechanism you
|
|
18
|
+
relied on and how you confirmed it. That is the difference between a claim
|
|
19
|
+
the reader can check and one they must trust.
|
|
20
|
+
- Explanation is capped by usefulness, not by length. If a paragraph would not
|
|
21
|
+
change what the reader does next, it does not belong.
|
|
22
|
+
- Never explain the same mechanism twice in one session. Reference the earlier
|
|
23
|
+
explanation instead.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The communication style presets, embedded in the binary.
|
|
3
|
+
*
|
|
4
|
+
* These files used to ship in `style@magus` and claudeup read them off disk,
|
|
5
|
+
* which meant the Styles tab was empty for anyone who had not installed that
|
|
6
|
+
* plugin. The plugin was retired at Marketplace 10.0.0 and the presets moved
|
|
7
|
+
* here, so they are always present and always the version this binary was
|
|
8
|
+
* built against.
|
|
9
|
+
*
|
|
10
|
+
* ## Why `.md` files rather than TypeScript string constants
|
|
11
|
+
*
|
|
12
|
+
* The frontmatter format (`name`, `title`, `axis`, `summary`, `conflicts`,
|
|
13
|
+
* `template`) is the contract `styles-manager.ts` parses, and a preset body is
|
|
14
|
+
* long instructional text full of backticks and braces. Kept as markdown it
|
|
15
|
+
* stays diffable and escape-free; `with { type: "text" }` inlines it at build
|
|
16
|
+
* time and `bun build --compile` carries it into the binary (verified against
|
|
17
|
+
* bun 1.3.10).
|
|
18
|
+
*
|
|
19
|
+
* ## Adding a preset
|
|
20
|
+
*
|
|
21
|
+
* Drop the `.md` file in this directory and add both lines below. The list is
|
|
22
|
+
* written out rather than globbed because a glob cannot be resolved at compile
|
|
23
|
+
* time — a missing entry means the preset silently does not ship, which is
|
|
24
|
+
* what `styles-manager.test.ts` counts the directory to catch.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import asdSte100 from "./asd-ste100.md" with { type: "text" };
|
|
28
|
+
import calibrated from "./calibrated.md" with { type: "text" };
|
|
29
|
+
import direct from "./direct.md" with { type: "text" };
|
|
30
|
+
import evidenceFirst from "./evidence-first.md" with { type: "text" };
|
|
31
|
+
import explanatory from "./explanatory.md" with { type: "text" };
|
|
32
|
+
import noSlop from "./no-slop.md" with { type: "text" };
|
|
33
|
+
import plainLanguage from "./plain-language.md" with { type: "text" };
|
|
34
|
+
import structured from "./structured.md" with { type: "text" };
|
|
35
|
+
import terminology from "./terminology.md" with { type: "text" };
|
|
36
|
+
import terse from "./terse.md" with { type: "text" };
|
|
37
|
+
|
|
38
|
+
/** One preset's file name and raw contents, frontmatter included. */
|
|
39
|
+
export interface EmbeddedPreset {
|
|
40
|
+
/** File name as it appears in this directory, e.g. `direct.md`. */
|
|
41
|
+
file: string;
|
|
42
|
+
/** Raw file text — frontmatter and body, exactly as authored. */
|
|
43
|
+
text: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const EMBEDDED_PRESETS: EmbeddedPreset[] = [
|
|
47
|
+
{ file: "asd-ste100.md", text: asdSte100 },
|
|
48
|
+
{ file: "calibrated.md", text: calibrated },
|
|
49
|
+
{ file: "direct.md", text: direct },
|
|
50
|
+
{ file: "evidence-first.md", text: evidenceFirst },
|
|
51
|
+
{ file: "explanatory.md", text: explanatory },
|
|
52
|
+
{ file: "no-slop.md", text: noSlop },
|
|
53
|
+
{ file: "plain-language.md", text: plainLanguage },
|
|
54
|
+
{ file: "structured.md", text: structured },
|
|
55
|
+
{ file: "terminology.md", text: terminology },
|
|
56
|
+
{ file: "terse.md", text: terse },
|
|
57
|
+
];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: no-slop
|
|
3
|
+
axis: modifier
|
|
4
|
+
summary: Banned vocabulary and punctuation tics that mark text as machine-written.
|
|
5
|
+
conflicts:
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
### No slop
|
|
9
|
+
|
|
10
|
+
Banned words and phrases. These are not stylistic preferences — they are the
|
|
11
|
+
tells that make text read as generated, and every one of them has a plainer
|
|
12
|
+
replacement:
|
|
13
|
+
|
|
14
|
+
- **Inflation:** delve, crucial, pivotal, robust, comprehensive, seamless,
|
|
15
|
+
nuanced, multifaceted, intricate, vibrant, landscape, tapestry, realm,
|
|
16
|
+
underscore, foster, showcase, leverage (as a verb), utilize.
|
|
17
|
+
- **Connectives:** furthermore, moreover, additionally, notably, importantly.
|
|
18
|
+
Start the next sentence instead.
|
|
19
|
+
- **Hedged enthusiasm:** "it's worth noting that", "it's important to
|
|
20
|
+
remember", "as we can see", "at the end of the day".
|
|
21
|
+
- **Empty openers:** "In today's fast-paced world", "When it comes to",
|
|
22
|
+
"Whether you're a beginner or an expert".
|
|
23
|
+
- **The not-X-but-Y frame** as a reflex: "It's not just a database, it's a
|
|
24
|
+
platform". Say what it is.
|
|
25
|
+
|
|
26
|
+
Punctuation and shape:
|
|
27
|
+
|
|
28
|
+
- No em dashes. Use a comma, a colon, or a full stop.
|
|
29
|
+
- No rule-of-three lists that pad a two-item point to three.
|
|
30
|
+
- No bold on whole sentences. Bold is for the one word the eye should land on.
|
|
31
|
+
- No emoji in code, commit messages, or technical prose unless the project
|
|
32
|
+
already uses them.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plain-language
|
|
3
|
+
axis: modifier
|
|
4
|
+
summary: Gloss jargon on first use, short sentences, active voice, concrete nouns.
|
|
5
|
+
conflicts: terse
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
### Plain language
|
|
9
|
+
|
|
10
|
+
- Gloss a term of art the first time it appears, in one clause: "idempotent
|
|
11
|
+
(running it twice does the same thing as once)". Once per conversation, not
|
|
12
|
+
once per message.
|
|
13
|
+
- Prefer the concrete noun to the abstraction. "The login page" beats "the
|
|
14
|
+
authentication surface"; "the file will not open" beats "a resource access
|
|
15
|
+
issue".
|
|
16
|
+
- Active voice with a named actor. "The migration drops the column", not "the
|
|
17
|
+
column is dropped".
|
|
18
|
+
- One idea per sentence. If a sentence needs a semicolon to hold together, it
|
|
19
|
+
is two sentences.
|
|
20
|
+
- Close with what it means for the reader: what they will see, wait for, lose,
|
|
21
|
+
or be able to do. A technical fact with no consequence attached is trivia.
|
|
22
|
+
- Expand an acronym on first use unless it is more familiar than its
|
|
23
|
+
expansion — write out "cross-site request forgery", but leave "URL" alone.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: structured
|
|
3
|
+
axis: modifier
|
|
4
|
+
summary: When to use a table, a list, a heading, or a paragraph — and when not to.
|
|
5
|
+
conflicts:
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
### Structure
|
|
9
|
+
|
|
10
|
+
Match the shape to the content. The wrong container is harder to read than
|
|
11
|
+
plain prose:
|
|
12
|
+
|
|
13
|
+
| Content | Shape |
|
|
14
|
+
|---|---|
|
|
15
|
+
| Two or more things compared on the same dimensions | table |
|
|
16
|
+
| Steps in order, where order matters | numbered list |
|
|
17
|
+
| Items with no order and no comparison | bullets |
|
|
18
|
+
| One thing explained | paragraph |
|
|
19
|
+
| Reasoning that connects claims | paragraph, not bullets |
|
|
20
|
+
|
|
21
|
+
- Never bullet a single item. Never build a table with one row or one column.
|
|
22
|
+
- A heading is a promise about what is below it. Do not use headings to break
|
|
23
|
+
up three sentences.
|
|
24
|
+
- Code identifiers, paths, commands, and literal values go in backticks —
|
|
25
|
+
every time, including in tables and headings.
|
|
26
|
+
- Reference code as `path/to/file.ts:42`. The line number makes it clickable.
|
|
27
|
+
- Prose carries reasoning; bullets fragment it. If the points depend on each
|
|
28
|
+
other, write sentences.
|
|
29
|
+
- Length is set by the content. Do not pad a one-line answer into a section,
|
|
30
|
+
and do not compress a real trade-off into a bullet.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: terminology
|
|
3
|
+
axis: modifier
|
|
4
|
+
summary: Project vocabulary — one name per concept, filled in from the codebase during apply.
|
|
5
|
+
conflicts:
|
|
6
|
+
template: true
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
### Terminology
|
|
10
|
+
|
|
11
|
+
One concept, one name, everywhere: code, comments, commit messages, docs, and
|
|
12
|
+
conversation. A concept with two names reads as two concepts.
|
|
13
|
+
|
|
14
|
+
| Use | Not | Because |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| <!-- filled during apply --> | | |
|
|
17
|
+
|
|
18
|
+
Rules that hold regardless of the table above:
|
|
19
|
+
|
|
20
|
+
- Use the domain's word, not the implementation's. If the business calls it a
|
|
21
|
+
"booking", the code and the conversation say booking, even where the table
|
|
22
|
+
is named `reservations`.
|
|
23
|
+
- Do not invent a synonym for a term the codebase already uses. Grep before
|
|
24
|
+
naming anything new.
|
|
25
|
+
- When the code and the domain disagree on a name, say which you are using and
|
|
26
|
+
which the reader will see in the file.
|
|
27
|
+
- Keep abbreviations out of names people say out loud. `usr`, `mgr`, and `cfg`
|
|
28
|
+
save four characters and cost a re-read every time.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: terse
|
|
3
|
+
axis: verbosity
|
|
4
|
+
summary: Minimum viable words. No glosses, no framing, no encouragement.
|
|
5
|
+
conflicts: direct, explanatory
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
### Terse
|
|
9
|
+
|
|
10
|
+
- Answer in the fewest words that stay correct. One line where one line works.
|
|
11
|
+
- No framing sentences, no summaries of what was just said, no offers of
|
|
12
|
+
further help.
|
|
13
|
+
- Prefer a fragment to a sentence, a table to prose, a file path to a
|
|
14
|
+
description of where something lives.
|
|
15
|
+
- Do not explain unless asked. Do not justify a choice unless it is
|
|
16
|
+
surprising.
|
|
17
|
+
- Omit adjectives that carry no information: "simple", "just", "quick",
|
|
18
|
+
"straightforward", "basically".
|
|
19
|
+
- Terse is not curt. Answer what was asked completely — brevity comes from
|
|
20
|
+
cutting padding, never from cutting the answer short.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown imported as text.
|
|
3
|
+
*
|
|
4
|
+
* `import body from "./direct.md" with { type: "text" }` is a Bun bundler
|
|
5
|
+
* feature — the loader inlines the file as a string literal at build time, so
|
|
6
|
+
* it survives `bun build --compile`. TypeScript has no idea, hence this
|
|
7
|
+
* declaration. Without it every preset import in `data/styles/` is an
|
|
8
|
+
* implicit-any error under `strict`.
|
|
9
|
+
*/
|
|
10
|
+
declare module "*.md" {
|
|
11
|
+
const contents: string;
|
|
12
|
+
export default contents;
|
|
13
|
+
}
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
} from "../types/index.js";
|
|
14
14
|
import { parsePluginId } from "../utils/string-utils.js";
|
|
15
15
|
import { inheritablePaths } from "./git-worktree.js";
|
|
16
|
+
import { repoHeadSha } from "./content-drift.js";
|
|
16
17
|
|
|
17
18
|
const CLAUDE_DIR = ".claude";
|
|
18
19
|
const SETTINGS_FILE = "settings.json";
|
|
@@ -1509,19 +1510,23 @@ async function getPluginSourcePath(
|
|
|
1509
1510
|
/**
|
|
1510
1511
|
* Copy plugin files from source to cache
|
|
1511
1512
|
* This ensures the cache is populated with the latest plugin version
|
|
1513
|
+
*
|
|
1514
|
+
* Returns the directory the files were copied FROM, or null if nothing was
|
|
1515
|
+
* copied. The caller needs the source path, not just a success flag: that path
|
|
1516
|
+
* is what identifies the commit the cached files came from.
|
|
1512
1517
|
*/
|
|
1513
1518
|
async function copyPluginToCache(
|
|
1514
1519
|
pluginId: string,
|
|
1515
1520
|
version: string,
|
|
1516
1521
|
marketplace: string,
|
|
1517
|
-
): Promise<
|
|
1522
|
+
): Promise<string | null> {
|
|
1518
1523
|
const { pluginName } = parsePluginId(pluginId) || {
|
|
1519
1524
|
pluginName: pluginId.split("@")[0],
|
|
1520
1525
|
};
|
|
1521
1526
|
|
|
1522
1527
|
const sourcePath = await getPluginSourcePath(pluginName, marketplace);
|
|
1523
1528
|
if (!sourcePath) {
|
|
1524
|
-
return
|
|
1529
|
+
return null;
|
|
1525
1530
|
}
|
|
1526
1531
|
|
|
1527
1532
|
const cachePath = getPluginCachePath(pluginId, version, marketplace);
|
|
@@ -1538,16 +1543,42 @@ async function copyPluginToCache(
|
|
|
1538
1543
|
errorOnExist: false,
|
|
1539
1544
|
});
|
|
1540
1545
|
|
|
1541
|
-
return
|
|
1546
|
+
return sourcePath;
|
|
1542
1547
|
} catch (error) {
|
|
1543
1548
|
console.warn(
|
|
1544
1549
|
`Failed to copy plugin ${pluginId} to cache:`,
|
|
1545
1550
|
error instanceof Error ? error.message : "Unknown error",
|
|
1546
1551
|
);
|
|
1547
|
-
return
|
|
1552
|
+
return null;
|
|
1548
1553
|
}
|
|
1549
1554
|
}
|
|
1550
1555
|
|
|
1556
|
+
/**
|
|
1557
|
+
* The commit a cache copy should be stamped with.
|
|
1558
|
+
*
|
|
1559
|
+
* `gitCommitSha` must describe the files sitting in the cache RIGHT NOW, which
|
|
1560
|
+
* is the single thing content-drift detection reads it for. Two rules:
|
|
1561
|
+
*
|
|
1562
|
+
* - Files were copied → the source repo's HEAD is their provenance. Undefined
|
|
1563
|
+
* is a legitimate answer here: a directory-type marketplace has no commit,
|
|
1564
|
+
* and content-drift stays silent rather than guessing.
|
|
1565
|
+
* - Nothing was copied → the cache is untouched, so whatever sha already
|
|
1566
|
+
* described it still does. Advancing it would claim the cache holds newer
|
|
1567
|
+
* files than it does, hiding real drift.
|
|
1568
|
+
*
|
|
1569
|
+
* The bug this replaces did neither: it preserved the pre-update sha even when
|
|
1570
|
+
* fresh files HAD been copied, so the recorded commit drifted further behind
|
|
1571
|
+
* with every release and drift detection reported a false positive on every
|
|
1572
|
+
* plugin at once.
|
|
1573
|
+
*/
|
|
1574
|
+
export async function resolveCacheProvenance(
|
|
1575
|
+
copiedFrom: string | null,
|
|
1576
|
+
previousSha: string | undefined,
|
|
1577
|
+
): Promise<string | undefined> {
|
|
1578
|
+
if (!copiedFrom) return previousSha;
|
|
1579
|
+
return await repoHeadSha(copiedFrom);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1551
1582
|
/**
|
|
1552
1583
|
* Read installed_plugins.json registry
|
|
1553
1584
|
*/
|
|
@@ -1632,7 +1663,7 @@ export async function updateInstalledPluginsRegistry(
|
|
|
1632
1663
|
|
|
1633
1664
|
// Copy plugin files from source to cache
|
|
1634
1665
|
// This ensures the cache has the latest plugin version
|
|
1635
|
-
await copyPluginToCache(pluginId, version, marketplace);
|
|
1666
|
+
const copiedFrom = await copyPluginToCache(pluginId, version, marketplace);
|
|
1636
1667
|
|
|
1637
1668
|
const installPath = getPluginCachePath(pluginId, version, marketplace);
|
|
1638
1669
|
const now = new Date().toISOString();
|
|
@@ -1659,10 +1690,12 @@ export async function updateInstalledPluginsRegistry(
|
|
|
1659
1690
|
? registry.plugins[pluginId][existingIndex].installedAt
|
|
1660
1691
|
: now,
|
|
1661
1692
|
lastUpdated: now,
|
|
1662
|
-
gitCommitSha:
|
|
1693
|
+
gitCommitSha: await resolveCacheProvenance(
|
|
1694
|
+
copiedFrom,
|
|
1663
1695
|
existingIndex >= 0
|
|
1664
1696
|
? registry.plugins[pluginId][existingIndex].gitCommitSha
|
|
1665
1697
|
: undefined,
|
|
1698
|
+
),
|
|
1666
1699
|
};
|
|
1667
1700
|
|
|
1668
1701
|
if (existingIndex >= 0) {
|
|
@@ -67,12 +67,33 @@ function git(cwd: string, args: string[]): Promise<{ code: number; out: string }
|
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* HEAD of the git repo containing `dir`, or undefined when there is none.
|
|
72
|
+
*
|
|
73
|
+
* This is what stamps provenance onto a cache copy: `copyPluginToCache` reads
|
|
74
|
+
* the plugin's files out of the marketplace clone, so that clone's HEAD is the
|
|
75
|
+
* commit those files came from. Undefined is a real answer — a directory-type
|
|
76
|
+
* marketplace has no commit to name, and inventing one would assert a
|
|
77
|
+
* provenance that is false.
|
|
78
|
+
*/
|
|
79
|
+
export async function repoHeadSha(dir: string): Promise<string | undefined> {
|
|
80
|
+
const r = await git(dir, ["rev-parse", "HEAD"]);
|
|
81
|
+
if (r.code !== 0) return undefined;
|
|
82
|
+
return /^[0-9a-f]{40}$/.test(r.out) ? r.out : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
export interface DriftQuery {
|
|
71
86
|
marketplace: string;
|
|
72
87
|
/** Plugin name without the @marketplace suffix. */
|
|
73
88
|
pluginName: string;
|
|
74
89
|
/** `gitCommitSha` from the installed_plugins.json entry. */
|
|
75
90
|
installedSha: string | undefined;
|
|
91
|
+
/**
|
|
92
|
+
* Version currently installed. Required, not optional: the sha alone cannot
|
|
93
|
+
* be trusted, and a caller that omitted this would silently get the old
|
|
94
|
+
* always-drifted behaviour back.
|
|
95
|
+
*/
|
|
96
|
+
installedVersion: string | undefined;
|
|
76
97
|
/**
|
|
77
98
|
* Path of the plugin inside the marketplace repo, from the catalog `source`
|
|
78
99
|
* field (e.g. "./plugins/dev"). Defaults to `plugins/<name>`.
|
|
@@ -80,19 +101,52 @@ export interface DriftQuery {
|
|
|
80
101
|
sourcePath?: string;
|
|
81
102
|
}
|
|
82
103
|
|
|
104
|
+
/** Strip a leading "v" so "v1.2.3" and "1.2.3" compare equal. */
|
|
105
|
+
const normalize = (v: string) => v.trim().replace(/^v/, "");
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The version a plugin's manifest declared at a given commit.
|
|
109
|
+
*
|
|
110
|
+
* Both layouts are tried because both are real: magus lays plugins out as
|
|
111
|
+
* `plugins/<name>/plugin.json`, while Claude Code documents
|
|
112
|
+
* `.claude-plugin/plugin.json`. Reading only one would silence drift detection
|
|
113
|
+
* for every plugin using the other.
|
|
114
|
+
*/
|
|
115
|
+
async function versionAtSha(
|
|
116
|
+
repo: string,
|
|
117
|
+
sha: string,
|
|
118
|
+
rel: string,
|
|
119
|
+
): Promise<string | undefined> {
|
|
120
|
+
for (const manifest of [
|
|
121
|
+
`${rel}/plugin.json`,
|
|
122
|
+
`${rel}/.claude-plugin/plugin.json`,
|
|
123
|
+
]) {
|
|
124
|
+
const r = await git(repo, ["show", `${sha}:${manifest}`]);
|
|
125
|
+
if (r.code !== 0 || !r.out) continue;
|
|
126
|
+
try {
|
|
127
|
+
const v = JSON.parse(r.out)?.version;
|
|
128
|
+
if (typeof v === "string" && v) return v;
|
|
129
|
+
} catch {
|
|
130
|
+
// Unparseable manifest is the same as no manifest: unverifiable.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
83
136
|
/**
|
|
84
137
|
* True when the plugin's files in the marketplace differ from the commit it was
|
|
85
138
|
* installed from — i.e. a reinstall would deliver different content.
|
|
86
139
|
*
|
|
87
140
|
* Returns false whenever the question cannot be answered honestly: no recorded
|
|
88
|
-
* sha, no clone, git unavailable,
|
|
89
|
-
* (force-push, shallow clone)
|
|
90
|
-
*
|
|
141
|
+
* sha, no clone, git unavailable, the recorded commit is absent locally
|
|
142
|
+
* (force-push, shallow clone), or that commit declares a DIFFERENT version than
|
|
143
|
+
* the one installed. A false negative leaves today's behaviour; a false
|
|
144
|
+
* positive would nag the user to reinstall for no reason.
|
|
91
145
|
*/
|
|
92
146
|
export async function hasContentDrift(q: DriftQuery): Promise<boolean> {
|
|
93
147
|
if (!q.installedSha) return false;
|
|
94
148
|
|
|
95
|
-
const key = `${q.marketplace}\0${q.pluginName}\0${q.installedSha}`;
|
|
149
|
+
const key = `${q.marketplace}\0${q.pluginName}\0${q.installedSha}\0${q.installedVersion}`;
|
|
96
150
|
const hit = cache.get(key);
|
|
97
151
|
if (hit !== undefined) return hit;
|
|
98
152
|
|
|
@@ -106,6 +160,29 @@ export async function hasContentDrift(q: DriftQuery): Promise<boolean> {
|
|
|
106
160
|
return false;
|
|
107
161
|
}
|
|
108
162
|
|
|
163
|
+
// The recorded sha must describe the version actually installed, or the diff
|
|
164
|
+
// answers a different question than the one asked.
|
|
165
|
+
//
|
|
166
|
+
// This is the whole reason the badge fired on every plugin at once:
|
|
167
|
+
// `updateInstalledPluginsRegistry` bumps `version` on an in-place update but
|
|
168
|
+
// carries the pre-update `gitCommitSha` forward, so the sha names whichever
|
|
169
|
+
// commit the plugin was FIRST installed from. Measured 2026-08-22 on one
|
|
170
|
+
// machine: dev recorded 4.3.0 against a sha holding 3.3.0, browser-use 1.4.1
|
|
171
|
+
// against 1.1.3. Diffing across those releases is trivially non-empty, so
|
|
172
|
+
// every plugin rendered "stale — reinstall" while its cached files were in
|
|
173
|
+
// fact byte-identical to the marketplace.
|
|
174
|
+
const shaVersion = q.installedVersion
|
|
175
|
+
? await versionAtSha(repo, q.installedSha, rel)
|
|
176
|
+
: undefined;
|
|
177
|
+
if (
|
|
178
|
+
!q.installedVersion ||
|
|
179
|
+
!shaVersion ||
|
|
180
|
+
normalize(shaVersion) !== normalize(q.installedVersion)
|
|
181
|
+
) {
|
|
182
|
+
cache.set(key, false);
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
109
186
|
// Exit 0 = no difference, 1 = differs, anything else = could not tell.
|
|
110
187
|
const diff = await git(repo, [
|
|
111
188
|
"diff",
|