magus-cli 7.12.2 → 7.14.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/README.md +21 -0
- package/package.json +4 -4
- package/src/__tests__/market-bases.test.ts +112 -0
- package/src/__tests__/market-config.test.ts +84 -0
- package/src/__tests__/market-conflicts.test.ts +175 -0
- package/src/__tests__/market-consent.test.ts +185 -0
- package/src/__tests__/market-content.test.ts +183 -0
- package/src/__tests__/market-delivery.test.ts +308 -0
- package/src/__tests__/market-fixture.ts +1167 -0
- package/src/__tests__/market-git.test.ts +130 -0
- package/src/__tests__/market-identity.test.ts +127 -0
- package/src/__tests__/market-install.test.ts +143 -0
- package/src/__tests__/market-layout.test.ts +206 -0
- package/src/__tests__/market-publish.test.ts +263 -0
- package/src/__tests__/market-r2-adapters.test.ts +190 -0
- package/src/__tests__/market-r2-company-cli.test.ts +350 -0
- package/src/__tests__/market-r2-company-unit.test.ts +224 -0
- package/src/__tests__/market-r2-local-items.test.ts +197 -0
- package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
- package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
- package/src/__tests__/market-r2-routing.test.ts +127 -0
- package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
- package/src/__tests__/market-r2b-display-path.test.ts +180 -0
- package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
- package/src/__tests__/market-r2b-local-items.test.ts +253 -0
- package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
- package/src/__tests__/market-readonly.test.ts +162 -0
- package/src/__tests__/market-repositories.test.ts +263 -0
- package/src/__tests__/market-results.test.ts +198 -0
- package/src/__tests__/market-router.test.ts +65 -0
- package/src/__tests__/market-safety.test.ts +357 -0
- package/src/__tests__/market-teammates.test.ts +304 -0
- package/src/cli/install.ts +64 -5
- package/src/cli/market-view.ts +329 -0
- package/src/cli/market.ts +664 -0
- package/src/cli/router.ts +69 -3
- package/src/cli/update-view.ts +56 -0
- package/src/cli/update.ts +111 -9
- package/src/data/marketplaces.ts +8 -2
- package/src/main.tsx +20 -2
- package/src/market-tui.tsx +21 -0
- package/src/services/active-closure.ts +71 -9
- package/src/services/claude-cli.ts +140 -10
- package/src/services/claude-settings.ts +19 -25
- package/src/services/doctor.ts +5 -5
- package/src/services/git-write.ts +615 -0
- package/src/services/github-cli.ts +309 -0
- package/src/services/local-marketplace.ts +112 -50
- package/src/services/market-bases.ts +191 -0
- package/src/services/market-change.ts +242 -0
- package/src/services/market-clone.ts +572 -0
- package/src/services/market-fs.ts +138 -0
- package/src/services/market-install.ts +172 -0
- package/src/services/market-layout.ts +311 -0
- package/src/services/market-local.ts +93 -0
- package/src/services/market-marker.ts +49 -0
- package/src/services/market-ops-content.ts +277 -0
- package/src/services/market-ops-plugin.ts +655 -0
- package/src/services/market-ops-publish.ts +1227 -0
- package/src/services/market-ops-repo.ts +916 -0
- package/src/services/market-ops-skill.ts +788 -0
- package/src/services/market-ops.ts +686 -0
- package/src/services/market-publish.ts +468 -0
- package/src/services/market-registry.ts +382 -0
- package/src/services/market-types.ts +268 -0
- package/src/services/marketplace-conflicts.ts +127 -0
- package/src/services/marketplace-refresh.ts +33 -3
- package/src/services/marketplace-sync.ts +253 -22
- package/src/services/plugin-dependencies.ts +17 -0
- package/src/services/plugin-manager.ts +1 -1
- package/src/services/plugin-mcp-config.ts +6 -9
- package/src/services/plugin-requires.ts +20 -8
- package/src/services/profile-absorb.ts +88 -3
- package/src/services/profile-adopt.ts +10 -1
- package/src/services/profiles.ts +64 -1
- package/src/services/resolver.ts +17 -5
- package/src/services/settings-manager.ts +2 -2
- package/src/services/skills-manager.ts +2 -2
- package/src/services/update-plan.ts +67 -8
- package/src/tui-runtime.tsx +142 -0
- package/src/tui.tsx +7 -124
- package/src/types/index.ts +7 -0
- package/src/ui/adapters/marketAdapter.ts +366 -0
- package/src/ui/adapters/pluginsAdapter.ts +45 -3
- package/src/ui/components/layout/ScreenLayout.tsx +43 -17
- package/src/ui/components/modals/ConfirmModal.tsx +145 -8
- package/src/ui/components/modals/HelpOverlay.tsx +10 -3
- package/src/ui/components/modals/ModalContainer.tsx +47 -5
- package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
- package/src/ui/components/primitives/ScopeBadge.tsx +28 -0
- package/src/ui/components/primitives/ScopeVersionLine.tsx +27 -0
- package/src/ui/components/primitives/SelectableRow.tsx +14 -1
- package/src/ui/components/primitives/ToneLine.tsx +25 -0
- package/src/ui/components/primitives/index.ts +5 -0
- package/src/ui/keymap.ts +21 -5
- package/src/ui/market/MarketApp.tsx +59 -0
- package/src/ui/market/MarketplaceScreen.tsx +280 -0
- package/src/ui/market/MarketplacesDetail.snowflake.tsx +161 -0
- package/src/ui/market/MarketplacesScreen.tsx +264 -0
- package/src/ui/market/PluginDetail.snowflake.tsx +61 -0
- package/src/ui/market/SkillDetail.snowflake.tsx +71 -0
- package/src/ui/market/marketKeymap.ts +243 -0
- package/src/ui/market/useMarketState.ts +694 -0
- package/src/ui/renderers/pluginRenderers.tsx +31 -3
- package/src/ui/renderers/pluginStatusText.ts +6 -0
- package/src/ui/screens/PluginsScreen.tsx +49 -5
- package/src/ui/screens/SkillsScreen.tsx +2 -2
- package/src/ui/state/AppContext.tsx +14 -2
- package/src/ui/state/types.ts +9 -2
- package/src/utils/config-dir.ts +29 -9
- package/src/utils/display-path.ts +33 -0
package/README.md
CHANGED
|
@@ -146,6 +146,27 @@ or `magus profile init`.
|
|
|
146
146
|
`magus doctor` exits non-zero when it finds a problem, so it works as a CI
|
|
147
147
|
check. So does `magus install --check`.
|
|
148
148
|
|
|
149
|
+
## Company marketplaces
|
|
150
|
+
|
|
151
|
+
A company marketplace is your team's own Claude Code plugin marketplace on GitHub.
|
|
152
|
+
magus lists it first, shown as "by" its company name, and `magus edit` (its screens) or
|
|
153
|
+
the `magus market` commands manage it: create or connect one, add
|
|
154
|
+
plugins and skills (a skill of your own, or a plugin folder of your own with
|
|
155
|
+
`market plugin add`), copy a skill here with `market skill edit`, and send the edits
|
|
156
|
+
back with `market publish` as the plugin's next version, one skill or every edited
|
|
157
|
+
skill of a plugin at once. Every change prints its
|
|
158
|
+
plan and asks first; `--yes=<planId>` applies exactly that plan in a script.
|
|
159
|
+
Teammates get the marketplace and every new version through `magus update` with
|
|
160
|
+
the project's profile.
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
magus edit Open the company marketplace screens
|
|
164
|
+
magus market --help Every market command, its flags and exit codes
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The [company marketplaces guide](../../userdocs/guides/company-marketplace.md)
|
|
168
|
+
walks through it end to end, including conflicts and protected branches.
|
|
169
|
+
|
|
149
170
|
## What the TUI covers
|
|
150
171
|
|
|
151
172
|
| Screen | What it manages |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.14.0",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
"typescript": "^5.6.3"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"magus-cli-darwin-arm64": "7.
|
|
69
|
-
"magus-cli-darwin-x64": "7.
|
|
70
|
-
"magus-cli-linux-x64": "7.
|
|
68
|
+
"magus-cli-darwin-arm64": "7.14.0",
|
|
69
|
+
"magus-cli-darwin-x64": "7.14.0",
|
|
70
|
+
"magus-cli-linux-x64": "7.14.0"
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, readFileSync, rmSync, symlinkSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { MarketFixture, sourceModule, write } from "./market-fixture.js";
|
|
5
|
+
|
|
6
|
+
let f: MarketFixture | undefined;
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
f?.close();
|
|
9
|
+
f = undefined;
|
|
10
|
+
});
|
|
11
|
+
const base = {
|
|
12
|
+
marketplace: "acme",
|
|
13
|
+
repo: "acme/market",
|
|
14
|
+
plugin: "review-kit",
|
|
15
|
+
skill: "review",
|
|
16
|
+
commit: "a".repeat(40),
|
|
17
|
+
tree: "tree-a",
|
|
18
|
+
};
|
|
19
|
+
function useBases(body: string) {
|
|
20
|
+
return `const b = await import(${JSON.stringify(sourceModule("services/market-bases.js"))}); const folder = ${JSON.stringify(f!.folder())}; ${body}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("TEST-13 REQ-29 a missing/mismatched token cannot attach a base by path", () => {
|
|
24
|
+
f = new MarketFixture(true);
|
|
25
|
+
mkdirSync(f.folder(), { recursive: true });
|
|
26
|
+
const record = f.value<Record<string, string>>(
|
|
27
|
+
useBases(
|
|
28
|
+
`console.log(JSON.stringify(b.recordBase(folder, ${JSON.stringify(base)})));`,
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
expect(readFileSync(join(f.folder(), ".magus-origin"), "utf8").trim()).toBe(
|
|
32
|
+
record.token,
|
|
33
|
+
);
|
|
34
|
+
expect(
|
|
35
|
+
f.value(useBases("console.log(JSON.stringify(b.readBase(folder)));")),
|
|
36
|
+
).toEqual(record);
|
|
37
|
+
|
|
38
|
+
rmSync(join(f.folder(), ".magus-origin"));
|
|
39
|
+
expect(
|
|
40
|
+
f.value(useBases("console.log(JSON.stringify(b.readBase(folder)));")),
|
|
41
|
+
).toBeNull();
|
|
42
|
+
write(join(f.folder(), ".magus-origin"), "wrong-token\n");
|
|
43
|
+
expect(
|
|
44
|
+
f.value(useBases("console.log(JSON.stringify(b.readBase(folder)));")),
|
|
45
|
+
).toBeNull();
|
|
46
|
+
write(join(f.folder(), ".magus-origin"), `${record.token}\n`);
|
|
47
|
+
expect(
|
|
48
|
+
f.value(useBases("console.log(JSON.stringify(b.readBase(folder)));")),
|
|
49
|
+
).toEqual(record);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("TEST-13 REQ-28 REQ-29 rotating a token invalidates restored old content", () => {
|
|
53
|
+
f = new MarketFixture(true);
|
|
54
|
+
mkdirSync(f.folder(), { recursive: true });
|
|
55
|
+
const result = f.value<{
|
|
56
|
+
a: { token: string };
|
|
57
|
+
b: { token: string };
|
|
58
|
+
current: { token: string };
|
|
59
|
+
}>(
|
|
60
|
+
useBases(
|
|
61
|
+
`const a = b.recordBase(folder, ${JSON.stringify(base)}); const newer = b.recordBase(folder, ${JSON.stringify({ ...base, commit: "b".repeat(40) })}); console.log(JSON.stringify({a, b:newer, current:b.readBase(folder)}));`,
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
expect(typeof result.a.token).toBe("string");
|
|
65
|
+
expect(result.b.token).not.toBe(result.a.token);
|
|
66
|
+
expect(result.current.token).toBe(result.b.token);
|
|
67
|
+
write(join(f.folder(), ".magus-origin"), `${result.a.token}\n`);
|
|
68
|
+
const before = f.snapshot();
|
|
69
|
+
|
|
70
|
+
expect(
|
|
71
|
+
f.value(useBases("console.log(JSON.stringify(b.readBase(folder)));")),
|
|
72
|
+
).toBeNull();
|
|
73
|
+
expect(f.snapshot()).toEqual(before);
|
|
74
|
+
expect(
|
|
75
|
+
f.value(
|
|
76
|
+
useBases(
|
|
77
|
+
"console.log(JSON.stringify([b.forgetBase(folder), b.forgetBase(folder)]));",
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
).toEqual([true, false]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("TEST-13 REQ-29 selective forgetting leaves other copies and markets intact", () => {
|
|
84
|
+
f = new MarketFixture();
|
|
85
|
+
for (const name of ["review", "other", "elsewhere"])
|
|
86
|
+
mkdirSync(f.folder("project", name), { recursive: true });
|
|
87
|
+
const script = useBases(
|
|
88
|
+
`b.recordBase(folder, ${JSON.stringify(base)});b.recordBase(${JSON.stringify(f.folder("project", "other"))}, ${JSON.stringify({ ...base, skill: "other" })});b.recordBase(${JSON.stringify(f.folder("project", "elsewhere"))}, ${JSON.stringify({ ...base, marketplace: "beta" })});console.log(JSON.stringify({ one:b.forgetBasesFor('acme', {plugin:'review-kit',skill:'review'}), remaining:b.readBase(${JSON.stringify(f.folder("project", "other"))}), all:b.forgetBasesFor('acme'), beta:b.readBase(${JSON.stringify(f.folder("project", "elsewhere"))}) }));`,
|
|
89
|
+
);
|
|
90
|
+
const result = f.value<{
|
|
91
|
+
one: number;
|
|
92
|
+
all: number;
|
|
93
|
+
remaining: unknown;
|
|
94
|
+
beta: unknown;
|
|
95
|
+
}>(script);
|
|
96
|
+
expect(result.one).toBe(1);
|
|
97
|
+
expect(result.remaining).not.toBeNull();
|
|
98
|
+
expect(result.all).toBe(1);
|
|
99
|
+
expect(result.beta).not.toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("TEST-13 REQ-29 folder keys canonicalise the parent, not a recreated skill", () => {
|
|
103
|
+
f = new MarketFixture();
|
|
104
|
+
const parent = join(f.root, "real-parent");
|
|
105
|
+
mkdirSync(parent);
|
|
106
|
+
const alias = join(f.root, "alias-parent");
|
|
107
|
+
symlinkSync(parent, alias);
|
|
108
|
+
const got = f.value(
|
|
109
|
+
`const b = await import(${JSON.stringify(sourceModule("services/market-bases.js"))}); console.log(JSON.stringify(b.folderKey(${JSON.stringify(join(alias, "review"))})));`,
|
|
110
|
+
);
|
|
111
|
+
expect(got).toBe(join(parent, "review"));
|
|
112
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
EDITED,
|
|
6
|
+
ID,
|
|
7
|
+
MarketFixture,
|
|
8
|
+
SKILL_REL,
|
|
9
|
+
sourceModule,
|
|
10
|
+
write,
|
|
11
|
+
writeJson,
|
|
12
|
+
} from "./market-fixture.js";
|
|
13
|
+
|
|
14
|
+
let f: MarketFixture | undefined;
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
f?.close();
|
|
17
|
+
f = undefined;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("TEST-35 REQ-28 USER installation, global skills and read-back follow CLAUDE_CONFIG_DIR outside HOME", () => {
|
|
21
|
+
f = new MarketFixture(true);
|
|
22
|
+
const repo = f.seed();
|
|
23
|
+
f.register(repo);
|
|
24
|
+
f.install();
|
|
25
|
+
f.edit(["--to", "global"]);
|
|
26
|
+
write(join(f.folder("global"), "SKILL.md"), EDITED);
|
|
27
|
+
const decoy = join(f.home, ".claude");
|
|
28
|
+
writeJson(join(decoy, "settings.json"), {
|
|
29
|
+
enabledPlugins: { [ID]: false },
|
|
30
|
+
installedPluginVersions: { [ID]: "99.0.0" },
|
|
31
|
+
});
|
|
32
|
+
write(
|
|
33
|
+
join(decoy, "skills", "review", "SKILL.md"),
|
|
34
|
+
"Wrong HOME skill; never publish this\n",
|
|
35
|
+
);
|
|
36
|
+
const beforeSettings = readFileSync(join(decoy, "settings.json"), "utf8");
|
|
37
|
+
f.rememberOwned();
|
|
38
|
+
|
|
39
|
+
const r = f.market([
|
|
40
|
+
"publish",
|
|
41
|
+
"review",
|
|
42
|
+
"--plugin",
|
|
43
|
+
ID,
|
|
44
|
+
"--from",
|
|
45
|
+
"global",
|
|
46
|
+
"--yes",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
expect(r.code).toBe(0);
|
|
50
|
+
expect(r.result.installs?.find((row) => row.scope === "user")?.to).toBe(
|
|
51
|
+
"0.3.2",
|
|
52
|
+
);
|
|
53
|
+
expect(f.version()).toBe("0.3.2");
|
|
54
|
+
expect(f.remoteFile(repo, SKILL_REL)).toBe(EDITED.trimEnd());
|
|
55
|
+
expect(
|
|
56
|
+
readFileSync(join(decoy, "skills", "review", "SKILL.md"), "utf8"),
|
|
57
|
+
).toBe("Wrong HOME skill; never publish this\n");
|
|
58
|
+
expect(readFileSync(join(decoy, "settings.json"), "utf8")).toBe(
|
|
59
|
+
beforeSettings,
|
|
60
|
+
);
|
|
61
|
+
f.assertOwned();
|
|
62
|
+
}, 60_000);
|
|
63
|
+
|
|
64
|
+
test("TEST-35 REQ-28 a registry row without that scope's activation is not an install", () => {
|
|
65
|
+
f = new MarketFixture(true);
|
|
66
|
+
const repo = f.seed();
|
|
67
|
+
f.register(repo);
|
|
68
|
+
f.install();
|
|
69
|
+
writeJson(join(f.config, "settings.json"), {
|
|
70
|
+
enabledPlugins: { [ID]: false },
|
|
71
|
+
});
|
|
72
|
+
writeJson(join(f.home, ".claude", "settings.json"), {
|
|
73
|
+
enabledPlugins: { [ID]: true },
|
|
74
|
+
});
|
|
75
|
+
f.rememberOwned();
|
|
76
|
+
const before = f.snapshot();
|
|
77
|
+
|
|
78
|
+
const installed = f.value(
|
|
79
|
+
`const m=await import(${JSON.stringify(sourceModule("services/market-publish.js"))}); console.log(JSON.stringify({version:await m.installedVersionAt(${JSON.stringify(ID)},'user',${JSON.stringify(f.project)}), scopes:await m.installedScopes(${JSON.stringify(ID)},${JSON.stringify(f.project)})}));`,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(installed).toEqual({ version: null, scopes: [] });
|
|
83
|
+
expect(f.snapshot()).toEqual(before);
|
|
84
|
+
}, 30_000);
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
fromConflicted,
|
|
5
|
+
leftOutWhy,
|
|
6
|
+
} from "../services/marketplace-conflicts.js";
|
|
7
|
+
import type { PluginInfo } from "../services/plugin-manager.js";
|
|
8
|
+
import {
|
|
9
|
+
classifyPluginRows,
|
|
10
|
+
planPluginUpdates,
|
|
11
|
+
} from "../services/update-plan.js";
|
|
12
|
+
import {
|
|
13
|
+
ID,
|
|
14
|
+
MarketFixture,
|
|
15
|
+
PLUGIN_MANIFEST,
|
|
16
|
+
readJson,
|
|
17
|
+
sourceModule,
|
|
18
|
+
writeJson,
|
|
19
|
+
} from "./market-fixture.js";
|
|
20
|
+
|
|
21
|
+
function info(id: string, extra: Partial<PluginInfo> = {}): PluginInfo {
|
|
22
|
+
return {
|
|
23
|
+
id,
|
|
24
|
+
name: id.split("@")[0]!,
|
|
25
|
+
version: "0.3.2",
|
|
26
|
+
description: "",
|
|
27
|
+
marketplace: id.split("@")[1]!,
|
|
28
|
+
marketplaceDisplay: "",
|
|
29
|
+
...extra,
|
|
30
|
+
} as PluginInfo;
|
|
31
|
+
}
|
|
32
|
+
test("TEST-38 REQ-34 a conflict predicate matches the marketplace suffix exactly", () => {
|
|
33
|
+
const names = new Set(["acme"]);
|
|
34
|
+
expect(fromConflicted("kit@acme", names)).toBe(true);
|
|
35
|
+
expect(fromConflicted("acme@safe", names)).toBe(false);
|
|
36
|
+
expect(fromConflicted("kit@not-acme", names)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test.each(["profile", "installed", "other-profile"] as const)(
|
|
40
|
+
"TEST-38 REQ-34 leaves out a conflicted plugin from the %s candidate source",
|
|
41
|
+
(source) => {
|
|
42
|
+
const bad = info(
|
|
43
|
+
ID,
|
|
44
|
+
source === "other-profile"
|
|
45
|
+
? { isOrphaned: true }
|
|
46
|
+
: source === "installed"
|
|
47
|
+
? { projectScope: { installed: true, version: "0.3.1" } }
|
|
48
|
+
: {},
|
|
49
|
+
);
|
|
50
|
+
const safe = info("safe-kit@safe", {
|
|
51
|
+
projectScope: { installed: true, version: "0.3.1" },
|
|
52
|
+
});
|
|
53
|
+
const catalog = [bad, safe, info("listed-kit@acme")];
|
|
54
|
+
const pins = source === "profile" ? { [ID]: "latest" } : {};
|
|
55
|
+
const otherProfiles = source === "other-profile" ? [ID] : [];
|
|
56
|
+
expect(
|
|
57
|
+
planPluginUpdates(pins, catalog, {}, otherProfiles).some(
|
|
58
|
+
(item) => item.pluginId === ID,
|
|
59
|
+
),
|
|
60
|
+
).toBe(true);
|
|
61
|
+
|
|
62
|
+
const plan = planPluginUpdates(pins, catalog, {}, otherProfiles, (id) =>
|
|
63
|
+
fromConflicted(id, new Set(["acme"])),
|
|
64
|
+
);
|
|
65
|
+
expect(plan.map((item) => item.pluginId)).toEqual(["safe-kit@safe"]);
|
|
66
|
+
expect(plan[0]?.action).toBe("update");
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
test("TEST-38 REQ-34 a still-loaded conflicted requirer protects its deprecated dependency from removal", () => {
|
|
71
|
+
const bad = info(ID, { projectScope: { installed: true, version: "0.3.1" } });
|
|
72
|
+
const dependency = info("lib@safe", {
|
|
73
|
+
isOrphaned: true,
|
|
74
|
+
projectScope: { installed: true, version: "0.1.0" },
|
|
75
|
+
});
|
|
76
|
+
const catalog = [bad, dependency, info("other@safe")];
|
|
77
|
+
|
|
78
|
+
const plan = planPluginUpdates({}, catalog, { "lib@safe": [ID] }, [], (id) =>
|
|
79
|
+
fromConflicted(id, new Set(["acme"])),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(plan.some((item) => item.pluginId === ID)).toBe(false);
|
|
83
|
+
expect(plan.find((item) => item.pluginId === "lib@safe")?.action).toBe(
|
|
84
|
+
"unknown",
|
|
85
|
+
);
|
|
86
|
+
expect(plan.find((item) => item.pluginId === "lib@safe")?.reason).toBe(
|
|
87
|
+
"still-required",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test.each(["conflicted", "unchecked"] as const)(
|
|
92
|
+
"TEST-38 REQ-34 %s display rows say why without creating executable work",
|
|
93
|
+
(kind) => {
|
|
94
|
+
const check = {
|
|
95
|
+
conflicted: new Set(kind === "conflicted" ? ["acme"] : []),
|
|
96
|
+
unchecked: new Set(kind === "unchecked" ? ["acme"] : []),
|
|
97
|
+
};
|
|
98
|
+
const why = leftOutWhy(ID, check);
|
|
99
|
+
expect(why).not.toBeNull();
|
|
100
|
+
expect(leftOutWhy("safe-kit@safe", check)).toBeNull();
|
|
101
|
+
|
|
102
|
+
const rows = classifyPluginRows(
|
|
103
|
+
[info(ID)],
|
|
104
|
+
{ [ID]: "latest" },
|
|
105
|
+
{},
|
|
106
|
+
[],
|
|
107
|
+
(id) => leftOutWhy(id, check),
|
|
108
|
+
);
|
|
109
|
+
const row = rows.get(ID);
|
|
110
|
+
expect(row?.action).toBe("unknown");
|
|
111
|
+
expect(row?.reason).toBe("marketplace-conflict");
|
|
112
|
+
expect(row?.target).toBeNull();
|
|
113
|
+
expect(row?.scopes).toEqual([]);
|
|
114
|
+
expect(row?.note).toBe(why!);
|
|
115
|
+
if (kind === "unchecked") expect(why).toMatch(/could not check/i);
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
let f: MarketFixture | undefined;
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
f?.close();
|
|
122
|
+
f = undefined;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("TEST-39 REQ-34 an active closure excludes conflicted plugin dependencies and its tool requirements", () => {
|
|
126
|
+
f = new MarketFixture();
|
|
127
|
+
const repo = f.seed({
|
|
128
|
+
slug: "different/market",
|
|
129
|
+
extras: {
|
|
130
|
+
[PLUGIN_MANIFEST]: JSON.stringify({
|
|
131
|
+
name: "review-kit",
|
|
132
|
+
version: "0.3.1",
|
|
133
|
+
dependencies: ["wrong-dependency"],
|
|
134
|
+
requires: {
|
|
135
|
+
schema: 2,
|
|
136
|
+
bin: [
|
|
137
|
+
{
|
|
138
|
+
name: "unsafe-company-tool",
|
|
139
|
+
check: ["unsafe-company-tool", "--version"],
|
|
140
|
+
install: [{ via: "brew", formula: "unsafe-company-tool" }],
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
f.register(repo);
|
|
148
|
+
f.profile(
|
|
149
|
+
{ [ID]: "latest" },
|
|
150
|
+
{ acme: { source: "github", repo: "expected/market" } },
|
|
151
|
+
);
|
|
152
|
+
const profileFile = join(f.project, ".claude", "profiles.json");
|
|
153
|
+
const manifest = readJson(profileFile);
|
|
154
|
+
manifest.profiles.team.cliTools = { claudish: "latest" };
|
|
155
|
+
writeJson(profileFile, manifest);
|
|
156
|
+
f.active();
|
|
157
|
+
const before = f.snapshot();
|
|
158
|
+
|
|
159
|
+
const closure = f.value<{
|
|
160
|
+
closure: { plugins: Record<string, string>; bins: Array<{ name: string }> };
|
|
161
|
+
conflicted: string[];
|
|
162
|
+
}>(
|
|
163
|
+
`const a=await import(${JSON.stringify(sourceModule("services/active-closure.js"))}); const x=await a.loadActiveClosure(${JSON.stringify(f.project)}); console.log(JSON.stringify(x && {closure:x.closure,conflicted:[...x.marketplaces.conflicted]}));`,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(closure.conflicted).toEqual(["acme"]);
|
|
167
|
+
expect(closure.closure.plugins["wrong-dependency@acme"]).toBeUndefined();
|
|
168
|
+
expect(
|
|
169
|
+
closure.closure.bins.some((bin) => bin.name === "unsafe-company-tool"),
|
|
170
|
+
).toBe(false);
|
|
171
|
+
expect(closure.closure.bins.some((bin) => bin.name === "claudish")).toBe(
|
|
172
|
+
true,
|
|
173
|
+
);
|
|
174
|
+
expect(f.snapshot()).toEqual(before);
|
|
175
|
+
}, 30_000);
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
EDITED,
|
|
6
|
+
ID,
|
|
7
|
+
MarketFixture,
|
|
8
|
+
SKILL_REL,
|
|
9
|
+
expectPublished,
|
|
10
|
+
write,
|
|
11
|
+
} from "./market-fixture.js";
|
|
12
|
+
|
|
13
|
+
let f: MarketFixture | undefined;
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
f?.close();
|
|
16
|
+
f = undefined;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("TEST-34 REQ-25 an explicit skill-edit target does not consult an unreachable other company", () => {
|
|
20
|
+
f = new MarketFixture();
|
|
21
|
+
const a = f.seed();
|
|
22
|
+
const b = f.seed({ name: "beta", slug: "beta/market" });
|
|
23
|
+
f.register(a);
|
|
24
|
+
f.register(b);
|
|
25
|
+
rmSync(b.bare, { recursive: true, force: true });
|
|
26
|
+
|
|
27
|
+
const r = f.market(["skill", "edit", "review", "--plugin", ID, "--yes"]);
|
|
28
|
+
|
|
29
|
+
expect(r.code).toBe(0);
|
|
30
|
+
expect(r.result.localCopy?.action).toBe("created");
|
|
31
|
+
expect(existsSync(join(f.folder(), "SKILL.md"))).toBe(true);
|
|
32
|
+
f.assertOwned();
|
|
33
|
+
}, 60_000);
|
|
34
|
+
|
|
35
|
+
test.each(["remote", "source", "install", "options", "notPublished"] as const)(
|
|
36
|
+
"TEST-33 REQ-24 changing %s after consent returns a fresh plan without publishing",
|
|
37
|
+
async (changed) => {
|
|
38
|
+
f = new MarketFixture();
|
|
39
|
+
const repo = await f.prepared();
|
|
40
|
+
const plan = f.market(["publish", "review", "--plugin", ID]);
|
|
41
|
+
expect(plan.code).toBe(3);
|
|
42
|
+
expect(typeof plan.result.planId).toBe("string");
|
|
43
|
+
if (changed === "remote") {
|
|
44
|
+
write(join(repo.author, "README.md"), "new remote head\n");
|
|
45
|
+
f.push(repo);
|
|
46
|
+
}
|
|
47
|
+
if (changed === "source")
|
|
48
|
+
write(join(f.folder(), "SKILL.md"), `${EDITED}\nA later edit.\n`);
|
|
49
|
+
if (changed === "install") f.install(ID, "project");
|
|
50
|
+
if (changed === "notPublished")
|
|
51
|
+
write(join(f.folder(), ".env"), "DO_NOT_PUBLISH=secret\n");
|
|
52
|
+
const head = f.head(repo);
|
|
53
|
+
const args = [
|
|
54
|
+
"publish",
|
|
55
|
+
"review",
|
|
56
|
+
"--plugin",
|
|
57
|
+
ID,
|
|
58
|
+
`--yes=${plan.result.planId}`,
|
|
59
|
+
];
|
|
60
|
+
if (changed === "options") args.push("--keep-local");
|
|
61
|
+
|
|
62
|
+
const applied = f.market(args);
|
|
63
|
+
|
|
64
|
+
expect(applied.code).toBe(3);
|
|
65
|
+
expect(applied.result.error?.code).toBe("plan-changed");
|
|
66
|
+
expect(applied.result.outcome).toBe("needs-consent");
|
|
67
|
+
expect(typeof applied.result.planId).toBe("string");
|
|
68
|
+
expect(applied.result.planId).not.toBe(plan.result.planId);
|
|
69
|
+
expect(applied.result.plan?.length).toBeGreaterThan(0);
|
|
70
|
+
expect(f.head(repo)).toBe(head);
|
|
71
|
+
expect(existsSync(f.folder())).toBe(true);
|
|
72
|
+
f.assertOwned();
|
|
73
|
+
},
|
|
74
|
+
60_000,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
test("TEST-34 REQ-25 an explicit target ignores an unreachable other marketplace", async () => {
|
|
78
|
+
f = new MarketFixture();
|
|
79
|
+
const repo = await f.prepared();
|
|
80
|
+
const other = f.seed({ name: "beta", slug: "beta/market" });
|
|
81
|
+
f.register(other);
|
|
82
|
+
rmSync(other.bare, { recursive: true, force: true });
|
|
83
|
+
const head = f.head(repo);
|
|
84
|
+
|
|
85
|
+
const inferred = f.market(["publish", "review"]);
|
|
86
|
+
|
|
87
|
+
expect(inferred.code).toBe(3);
|
|
88
|
+
expect(inferred.result.error?.code).toBe("stale");
|
|
89
|
+
expect(JSON.stringify(inferred.result)).toContain("beta");
|
|
90
|
+
expect(JSON.stringify(inferred.result)).toContain("--plugin");
|
|
91
|
+
expect(f.head(repo)).toBe(head);
|
|
92
|
+
|
|
93
|
+
const explicit = f.market(["publish", "review", "--plugin", ID, "--yes"]);
|
|
94
|
+
expect(explicit.code).toBe(0);
|
|
95
|
+
expectPublished(f, repo);
|
|
96
|
+
f.assertOwned();
|
|
97
|
+
}, 60_000);
|
|
98
|
+
|
|
99
|
+
test("TEST-34 REQ-25 fresh duplicate skill ownership is ambiguous until a plugin is named", () => {
|
|
100
|
+
f = new MarketFixture();
|
|
101
|
+
const repo = f.seed();
|
|
102
|
+
const other = f.seed({ name: "beta", slug: "beta/market" });
|
|
103
|
+
f.register(repo);
|
|
104
|
+
f.register(other);
|
|
105
|
+
write(join(f.folder(), "SKILL.md"), EDITED);
|
|
106
|
+
const head = f.head(repo);
|
|
107
|
+
|
|
108
|
+
const r = f.market(["publish", "review"]);
|
|
109
|
+
|
|
110
|
+
expect(r.code).toBe(2);
|
|
111
|
+
expect(r.result.error?.code).toBe("ambiguous");
|
|
112
|
+
expect(f.head(repo)).toBe(head);
|
|
113
|
+
f.assertOwned();
|
|
114
|
+
}, 30_000);
|
|
115
|
+
|
|
116
|
+
test("TEST-45 REQ-24 non-fast-forward races return the fresh plan instead of silently retrying", async () => {
|
|
117
|
+
f = new MarketFixture();
|
|
118
|
+
const repo = await f.prepared();
|
|
119
|
+
const oldSkill = f.remoteFile(repo, SKILL_REL);
|
|
120
|
+
const plan = f.market(["publish", "review", "--plugin", ID]);
|
|
121
|
+
expect(plan.code).toBe(3);
|
|
122
|
+
f.setModes({ advanceOnValidate: { author: repo.author } });
|
|
123
|
+
|
|
124
|
+
const r = f.market([
|
|
125
|
+
"publish",
|
|
126
|
+
"review",
|
|
127
|
+
"--plugin",
|
|
128
|
+
ID,
|
|
129
|
+
`--yes=${plan.result.planId}`,
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
expect(r.code).toBe(3);
|
|
133
|
+
expect(r.result.error?.code).toBe("plan-changed");
|
|
134
|
+
expect(r.result.planId).not.toBe(plan.result.planId);
|
|
135
|
+
expect(r.result.plan?.length).toBeGreaterThan(0);
|
|
136
|
+
expect(f.remoteFile(repo, "remote-race.txt")).toBe("remote advance");
|
|
137
|
+
expect(f.remoteFile(repo, SKILL_REL)).toBe(oldSkill);
|
|
138
|
+
expect(existsSync(f.folder())).toBe(true);
|
|
139
|
+
f.assertOwned();
|
|
140
|
+
}, 60_000);
|
|
141
|
+
|
|
142
|
+
test("TEST-45 REQ-32 disposable leftover content cannot enter a later operation's commit", () => {
|
|
143
|
+
f = new MarketFixture();
|
|
144
|
+
const repo = f.seed();
|
|
145
|
+
f.register(repo);
|
|
146
|
+
f.ok(["fetch", "acme"]);
|
|
147
|
+
write(
|
|
148
|
+
join(f.working(repo), "plugins", "unapproved", "SKILL.md"),
|
|
149
|
+
"must not escape\n",
|
|
150
|
+
);
|
|
151
|
+
write(join(f.working(repo), ".unapproved-secret"), "not approved\n");
|
|
152
|
+
|
|
153
|
+
const r = f.market([
|
|
154
|
+
"plugin",
|
|
155
|
+
"create",
|
|
156
|
+
"approved@acme",
|
|
157
|
+
"--description",
|
|
158
|
+
"Approved tools",
|
|
159
|
+
"--yes",
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
expect(r.code).toBe(0);
|
|
163
|
+
const changed = f
|
|
164
|
+
.git(
|
|
165
|
+
f.root,
|
|
166
|
+
"--git-dir",
|
|
167
|
+
repo.bare,
|
|
168
|
+
"diff-tree",
|
|
169
|
+
"--no-commit-id",
|
|
170
|
+
"--name-only",
|
|
171
|
+
"-r",
|
|
172
|
+
"HEAD",
|
|
173
|
+
)
|
|
174
|
+
.split("\n")
|
|
175
|
+
.sort();
|
|
176
|
+
expect(changed).toEqual([
|
|
177
|
+
".claude-plugin/marketplace.json",
|
|
178
|
+
"plugins/approved/.claude-plugin/plugin.json",
|
|
179
|
+
]);
|
|
180
|
+
expect(f.remotePaths(repo).some((path) => path.includes("unapproved"))).toBe(
|
|
181
|
+
false,
|
|
182
|
+
);
|
|
183
|
+
expect(existsSync(join(f.working(repo), ".unapproved-secret"))).toBe(false);
|
|
184
|
+
f.assertOwned();
|
|
185
|
+
}, 60_000);
|