magus-cli 7.13.0 → 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.
Files changed (43) hide show
  1. package/README.md +7 -4
  2. package/package.json +4 -4
  3. package/src/__tests__/market-r2-adapters.test.ts +190 -0
  4. package/src/__tests__/market-r2-company-cli.test.ts +350 -0
  5. package/src/__tests__/market-r2-company-unit.test.ts +224 -0
  6. package/src/__tests__/market-r2-local-items.test.ts +197 -0
  7. package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
  8. package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
  9. package/src/__tests__/market-r2-routing.test.ts +127 -0
  10. package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
  11. package/src/__tests__/market-r2b-display-path.test.ts +180 -0
  12. package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
  13. package/src/__tests__/market-r2b-local-items.test.ts +253 -0
  14. package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
  15. package/src/__tests__/market-router.test.ts +4 -4
  16. package/src/cli/market-view.ts +59 -18
  17. package/src/cli/market.ts +128 -44
  18. package/src/cli/router.ts +42 -10
  19. package/src/data/marketplaces.ts +1 -0
  20. package/src/main.tsx +1 -1
  21. package/src/market-tui.tsx +1 -1
  22. package/src/services/local-marketplace.ts +8 -0
  23. package/src/services/market-change.ts +6 -6
  24. package/src/services/market-local.ts +93 -0
  25. package/src/services/market-ops-plugin.ts +366 -47
  26. package/src/services/market-ops-publish.ts +363 -15
  27. package/src/services/market-ops-repo.ts +94 -13
  28. package/src/services/market-ops-skill.ts +93 -11
  29. package/src/services/market-ops.ts +72 -8
  30. package/src/services/market-publish.ts +10 -1
  31. package/src/services/market-types.ts +23 -0
  32. package/src/tui-runtime.tsx +2 -2
  33. package/src/types/index.ts +2 -0
  34. package/src/ui/adapters/marketAdapter.ts +123 -4
  35. package/src/ui/adapters/pluginsAdapter.ts +25 -1
  36. package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
  37. package/src/ui/market/MarketplaceScreen.tsx +55 -24
  38. package/src/ui/market/MarketplacesDetail.snowflake.tsx +60 -4
  39. package/src/ui/market/MarketplacesScreen.tsx +120 -29
  40. package/src/ui/market/PluginDetail.snowflake.tsx +12 -1
  41. package/src/ui/market/marketKeymap.ts +29 -2
  42. package/src/ui/market/useMarketState.ts +146 -5
  43. package/src/utils/display-path.ts +33 -0
@@ -0,0 +1,224 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ COMPANY_NAME_MAX,
4
+ checkCompanyName,
5
+ } from "../services/market-ops-repo.js";
6
+ import { companyOf } from "../services/market-ops.js";
7
+ import { MarketError } from "../services/market-types.js";
8
+ import type { Marketplace } from "../types/index.js";
9
+ import {
10
+ buildPluginBrowserItems,
11
+ companyName,
12
+ } from "../ui/adapters/pluginsAdapter.js";
13
+
14
+ const marketplace = (over: Partial<Marketplace> = {}): Marketplace => ({
15
+ name: "company-tools",
16
+ displayName: "Company tools",
17
+ description: "Team tools",
18
+ source: { source: "github", repo: "github-owner/tools" },
19
+ company: true,
20
+ ...over,
21
+ });
22
+
23
+ describe("round-two company identity", () => {
24
+ test.each([
25
+ { input: " 10xlabs ", expected: "10xlabs" },
26
+ { input: "A", expected: "A" },
27
+ { input: " North Star Labs ", expected: "North Star Labs" },
28
+ { input: "x".repeat(64), expected: "x".repeat(64) },
29
+ { input: ` ${"界".repeat(64)} `, expected: "界".repeat(64) },
30
+ { input: "🏢".repeat(64), expected: "🏢".repeat(64) },
31
+ ])(
32
+ "R1.3 TEST-1 returns trimmed company name at valid boundaries: $input",
33
+ ({ input, expected }) => {
34
+ expect(COMPANY_NAME_MAX).toBe(64);
35
+ expect(checkCompanyName(input)).toBe(expected);
36
+ },
37
+ );
38
+
39
+ const invalidNames = [
40
+ { input: "", message: "The company name is empty" },
41
+ { input: " ", message: "The company name is empty" },
42
+ {
43
+ input: "x".repeat(65),
44
+ message: "The company name is longer than 64 characters",
45
+ },
46
+ {
47
+ input: "🏢".repeat(65),
48
+ message: "The company name is longer than 64 characters",
49
+ },
50
+ ...[0, 1, 8, 9, 10, 11, 12, 13, 31, 127, 128, 133, 159, 0x2028, 0x2029].map(
51
+ (code) => ({
52
+ input: `Acme${String.fromCodePoint(code)}Labs`,
53
+ message: "The company name must be a single line",
54
+ }),
55
+ ),
56
+ ];
57
+ test.each(invalidNames)(
58
+ "R1.3 TEST-2 rejects invalid company name %j as invalid-name usage",
59
+ ({ input, message }) => {
60
+ expect(() => checkCompanyName(input)).toThrow(MarketError);
61
+ expect(() => checkCompanyName(input)).toThrow(message);
62
+ let failure: unknown;
63
+ try {
64
+ checkCompanyName(input);
65
+ } catch (error) {
66
+ failure = error;
67
+ }
68
+ expect(failure).toBeInstanceOf(MarketError);
69
+ if (!(failure instanceof MarketError))
70
+ throw new Error("Expected a company-name MarketError");
71
+ expect({ code: failure.code, exit: failure.exit }).toEqual({
72
+ code: "invalid-name",
73
+ exit: 2,
74
+ });
75
+ },
76
+ );
77
+
78
+ test.each([
79
+ {
80
+ manifest: { owner: { name: " 10xlabs " } },
81
+ source: "git-owner/market",
82
+ expected: "10xlabs",
83
+ },
84
+ {
85
+ manifest: { owner: { name: "10xlabs" } },
86
+ source: "https://git.example.invalid/team/market",
87
+ expected: "10xlabs",
88
+ },
89
+ {
90
+ manifest: { owner: { name: " " } },
91
+ source: "GitOwner/market",
92
+ expected: "GitOwner",
93
+ },
94
+ {
95
+ manifest: { owner: { name: 42 } },
96
+ source: "GitOwner/market",
97
+ expected: "GitOwner",
98
+ },
99
+ { manifest: {}, source: "GitOwner/market", expected: "GitOwner" },
100
+ { manifest: null, source: "GitOwner/market", expected: "GitOwner" },
101
+ { manifest: { owner: null }, source: "", expected: undefined },
102
+ {
103
+ manifest: { owner: { name: "" } },
104
+ source: "https://git.example.invalid/team/market",
105
+ expected: undefined,
106
+ },
107
+ { manifest: {}, source: "file:///company-market", expected: undefined },
108
+ ])(
109
+ "R1.6 TEST-3 companyOf prefers manifest owner and falls back only to GitHub source: %j",
110
+ ({ manifest, source, expected }) => {
111
+ expect(companyOf(manifest, source)).toBe(expected);
112
+ },
113
+ );
114
+
115
+ test.each([
116
+ { mp: marketplace({ ownerName: "10xlabs" }), expected: "10xlabs" },
117
+ { mp: marketplace(), expected: "github-owner" },
118
+ {
119
+ mp: marketplace({ source: { source: "github", repo: "" } }),
120
+ expected: undefined,
121
+ },
122
+ ])(
123
+ "R1.6 TEST-4 companyName uses manifest, GitHub owner, then no name: %j",
124
+ ({ mp, expected }) => {
125
+ expect(companyName(mp)).toBe(expected);
126
+ },
127
+ );
128
+ });
129
+
130
+ describe("round-two company badges", () => {
131
+ test.each([
132
+ { mp: marketplace({ ownerName: "10xlabs" }), badge: "by 10xlabs" },
133
+ { mp: marketplace(), badge: "by github-owner" },
134
+ {
135
+ mp: marketplace({ source: { source: "github", repo: "" } }),
136
+ badge: "◆ Company",
137
+ },
138
+ {
139
+ mp: marketplace({
140
+ source: { source: "github", repo: "MadAppGang/team-tools" },
141
+ ownerName: "Different Company",
142
+ }),
143
+ badge: "by Different Company",
144
+ },
145
+ ])(
146
+ "R1.6 TEST-5 empty company marketplace has the real byline and info tone: $badge",
147
+ ({ mp, badge }) => {
148
+ const rows = buildPluginBrowserItems({
149
+ marketplaces: [mp],
150
+ plugins: [],
151
+ collapsedMarketplaces: new Set(),
152
+ });
153
+ const row = rows.find((item) => item.kind === "category");
154
+ expect(row?.kind).toBe("category");
155
+ if (row?.kind !== "category") throw new Error("Company category missing");
156
+ expect({ tone: row.tone, badge: row.badge }).toEqual({
157
+ tone: "info",
158
+ badge,
159
+ });
160
+ },
161
+ );
162
+
163
+ test.each([
164
+ { name: "A".repeat(24), expected: `by ${"A".repeat(24)}` },
165
+ { name: "A".repeat(25), expected: `by ${"A".repeat(23)}…` },
166
+ ])(
167
+ "R1.6 TEST-5 truncates the company name to 24 columns including ellipsis: $name",
168
+ ({ name, expected }) => {
169
+ const row = buildPluginBrowserItems({
170
+ marketplaces: [marketplace({ ownerName: name })],
171
+ plugins: [],
172
+ collapsedMarketplaces: new Set(["company-tools"]),
173
+ }).find((item) => item.kind === "category");
174
+ expect(row?.kind === "category" ? row.badge : undefined).toBe(expected);
175
+ },
176
+ );
177
+
178
+ test("R1.6 TEST-5 wide company characters also fit the 24-column name budget", () => {
179
+ const row = buildPluginBrowserItems({
180
+ marketplaces: [marketplace({ ownerName: "界".repeat(20) })],
181
+ plugins: [],
182
+ collapsedMarketplaces: new Set(),
183
+ }).find((item) => item.kind === "category");
184
+ if (row?.kind !== "category" || !row.badge)
185
+ throw new Error("Company badge missing");
186
+ expect(row.badge.startsWith("by ")).toBe(true);
187
+ expect(row.badge.endsWith("…")).toBe(true);
188
+ expect(Bun.stringWidth(row.badge.slice(3))).toBeLessThanOrEqual(24);
189
+ });
190
+
191
+ test("R1.6 TEST-5 company marketplaces sort before ordinary and MadAppGang marketplaces", () => {
192
+ const rows = buildPluginBrowserItems({
193
+ marketplaces: [
194
+ marketplace({
195
+ name: "ordinary",
196
+ company: false,
197
+ source: { source: "github", repo: "other/ordinary" },
198
+ }),
199
+ marketplace({
200
+ name: "magus",
201
+ company: false,
202
+ source: { source: "github", repo: "MadAppGang/magus" },
203
+ }),
204
+ marketplace({ name: "company-tools", ownerName: "10xlabs" }),
205
+ ],
206
+ plugins: ["ordinary", "magus", "company-tools"].map((name) => ({
207
+ id: `demo@${name}`,
208
+ name: "demo",
209
+ version: "1.0.0",
210
+ description: "Demonstration plugin",
211
+ marketplace: name,
212
+ marketplaceDisplay: name,
213
+ author: { name: "Publisher" },
214
+ })),
215
+ collapsedMarketplaces: new Set(),
216
+ }).filter((item) => item.kind === "category");
217
+ expect(rows.map((row) => row.marketplace.name).sort()).toEqual([
218
+ "company-tools",
219
+ "magus",
220
+ "ordinary",
221
+ ]);
222
+ expect(rows[0]?.marketplace.name).toBe("company-tools");
223
+ });
224
+ });
@@ -0,0 +1,197 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdirSync, symlinkSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import type { LocalItem } from "../services/market-types.js";
5
+ import {
6
+ MarketFixture,
7
+ SKILL,
8
+ sourceModule,
9
+ write,
10
+ writeJson,
11
+ } from "./market-fixture.js";
12
+
13
+ let f: MarketFixture | undefined;
14
+ afterEach(() => {
15
+ f?.close();
16
+ f = undefined;
17
+ });
18
+
19
+ function discover(
20
+ world: MarketFixture,
21
+ companySkills: string[] = [],
22
+ ): LocalItem[] {
23
+ return world.value<LocalItem[]>(
24
+ `const {localItems}=await import(${JSON.stringify(sourceModule("services/market-local.js"))});console.log(JSON.stringify(await localItems(${JSON.stringify(world.project)},new Set(${JSON.stringify(companySkills)}))));`,
25
+ );
26
+ }
27
+
28
+ test("R4.1 TEST-31 absent skills roots yield no local items and create no folders", () => {
29
+ f = new MarketFixture(true);
30
+ const before = f.snapshot();
31
+
32
+ expect(discover(f)).toEqual([]);
33
+ expect(f.snapshot()).toEqual(before);
34
+ });
35
+
36
+ test("R4.1 TEST-31 localItems detects both scopes, distinguishes plugins, and sorts by name then scope", () => {
37
+ f = new MarketFixture(true);
38
+ const projectAlpha = f.folder("project", "alpha");
39
+ const userAlpha = f.folder("global", "alpha");
40
+ write(join(projectAlpha, "SKILL.md"), SKILL);
41
+ write(join(userAlpha, "SKILL.md"), SKILL);
42
+ const renamedPlugin = f.folder("project", "folder-label");
43
+ writeJson(join(renamedPlugin, ".claude-plugin", "plugin.json"), {
44
+ name: "beta-kit",
45
+ version: "1.2.3",
46
+ });
47
+ write(join(renamedPlugin, "SKILL.md"), SKILL);
48
+ const fallbackPlugin = f.folder("global", "gamma-kit");
49
+ writeJson(join(fallbackPlugin, ".claude-plugin", "plugin.json"), {
50
+ version: "1.0.0",
51
+ });
52
+ write(join(f.folder("global", "zeta"), "SKILL.md"), SKILL);
53
+ write(
54
+ join(f.folder("project", "not-a-skill"), "README.md"),
55
+ "No skill entry point\n",
56
+ );
57
+ write(join(f.project, ".claude", "skills", "regular-file"), SKILL);
58
+ write(join(f.home, ".claude", "skills", "wrong-config"), SKILL);
59
+ write(join(f.home, ".claude", "skills", "decoy", "SKILL.md"), SKILL);
60
+ const before = f.snapshot();
61
+
62
+ const items = discover(f);
63
+
64
+ expect(items).toEqual([
65
+ { kind: "skill", name: "alpha", scope: "project", path: projectAlpha },
66
+ { kind: "skill", name: "alpha", scope: "user", path: userAlpha },
67
+ { kind: "plugin", name: "beta-kit", scope: "project", path: renamedPlugin },
68
+ { kind: "plugin", name: "gamma-kit", scope: "user", path: fallbackPlugin },
69
+ {
70
+ kind: "skill",
71
+ name: "zeta",
72
+ scope: "user",
73
+ path: f.folder("global", "zeta"),
74
+ },
75
+ ]);
76
+ expect(f.snapshot()).toEqual(before);
77
+ });
78
+
79
+ test("R4.1 TEST-32 origin copies, held-by-name skills and project profile skills are omitted, not USER namesakes", () => {
80
+ f = new MarketFixture(true);
81
+ for (const scope of ["project", "global"] as const) {
82
+ write(join(f.folder(scope, "company-name"), "SKILL.md"), SKILL);
83
+ write(join(f.folder(scope, "origin-copy"), "SKILL.md"), SKILL);
84
+ write(
85
+ join(f.folder(scope, "origin-copy"), ".magus-origin"),
86
+ "Even an unrecognized token excludes this copy\n",
87
+ );
88
+ write(join(f.folder(scope, "managed"), "SKILL.md"), SKILL);
89
+ }
90
+ f.profile({}, {});
91
+ f.active({ managed: "a".repeat(64) });
92
+ const unmanaged = f.folder("project", "unmanaged");
93
+ write(join(unmanaged, "SKILL.md"), SKILL);
94
+ const before = f.snapshot();
95
+
96
+ const items = discover(f, ["company-name"]);
97
+
98
+ expect(items).toEqual([
99
+ {
100
+ kind: "skill",
101
+ name: "managed",
102
+ scope: "user",
103
+ path: f.folder("global", "managed"),
104
+ },
105
+ { kind: "skill", name: "unmanaged", scope: "project", path: unmanaged },
106
+ ]);
107
+ expect(f.snapshot()).toEqual(before);
108
+ });
109
+
110
+ test("R4.1 TEST-32 linked directories and nonregular plugin manifests are not local items", () => {
111
+ f = new MarketFixture();
112
+ const outside = join(f.root, "outside");
113
+ write(join(outside, "SKILL.md"), SKILL);
114
+ writeJson(join(outside, ".claude-plugin", "plugin.json"), {
115
+ name: "outside-kit",
116
+ });
117
+ mkdirSync(join(f.project, ".claude", "skills"), { recursive: true });
118
+ mkdirSync(join(f.config, "skills"), { recursive: true });
119
+ symlinkSync(outside, f.folder("project", "linked-project"));
120
+ symlinkSync(outside, f.folder("global", "linked-user"));
121
+ const linkedManifest = join(
122
+ f.folder("project", "linked-manifest"),
123
+ ".claude-plugin",
124
+ );
125
+ mkdirSync(linkedManifest, { recursive: true });
126
+ symlinkSync(
127
+ join(outside, ".claude-plugin", "plugin.json"),
128
+ join(linkedManifest, "plugin.json"),
129
+ );
130
+ mkdirSync(
131
+ join(
132
+ f.folder("project", "directory-manifest"),
133
+ ".claude-plugin",
134
+ "plugin.json",
135
+ ),
136
+ { recursive: true },
137
+ );
138
+ const before = f.snapshot();
139
+
140
+ expect(discover(f)).toEqual([]);
141
+ expect(f.snapshot()).toEqual(before);
142
+ });
143
+
144
+ test.each([false, true])(
145
+ "R4.1 R4.4 TEST-33 text and JSON list the same local items; company marketplace=%s",
146
+ (withCompany) => {
147
+ f = new MarketFixture(true);
148
+ const repo = withCompany ? f.seed() : undefined;
149
+ if (repo) {
150
+ f.register(repo);
151
+ write(join(f.folder(), "SKILL.md"), SKILL);
152
+ }
153
+ const skill = f.folder("project", "authoring");
154
+ const plugin = f.folder("global", "personal-kit");
155
+ write(
156
+ join(skill, "SKILL.md"),
157
+ "---\nname: authoring\n---\nWrite guides.\n",
158
+ );
159
+ writeJson(join(plugin, ".claude-plugin", "plugin.json"), {
160
+ name: "personal-kit",
161
+ version: "1.0.0",
162
+ });
163
+ write(join(f.folder("project", "tracked-copy"), "SKILL.md"), SKILL);
164
+ write(
165
+ join(f.folder("project", "tracked-copy"), ".magus-origin"),
166
+ "tracked\n",
167
+ );
168
+ const before = f.snapshot();
169
+ const head = repo ? f.head(repo) : undefined;
170
+
171
+ const json = f.market(["list"]);
172
+ const text = f.cli(["market", "list"]);
173
+
174
+ expect(json.code).toBe(0);
175
+ expect(json.result.local).toEqual([
176
+ { kind: "skill", name: "authoring", scope: "project", path: skill },
177
+ { kind: "plugin", name: "personal-kit", scope: "user", path: plugin },
178
+ ]);
179
+ expect(json.result.marketplaces?.map((entry) => entry.name)).toEqual(
180
+ withCompany ? ["acme"] : [],
181
+ );
182
+ expect(text.code).toBe(0);
183
+ expect(text.out).toContain("authoring");
184
+ expect(text.out).toContain("personal-kit");
185
+ expect(text.out).toContain("PROJECT");
186
+ expect(text.out).toContain("USER");
187
+ expect(text.out).not.toContain("tracked-copy");
188
+ if (repo) {
189
+ expect(text.out.indexOf("authoring")).toBeGreaterThan(
190
+ text.out.indexOf("acme"),
191
+ );
192
+ expect(f.head(repo)).toBe(head!);
193
+ }
194
+ expect(f.snapshot()).toEqual(before);
195
+ },
196
+ 30_000,
197
+ );