magus-cli 7.13.0 → 7.14.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 +7 -4
- package/package.json +4 -4
- 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-router.test.ts +4 -4
- package/src/__tests__/scope-uninstall-wrong-scope.test.ts +341 -0
- package/src/cli/market-view.ts +59 -18
- package/src/cli/market.ts +128 -44
- package/src/cli/router.ts +42 -10
- package/src/data/marketplaces.ts +1 -0
- package/src/main.tsx +1 -1
- package/src/market-tui.tsx +1 -1
- package/src/services/claude-cli.ts +13 -0
- package/src/services/local-marketplace.ts +8 -0
- package/src/services/market-change.ts +6 -6
- package/src/services/market-local.ts +93 -0
- package/src/services/market-ops-plugin.ts +366 -47
- package/src/services/market-ops-publish.ts +363 -15
- package/src/services/market-ops-repo.ts +94 -13
- package/src/services/market-ops-skill.ts +93 -11
- package/src/services/market-ops.ts +72 -8
- package/src/services/market-publish.ts +10 -1
- package/src/services/market-types.ts +23 -0
- package/src/services/update-engine.ts +97 -1
- package/src/tui-runtime.tsx +2 -2
- package/src/types/index.ts +2 -0
- package/src/ui/adapters/marketAdapter.ts +123 -4
- package/src/ui/adapters/pluginsAdapter.ts +25 -1
- package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
- package/src/ui/market/MarketplaceScreen.tsx +55 -24
- package/src/ui/market/MarketplacesDetail.snowflake.tsx +60 -4
- package/src/ui/market/MarketplacesScreen.tsx +120 -29
- package/src/ui/market/PluginDetail.snowflake.tsx +12 -1
- package/src/ui/market/marketKeymap.ts +29 -2
- package/src/ui/market/useMarketState.ts +146 -5
- package/src/utils/display-path.ts +33 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { fitRow } from "../ui/adapters/marketAdapter.js";
|
|
3
|
+
import { displayWidth } from "../ui/text-width.js";
|
|
4
|
+
|
|
5
|
+
const widths = Array.from({ length: 60 }, (_, index) => index + 1);
|
|
6
|
+
|
|
7
|
+
function rowColumns(
|
|
8
|
+
row: { name: string; secondary: readonly { text: string }[] },
|
|
9
|
+
fixed: number,
|
|
10
|
+
gap = 2,
|
|
11
|
+
): number {
|
|
12
|
+
return (
|
|
13
|
+
displayWidth(row.name) +
|
|
14
|
+
fixed +
|
|
15
|
+
row.secondary.reduce(
|
|
16
|
+
(total, part) => total + gap + displayWidth(part.text),
|
|
17
|
+
0,
|
|
18
|
+
)
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test.each(widths)(
|
|
23
|
+
"R7.1 REQ-7 TEST-13 fits an over-wide name and empty secondary list at width %i",
|
|
24
|
+
(width) => {
|
|
25
|
+
const name = "n".repeat(80);
|
|
26
|
+
|
|
27
|
+
for (const fixed of new Set([0, Math.min(8, width - 1)])) {
|
|
28
|
+
const row = fitRow(width, name, fixed, []);
|
|
29
|
+
const available = width - fixed;
|
|
30
|
+
|
|
31
|
+
expect(row).toEqual({
|
|
32
|
+
name: `${"n".repeat(available - 1)}…`,
|
|
33
|
+
secondary: [],
|
|
34
|
+
});
|
|
35
|
+
expect(rowColumns(row, fixed)).toBeLessThanOrEqual(width);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
test.each(widths)(
|
|
41
|
+
"R7.1 REQ-7 TEST-14 reserves fixed columns for over-wide Unicode names at width %i",
|
|
42
|
+
(width) => {
|
|
43
|
+
const names = ["日本語".repeat(30), "é".repeat(80), "👩💻".repeat(40)];
|
|
44
|
+
const fixed = Math.min(8, width - 1);
|
|
45
|
+
|
|
46
|
+
for (const name of names) {
|
|
47
|
+
const row = fitRow(width, name, fixed, [
|
|
48
|
+
{ text: "optional metadata" },
|
|
49
|
+
{ text: "x" },
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
expect(displayWidth(name)).toBeGreaterThan(width);
|
|
53
|
+
expect(row.name.endsWith("…")).toBe(true);
|
|
54
|
+
expect(row.secondary).toEqual([]);
|
|
55
|
+
expect(rowColumns(row, fixed)).toBeLessThanOrEqual(width);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
test.each([
|
|
61
|
+
{
|
|
62
|
+
label: "exact ASCII name",
|
|
63
|
+
width: 9,
|
|
64
|
+
fixed: 4,
|
|
65
|
+
name: "hello",
|
|
66
|
+
expected: "hello",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
label: "one name column",
|
|
70
|
+
width: 5,
|
|
71
|
+
fixed: 4,
|
|
72
|
+
name: "hello",
|
|
73
|
+
expected: "…",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
label: "CJK name",
|
|
77
|
+
width: 9,
|
|
78
|
+
fixed: 2,
|
|
79
|
+
name: "日本語会社",
|
|
80
|
+
expected: "日本語…",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
label: "combining-mark name",
|
|
84
|
+
width: 5,
|
|
85
|
+
fixed: 3,
|
|
86
|
+
name: "ééé",
|
|
87
|
+
expected: "é…",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
label: "joined-emoji name",
|
|
91
|
+
width: 5,
|
|
92
|
+
fixed: 2,
|
|
93
|
+
name: "👩💻👩💻👩💻",
|
|
94
|
+
expected: "👩💻…",
|
|
95
|
+
},
|
|
96
|
+
])(
|
|
97
|
+
"R7.1 REQ-7 TEST-15 preserves fixed columns and the exact expected $label",
|
|
98
|
+
({ width, fixed, name, expected }) => {
|
|
99
|
+
const row = fitRow(width, name, fixed, []);
|
|
100
|
+
|
|
101
|
+
expect(row).toEqual({ name: expected, secondary: [] });
|
|
102
|
+
expect(rowColumns(row, fixed)).toBeLessThanOrEqual(width);
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
test.each([0, 2, 5])(
|
|
107
|
+
"R7.1 REQ-7 TEST-16 keeps a secondary whole at its exact fit with gap %i",
|
|
108
|
+
(gap) => {
|
|
109
|
+
const first = { text: "ready", id: "state" };
|
|
110
|
+
const width = 3 + 4 + gap + 5;
|
|
111
|
+
|
|
112
|
+
const row = fitRow(
|
|
113
|
+
width,
|
|
114
|
+
"kit",
|
|
115
|
+
4,
|
|
116
|
+
[first, { text: "z", id: "later" }],
|
|
117
|
+
gap,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(row).toEqual({ name: "kit", secondary: [first] });
|
|
121
|
+
expect(rowColumns(row, 4, gap)).toBe(width);
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const remainingCases = [
|
|
126
|
+
{ remaining: 0, expectedText: null },
|
|
127
|
+
{ remaining: 1, expectedText: null },
|
|
128
|
+
{ remaining: 2, expectedText: null },
|
|
129
|
+
{ remaining: 3, expectedText: null },
|
|
130
|
+
{ remaining: 4, expectedText: "abc…" },
|
|
131
|
+
{ remaining: 5, expectedText: "abcd…" },
|
|
132
|
+
];
|
|
133
|
+
const clippingCases = [0, 2, 5].flatMap((gap) =>
|
|
134
|
+
remainingCases.map((entry) => ({ gap, ...entry })),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
test.each(clippingCases)(
|
|
138
|
+
"R7.1 REQ-7 TEST-17 clips only at four or more available columns: gap=$gap remaining=$remaining",
|
|
139
|
+
({ gap, remaining, expectedText }) => {
|
|
140
|
+
const width = 3 + 4 + gap + remaining;
|
|
141
|
+
|
|
142
|
+
const row = fitRow(
|
|
143
|
+
width,
|
|
144
|
+
"kit",
|
|
145
|
+
4,
|
|
146
|
+
[
|
|
147
|
+
{ text: "abcdefgh", id: "first" },
|
|
148
|
+
{ text: "x", id: "later" },
|
|
149
|
+
],
|
|
150
|
+
gap,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(row).toEqual({
|
|
154
|
+
name: "kit",
|
|
155
|
+
secondary:
|
|
156
|
+
expectedText === null ? [] : [{ text: expectedText, id: "first" }],
|
|
157
|
+
});
|
|
158
|
+
expect(rowColumns(row, 4, gap)).toBeLessThanOrEqual(width);
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
test("R7.1 REQ-7 TEST-18 keeps whole secondary text before the first clipped one and drops later text", () => {
|
|
163
|
+
const row = fitRow(20, "kit", 4, [
|
|
164
|
+
{ text: "ready", id: "state" },
|
|
165
|
+
{ text: "long-detail", id: "detail" },
|
|
166
|
+
{ text: "x", id: "later" },
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
expect(row).toEqual({
|
|
170
|
+
name: "kit",
|
|
171
|
+
secondary: [
|
|
172
|
+
{ text: "ready", id: "state" },
|
|
173
|
+
{ text: "lon…", id: "detail" },
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
expect(rowColumns(row, 4)).toBe(20);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("R7.1 REQ-7 TEST-19 drops later short text rather than skipping a non-fitting secondary", () => {
|
|
180
|
+
const row = fitRow(19, "kit", 4, [
|
|
181
|
+
{ text: "ready", id: "state" },
|
|
182
|
+
{ text: "long-detail", id: "blocked" },
|
|
183
|
+
{ text: "x", id: "would-fit-if-skipped" },
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
expect(row).toEqual({
|
|
187
|
+
name: "kit",
|
|
188
|
+
secondary: [{ text: "ready", id: "state" }],
|
|
189
|
+
});
|
|
190
|
+
expect(rowColumns(row, 4)).toBeLessThanOrEqual(19);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test.each([
|
|
194
|
+
{ label: "CJK", text: "日本語", expected: "日…" },
|
|
195
|
+
{
|
|
196
|
+
label: "combining marks",
|
|
197
|
+
text: "é".repeat(8),
|
|
198
|
+
expected: "ééé…",
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
label: "joined emoji",
|
|
202
|
+
text: "👩💻👩💻👩💻",
|
|
203
|
+
expected: "👩💻…",
|
|
204
|
+
},
|
|
205
|
+
])(
|
|
206
|
+
"R7.1 REQ-7 TEST-20 clips $label secondary text by display columns",
|
|
207
|
+
({ text, expected }) => {
|
|
208
|
+
const row = fitRow(13, "kit", 4, [
|
|
209
|
+
{ text, id: "first" },
|
|
210
|
+
{ text: "x", id: "later" },
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
expect(row).toEqual({
|
|
214
|
+
name: "kit",
|
|
215
|
+
secondary: [{ text: expected, id: "first" }],
|
|
216
|
+
});
|
|
217
|
+
expect(rowColumns(row, 4)).toBeLessThanOrEqual(13);
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
test.each(widths)(
|
|
222
|
+
"R7.1 REQ-7 TEST-21 keeps mixed secondary rows within width %i in their original order",
|
|
223
|
+
(width) => {
|
|
224
|
+
const fixed = Math.min(6, width - 1);
|
|
225
|
+
const secondary = [
|
|
226
|
+
{ text: "会社の道具", id: "company" },
|
|
227
|
+
{ text: "● 4 plugins to push", id: "status" },
|
|
228
|
+
{ text: "v1.2.3", id: "version" },
|
|
229
|
+
];
|
|
230
|
+
const available = width - fixed;
|
|
231
|
+
const expectedName = available >= 3 ? "kit" : available === 2 ? "k…" : "…";
|
|
232
|
+
|
|
233
|
+
const row = fitRow(width, "kit", fixed, secondary);
|
|
234
|
+
|
|
235
|
+
expect(row.name).toBe(expectedName);
|
|
236
|
+
expect(rowColumns(row, fixed)).toBeLessThanOrEqual(width);
|
|
237
|
+
expect(row.secondary.length).toBeLessThanOrEqual(secondary.length);
|
|
238
|
+
expect(row.secondary.map((part) => part.id)).toEqual(
|
|
239
|
+
secondary.slice(0, row.secondary.length).map((part) => part.id),
|
|
240
|
+
);
|
|
241
|
+
for (let index = 0; index < row.secondary.length; index++) {
|
|
242
|
+
const returned = row.secondary[index]!;
|
|
243
|
+
const original = secondary[index]!;
|
|
244
|
+
if (returned.text !== original.text) {
|
|
245
|
+
expect(index).toBe(row.secondary.length - 1);
|
|
246
|
+
expect(returned.text.endsWith("…")).toBe(true);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const sufficientWidthCases = [
|
|
253
|
+
{ label: "default two-column gap", gap: undefined, effectiveGap: 2 },
|
|
254
|
+
{ label: "zero-column gap", gap: 0, effectiveGap: 0 },
|
|
255
|
+
{ label: "one-column gap", gap: 1, effectiveGap: 1 },
|
|
256
|
+
{ label: "five-column gap", gap: 5, effectiveGap: 5 },
|
|
257
|
+
].flatMap((entry) => [0, 9].map((spare) => ({ ...entry, spare })));
|
|
258
|
+
|
|
259
|
+
test.each(sufficientWidthCases)(
|
|
260
|
+
"R7.2 REQ-8 TEST-22 preserves all text with $label and $spare spare columns",
|
|
261
|
+
({ gap, effectiveGap, spare }) => {
|
|
262
|
+
const name = "会社-kit";
|
|
263
|
+
const fixed = 8;
|
|
264
|
+
const secondary = [
|
|
265
|
+
{ text: "by Équipe", id: "company" },
|
|
266
|
+
{ text: "● 3 skills edited here", id: "status" },
|
|
267
|
+
{ text: "👩💻 é", id: "detail" },
|
|
268
|
+
];
|
|
269
|
+
const required =
|
|
270
|
+
displayWidth(name) +
|
|
271
|
+
fixed +
|
|
272
|
+
secondary.reduce(
|
|
273
|
+
(total, part) => total + effectiveGap + displayWidth(part.text),
|
|
274
|
+
0,
|
|
275
|
+
);
|
|
276
|
+
const width = required + spare;
|
|
277
|
+
|
|
278
|
+
const row =
|
|
279
|
+
gap === undefined
|
|
280
|
+
? fitRow(width, name, fixed, secondary)
|
|
281
|
+
: fitRow(width, name, fixed, secondary, gap);
|
|
282
|
+
|
|
283
|
+
expect(row).toEqual({ name, secondary });
|
|
284
|
+
expect(rowColumns(row, fixed, effectiveGap)).toBe(required);
|
|
285
|
+
expect(rowColumns(row, fixed, effectiveGap)).toBeLessThanOrEqual(width);
|
|
286
|
+
},
|
|
287
|
+
);
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { companyPluginNames } from "../services/market-ops.js";
|
|
4
|
+
import {
|
|
5
|
+
MarketFixture,
|
|
6
|
+
SKILL,
|
|
7
|
+
sourceModule,
|
|
8
|
+
write,
|
|
9
|
+
writeJson,
|
|
10
|
+
} from "./market-fixture.js";
|
|
11
|
+
|
|
12
|
+
type LocalItems = Awaited<
|
|
13
|
+
ReturnType<typeof import("../services/market-local.js").localItems>
|
|
14
|
+
>;
|
|
15
|
+
|
|
16
|
+
let world: MarketFixture | undefined;
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
world?.close();
|
|
19
|
+
world = undefined;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const scopes = [
|
|
23
|
+
{ from: "project", scope: "project" },
|
|
24
|
+
{ from: "global", scope: "user" },
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
function discover(
|
|
28
|
+
f: MarketFixture,
|
|
29
|
+
companySkills: readonly string[] = [],
|
|
30
|
+
companyPlugins?: readonly string[],
|
|
31
|
+
): LocalItems {
|
|
32
|
+
const pluginsArgument =
|
|
33
|
+
companyPlugins === undefined
|
|
34
|
+
? ""
|
|
35
|
+
: `,new Set(${JSON.stringify(companyPlugins)})`;
|
|
36
|
+
return f.value<LocalItems>(
|
|
37
|
+
`const {localItems}=await import(${JSON.stringify(sourceModule("services/market-local.js"))});console.log(JSON.stringify(await localItems(${JSON.stringify(f.project)},new Set(${JSON.stringify(companySkills)})${pluginsArgument})));`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test.each(["omitted", "empty"] as const)(
|
|
42
|
+
"R5.1 REQ-1 TEST-1 retains plugins when companyPlugins is %s",
|
|
43
|
+
(mode) => {
|
|
44
|
+
world = new MarketFixture(true);
|
|
45
|
+
const f = world;
|
|
46
|
+
const projectPlugin = f.folder("project", "folder-label");
|
|
47
|
+
const userPlugin = f.folder("global", "fallback-kit");
|
|
48
|
+
writeJson(join(projectPlugin, ".claude-plugin", "plugin.json"), {
|
|
49
|
+
name: "company-kit",
|
|
50
|
+
version: "1.0.0",
|
|
51
|
+
});
|
|
52
|
+
writeJson(join(userPlugin, ".claude-plugin", "plugin.json"), {
|
|
53
|
+
version: "1.0.0",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const items = discover(f, [], mode === "omitted" ? undefined : []);
|
|
57
|
+
|
|
58
|
+
expect(items).toEqual([
|
|
59
|
+
{
|
|
60
|
+
kind: "plugin",
|
|
61
|
+
name: "company-kit",
|
|
62
|
+
scope: "project",
|
|
63
|
+
path: projectPlugin,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
kind: "plugin",
|
|
67
|
+
name: "fallback-kit",
|
|
68
|
+
scope: "user",
|
|
69
|
+
path: userPlugin,
|
|
70
|
+
},
|
|
71
|
+
]);
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
test.each(scopes)(
|
|
76
|
+
"R5.1 REQ-1 TEST-2 excludes a held manifest name in $scope but keeps skills and plugin prefix matches",
|
|
77
|
+
({ from, scope }) => {
|
|
78
|
+
world = new MarketFixture(true);
|
|
79
|
+
const f = world;
|
|
80
|
+
const held = f.folder(from, "different-folder");
|
|
81
|
+
const namesakeSkill = f.folder(from, "shared-kit");
|
|
82
|
+
const unheld = f.folder(from, "unheld-folder");
|
|
83
|
+
writeJson(join(held, ".claude-plugin", "plugin.json"), {
|
|
84
|
+
name: "shared-kit",
|
|
85
|
+
version: "1.0.0",
|
|
86
|
+
});
|
|
87
|
+
write(join(held, "SKILL.md"), SKILL);
|
|
88
|
+
write(join(namesakeSkill, "SKILL.md"), SKILL);
|
|
89
|
+
writeJson(join(unheld, ".claude-plugin", "plugin.json"), {
|
|
90
|
+
name: "shared-kit-extra",
|
|
91
|
+
version: "1.0.0",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const items = discover(f, [], ["shared-kit"]);
|
|
95
|
+
|
|
96
|
+
expect(items).toEqual([
|
|
97
|
+
{
|
|
98
|
+
kind: "skill",
|
|
99
|
+
name: "shared-kit",
|
|
100
|
+
scope,
|
|
101
|
+
path: namesakeSkill,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
kind: "plugin",
|
|
105
|
+
name: "shared-kit-extra",
|
|
106
|
+
scope,
|
|
107
|
+
path: unheld,
|
|
108
|
+
},
|
|
109
|
+
]);
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
test.each(scopes)(
|
|
114
|
+
"R5.1 REQ-1 TEST-3 uses the manifest name rather than a held folder label in $scope",
|
|
115
|
+
({ from, scope }) => {
|
|
116
|
+
world = new MarketFixture(true);
|
|
117
|
+
const f = world;
|
|
118
|
+
const folder = f.folder(from, "held-folder");
|
|
119
|
+
writeJson(join(folder, ".claude-plugin", "plugin.json"), {
|
|
120
|
+
name: "unheld-kit",
|
|
121
|
+
version: "1.0.0",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const items = discover(f, [], ["held-folder"]);
|
|
125
|
+
|
|
126
|
+
expect(items).toEqual([
|
|
127
|
+
{ kind: "plugin", name: "unheld-kit", scope, path: folder },
|
|
128
|
+
]);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
test.each(scopes)(
|
|
133
|
+
"R5.1 REQ-1 TEST-4 excludes a held folder-name fallback in $scope",
|
|
134
|
+
({ from, scope }) => {
|
|
135
|
+
world = new MarketFixture(true);
|
|
136
|
+
const f = world;
|
|
137
|
+
const held = f.folder(from, "held-kit");
|
|
138
|
+
const kept = f.folder(from, "kept-kit");
|
|
139
|
+
for (const folder of [held, kept]) {
|
|
140
|
+
writeJson(join(folder, ".claude-plugin", "plugin.json"), {
|
|
141
|
+
version: "1.0.0",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const items = discover(f, [], ["held-kit"]);
|
|
146
|
+
|
|
147
|
+
expect(items).toEqual([
|
|
148
|
+
{ kind: "plugin", name: "kept-kit", scope, path: kept },
|
|
149
|
+
]);
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
test("R5.1 REQ-1 TEST-5 applies companySkills and companyPlugins only to their own item kinds", () => {
|
|
154
|
+
world = new MarketFixture(true);
|
|
155
|
+
const f = world;
|
|
156
|
+
const heldSkill = f.folder("project", "held-skill");
|
|
157
|
+
const skillNamesake = f.folder("project", "held-kit");
|
|
158
|
+
const pluginNamesake = f.folder("global", "plugin-folder");
|
|
159
|
+
const heldPlugin = f.folder("global", "held-plugin-folder");
|
|
160
|
+
write(join(heldSkill, "SKILL.md"), SKILL);
|
|
161
|
+
write(join(skillNamesake, "SKILL.md"), SKILL);
|
|
162
|
+
writeJson(join(pluginNamesake, ".claude-plugin", "plugin.json"), {
|
|
163
|
+
name: "held-skill",
|
|
164
|
+
version: "1.0.0",
|
|
165
|
+
});
|
|
166
|
+
writeJson(join(heldPlugin, ".claude-plugin", "plugin.json"), {
|
|
167
|
+
name: "held-kit",
|
|
168
|
+
version: "1.0.0",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const items = discover(f, ["held-skill"], ["held-kit"]);
|
|
172
|
+
|
|
173
|
+
expect(items).toEqual([
|
|
174
|
+
{
|
|
175
|
+
kind: "skill",
|
|
176
|
+
name: "held-kit",
|
|
177
|
+
scope: "project",
|
|
178
|
+
path: skillNamesake,
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
kind: "plugin",
|
|
182
|
+
name: "held-skill",
|
|
183
|
+
scope: "user",
|
|
184
|
+
path: pluginNamesake,
|
|
185
|
+
},
|
|
186
|
+
]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("R5.2 REQ-2 TEST-6 returns the union of plugin names across every marketplace without skill names or duplicates", () => {
|
|
190
|
+
const marketplaces: Parameters<typeof companyPluginNames>[0] = [
|
|
191
|
+
{
|
|
192
|
+
plugins: [
|
|
193
|
+
{ name: "review-kit", version: "1.0.0", skills: ["review"] },
|
|
194
|
+
{ name: "deploy-kit", version: "2.0.0", skills: ["deploy"] },
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
{ plugins: [] },
|
|
198
|
+
{
|
|
199
|
+
plugins: [
|
|
200
|
+
{ name: "review-kit", version: "3.0.0", skills: ["different-skill"] },
|
|
201
|
+
{ name: "empty-kit", version: "1.0.0", skills: [] },
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
const names = companyPluginNames(marketplaces);
|
|
207
|
+
|
|
208
|
+
expect(names).toBeInstanceOf(Set);
|
|
209
|
+
expect([...names].sort()).toEqual(["deploy-kit", "empty-kit", "review-kit"]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test.each([
|
|
213
|
+
{ label: "no marketplaces", entries: [] },
|
|
214
|
+
{
|
|
215
|
+
label: "only empty marketplaces",
|
|
216
|
+
entries: [{ plugins: [] }, { plugins: [] }],
|
|
217
|
+
},
|
|
218
|
+
])("R5.2 REQ-2 TEST-7 returns an empty set for $label", ({ entries }) => {
|
|
219
|
+
const names = companyPluginNames(entries);
|
|
220
|
+
|
|
221
|
+
expect(names).toBeInstanceOf(Set);
|
|
222
|
+
expect([...names]).toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("R6.3 REQ-6 TEST-8 keeps LocalItem.path absolute for project and USER skills and plugins", () => {
|
|
226
|
+
world = new MarketFixture();
|
|
227
|
+
const f = world;
|
|
228
|
+
const expected: LocalItems = [];
|
|
229
|
+
for (const { from, scope } of scopes) {
|
|
230
|
+
const plugin = f.folder(from, "alpha-kit");
|
|
231
|
+
const skill = f.folder(from, "beta-skill");
|
|
232
|
+
writeJson(join(plugin, ".claude-plugin", "plugin.json"), {
|
|
233
|
+
name: "alpha-kit",
|
|
234
|
+
version: "1.0.0",
|
|
235
|
+
});
|
|
236
|
+
write(join(skill, "SKILL.md"), SKILL);
|
|
237
|
+
expected.push(
|
|
238
|
+
{ kind: "plugin", name: "alpha-kit", scope, path: plugin },
|
|
239
|
+
{ kind: "skill", name: "beta-skill", scope, path: skill },
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
expected.sort(
|
|
243
|
+
(a, b) => a.name.localeCompare(b.name) || a.scope.localeCompare(b.scope),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const items = discover(f);
|
|
247
|
+
|
|
248
|
+
expect(items).toEqual(expected);
|
|
249
|
+
for (const item of items) {
|
|
250
|
+
expect(isAbsolute(item.path)).toBe(true);
|
|
251
|
+
expect(item.path.startsWith("~")).toBe(false);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
4
|
+
import { MarketFixture, SKILL, write, writeJson } from "./market-fixture.js";
|
|
5
|
+
|
|
6
|
+
let world: MarketFixture | undefined;
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
world?.close();
|
|
9
|
+
world = undefined;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const scopes = [
|
|
13
|
+
{ from: "project", scope: "project" },
|
|
14
|
+
{ from: "global", scope: "user" },
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
function makePlugin(f: MarketFixture, from: "project" | "global"): string {
|
|
18
|
+
const folder = f.folder(from, "copy-me");
|
|
19
|
+
writeJson(join(folder, ".claude-plugin", "plugin.json"), {
|
|
20
|
+
name: "local-kit",
|
|
21
|
+
description: "Local team tools",
|
|
22
|
+
version: "1.2.3",
|
|
23
|
+
});
|
|
24
|
+
write(join(folder, "README.md"), "Local team tools\n");
|
|
25
|
+
write(join(folder, "skills", "review", "SKILL.md"), SKILL);
|
|
26
|
+
return folder;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test.each(scopes)(
|
|
30
|
+
"R5.3 R6.3 REQ-3 REQ-6 TEST-9 removes the added $scope plugin from local JSON but retains its folder and unrelated items",
|
|
31
|
+
({ from, scope }) => {
|
|
32
|
+
world = new MarketFixture(true);
|
|
33
|
+
const f = world;
|
|
34
|
+
const repo = f.seed();
|
|
35
|
+
f.register(repo);
|
|
36
|
+
const folder = makePlugin(f, from);
|
|
37
|
+
const manifest = join(folder, ".claude-plugin", "plugin.json");
|
|
38
|
+
const originalManifest = readFileSync(manifest, "utf8");
|
|
39
|
+
const spareSkill = f.folder(from, "spare-skill");
|
|
40
|
+
write(join(spareSkill, "SKILL.md"), SKILL);
|
|
41
|
+
|
|
42
|
+
const before = f.market(["list"]);
|
|
43
|
+
|
|
44
|
+
expect(before.code).toBe(0);
|
|
45
|
+
expect(before.result.local).toEqual([
|
|
46
|
+
{ kind: "plugin", name: "local-kit", scope, path: folder },
|
|
47
|
+
{ kind: "skill", name: "spare-skill", scope, path: spareSkill },
|
|
48
|
+
]);
|
|
49
|
+
for (const item of before.result.local ?? []) {
|
|
50
|
+
expect(isAbsolute(item.path)).toBe(true);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const added = f.market([
|
|
54
|
+
"plugin",
|
|
55
|
+
"add",
|
|
56
|
+
folder,
|
|
57
|
+
"--to",
|
|
58
|
+
repo.name,
|
|
59
|
+
"--yes",
|
|
60
|
+
]);
|
|
61
|
+
expect({ exit: added.code, outcome: added.result.outcome }).toEqual({
|
|
62
|
+
exit: 0,
|
|
63
|
+
outcome: "ok",
|
|
64
|
+
});
|
|
65
|
+
const after = f.market(["list"]);
|
|
66
|
+
|
|
67
|
+
expect(after.code).toBe(0);
|
|
68
|
+
expect(after.result.local).toEqual([
|
|
69
|
+
{ kind: "skill", name: "spare-skill", scope, path: spareSkill },
|
|
70
|
+
]);
|
|
71
|
+
expect(
|
|
72
|
+
after.result.marketplaces
|
|
73
|
+
?.find((entry) => entry.name === repo.name)
|
|
74
|
+
?.plugins.map((plugin) => plugin.name),
|
|
75
|
+
).toContain("local-kit");
|
|
76
|
+
for (const item of after.result.local ?? []) {
|
|
77
|
+
expect(isAbsolute(item.path)).toBe(true);
|
|
78
|
+
}
|
|
79
|
+
expect(statSync(folder).isDirectory()).toBe(true);
|
|
80
|
+
expect(readFileSync(manifest, "utf8")).toBe(originalManifest);
|
|
81
|
+
expect(readFileSync(join(folder, "README.md"), "utf8")).toBe(
|
|
82
|
+
"Local team tools\n",
|
|
83
|
+
);
|
|
84
|
+
expect(
|
|
85
|
+
readFileSync(join(folder, "skills", "review", "SKILL.md"), "utf8"),
|
|
86
|
+
).toBe(SKILL);
|
|
87
|
+
f.assertOwned();
|
|
88
|
+
},
|
|
89
|
+
60_000,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
test.each([
|
|
93
|
+
{ from: "project", input: "absolute" },
|
|
94
|
+
{ from: "project", input: "relative" },
|
|
95
|
+
{ from: "global", input: "absolute" },
|
|
96
|
+
{ from: "global", input: "relative" },
|
|
97
|
+
] as const)(
|
|
98
|
+
"R6.2 REQ-5 TEST-10 prints one short source path for a $from plugin given a $input argument",
|
|
99
|
+
({ from, input }) => {
|
|
100
|
+
world = new MarketFixture();
|
|
101
|
+
const f = world;
|
|
102
|
+
const repo = f.seed();
|
|
103
|
+
f.register(repo);
|
|
104
|
+
const folder = makePlugin(f, from);
|
|
105
|
+
const argument =
|
|
106
|
+
input === "absolute" ? folder : relative(f.project, folder);
|
|
107
|
+
const expectedDisplay =
|
|
108
|
+
from === "project"
|
|
109
|
+
? join(".claude", "skills", "copy-me")
|
|
110
|
+
: join("~", ".claude", "skills", "copy-me");
|
|
111
|
+
const before = f.snapshot();
|
|
112
|
+
|
|
113
|
+
const plan = f.cli([
|
|
114
|
+
"market",
|
|
115
|
+
"plugin",
|
|
116
|
+
"add",
|
|
117
|
+
argument,
|
|
118
|
+
"--to",
|
|
119
|
+
repo.name,
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
expect(plan.code).toBe(3);
|
|
123
|
+
const humanOutput = `${plan.out}\n${plan.err}`;
|
|
124
|
+
expect(humanOutput).toContain(expectedDisplay);
|
|
125
|
+
expect(humanOutput.split(expectedDisplay)).toHaveLength(2);
|
|
126
|
+
expect(humanOutput).not.toContain(folder);
|
|
127
|
+
expect(f.snapshot()).toEqual(before);
|
|
128
|
+
},
|
|
129
|
+
30_000,
|
|
130
|
+
);
|
|
@@ -7,13 +7,13 @@ afterEach(() => {
|
|
|
7
7
|
f = undefined;
|
|
8
8
|
});
|
|
9
9
|
|
|
10
|
-
test("TEST-17 REQ-1 routing
|
|
10
|
+
test("TEST-17 REQ-1 routing edit selects a different app than bare magus", () => {
|
|
11
11
|
f = new MarketFixture();
|
|
12
12
|
const before = f.snapshot();
|
|
13
13
|
const routes = f.value<
|
|
14
14
|
Array<{ launchTui: boolean; app?: string; handled: boolean }>
|
|
15
15
|
>(
|
|
16
|
-
`const {route}=await import(${JSON.stringify(sourceModule("cli/router.js"))}); console.log(JSON.stringify([await route(['
|
|
16
|
+
`const {route}=await import(${JSON.stringify(sourceModule("cli/router.js"))}); console.log(JSON.stringify([await route(['edit'],'1.2.3'),await route([],'1.2.3')]));`,
|
|
17
17
|
);
|
|
18
18
|
expect(routes[0]).toEqual({ handled: false, launchTui: true, app: "market" });
|
|
19
19
|
expect(routes[1]?.handled).toBe(false);
|
|
@@ -21,11 +21,11 @@ test("TEST-17 REQ-1 routing market selects a different app than bare magus", ()
|
|
|
21
21
|
expect(routes[1]?.app ?? "main").toBe("main");
|
|
22
22
|
expect(f.snapshot()).toEqual(before);
|
|
23
23
|
});
|
|
24
|
-
test("TEST-17 REQ-1 headless bare
|
|
24
|
+
test("TEST-17 REQ-1 headless bare edit is a usage error without renderer writes", () => {
|
|
25
25
|
f = new MarketFixture();
|
|
26
26
|
const before = f.snapshot();
|
|
27
27
|
|
|
28
|
-
const r = f.cli(["
|
|
28
|
+
const r = f.cli(["edit"]);
|
|
29
29
|
|
|
30
30
|
expect(r.code).toBe(2);
|
|
31
31
|
expect(r.out + r.err).toMatch(/terminal|TTY|interactive/i);
|