magus-cli 7.5.2 → 7.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/blackbox-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/gitignore-check.test.ts +11 -29
- package/src/__tests__/gitignore-profile.test.ts +248 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/models-cli.test.ts +11 -0
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-skills.test.ts +17 -22
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-state.test.ts +152 -164
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +239 -70
- package/src/cli/doctor.ts +7 -20
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +7 -3
- package/src/cli/router.ts +6 -4
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +118 -6
- package/src/cli/update.ts +10 -1
- package/src/services/claude-cli.ts +0 -24
- package/src/services/claude-settings.ts +28 -7
- package/src/services/gitignore-check.ts +37 -49
- package/src/services/gitignore-resolver.ts +10 -1
- package/src/services/gitignore-service.ts +67 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +41 -24
- package/src/services/profile-activate.ts +102 -127
- package/src/services/profile-startup.ts +160 -4
- package/src/services/profile-state.ts +7 -68
- package/src/services/tracked-live-files.ts +109 -74
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +41 -35
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +63 -17
- package/src/ui/hooks/useProfileStartupModal.ts +94 -9
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +30 -52
- package/src/ui/screens/ProfilesScreen.tsx +3 -10
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec 7.5.4 v3 — REQ-4: at each scope a plugin is installed or not, and the list row
|
|
3
|
+
* and the detail pane say the same thing.
|
|
4
|
+
*
|
|
5
|
+
* Written blind: from the spec and the exported signatures and interfaces only. Both
|
|
6
|
+
* surfaces are rendered into a character frame and read the way a user reads them.
|
|
7
|
+
*
|
|
8
|
+
* The item's `status` is `null` throughout: `PluginUpdateItem` is not part of the
|
|
9
|
+
* contract this test was given, and the spec derives every scope's display from its
|
|
10
|
+
* `ScopeStatus` alone.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
14
|
+
import { type ReactNode, act } from "react";
|
|
15
|
+
import type { PluginInfo, ScopeStatus } from "../services/plugin-manager.js";
|
|
16
|
+
import type { PluginPluginItem } from "../ui/adapters/pluginsAdapter.js";
|
|
17
|
+
import {
|
|
18
|
+
renderPluginDetail,
|
|
19
|
+
renderPluginRow,
|
|
20
|
+
} from "../ui/renderers/pluginRenderers.js";
|
|
21
|
+
|
|
22
|
+
/** Newer than any scope's version, so an "update available" claim would be plausible. */
|
|
23
|
+
const CATALOG_VERSION = "0.9.0";
|
|
24
|
+
|
|
25
|
+
interface Scopes {
|
|
26
|
+
user?: ScopeStatus;
|
|
27
|
+
project?: ScopeStatus;
|
|
28
|
+
local?: ScopeStatus;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SCOPE_NAMES = ["User", "Project", "Local"] as const;
|
|
32
|
+
type ScopeName = (typeof SCOPE_NAMES)[number];
|
|
33
|
+
const CHIP: Record<ScopeName, string> = { User: "u", Project: "p", Local: "l" };
|
|
34
|
+
|
|
35
|
+
function pluginItem(scopes: Scopes): PluginPluginItem {
|
|
36
|
+
// The version that loads here: undefined unless some scope has a real install.
|
|
37
|
+
const loaded = [scopes.user, scopes.project, scopes.local].find(
|
|
38
|
+
(s) => s?.installed === true && s.version !== undefined,
|
|
39
|
+
);
|
|
40
|
+
const plugin: PluginInfo = {
|
|
41
|
+
id: "scopeprobe@magus",
|
|
42
|
+
name: "scopeprobe",
|
|
43
|
+
version: CATALOG_VERSION,
|
|
44
|
+
description: "Probe plugin for scope rendering.",
|
|
45
|
+
marketplace: "magus",
|
|
46
|
+
marketplaceDisplay: "Magus",
|
|
47
|
+
installedVersion: loaded?.version,
|
|
48
|
+
userScope: scopes.user,
|
|
49
|
+
projectScope: scopes.project,
|
|
50
|
+
localScope: scopes.local,
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
id: `plugin:${plugin.id}`,
|
|
54
|
+
kind: "plugin",
|
|
55
|
+
label: plugin.name,
|
|
56
|
+
plugin,
|
|
57
|
+
isExpandable: false,
|
|
58
|
+
isExpanded: false,
|
|
59
|
+
status: null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function frameOf(
|
|
64
|
+
node: ReactNode,
|
|
65
|
+
width: number,
|
|
66
|
+
height: number,
|
|
67
|
+
): Promise<string[]> {
|
|
68
|
+
const { captureCharFrame, renderOnce, renderer } = await testRender(
|
|
69
|
+
<box flexDirection="column">{node}</box>,
|
|
70
|
+
{ width, height },
|
|
71
|
+
);
|
|
72
|
+
await act(async () => {
|
|
73
|
+
await renderOnce();
|
|
74
|
+
await renderOnce();
|
|
75
|
+
});
|
|
76
|
+
const lines = captureCharFrame().split("\n");
|
|
77
|
+
await act(async () => renderer.destroy());
|
|
78
|
+
return lines;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function detailFrame(item: PluginPluginItem): Promise<string[]> {
|
|
82
|
+
return frameOf(renderPluginDetail(item, new Set<string>()), 120, 80);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function rowFrame(
|
|
86
|
+
item: PluginPluginItem,
|
|
87
|
+
isSelected: boolean,
|
|
88
|
+
): Promise<string[]> {
|
|
89
|
+
return frameOf(renderPluginRow(item, 0, isSelected), 120, 3);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The `User` / `Project` / `Local` line under `Scopes:`. */
|
|
93
|
+
function scopeLine(frame: string[], name: ScopeName): string {
|
|
94
|
+
const header = frame.findIndex((l) => l.includes("Scopes:"));
|
|
95
|
+
if (header === -1) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`no "Scopes:" section in the detail pane:\n${frame.join("\n")}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const pattern = new RegExp(`\\b${name}\\b`);
|
|
101
|
+
const line = frame.slice(header + 1).find((l) => pattern.test(l));
|
|
102
|
+
if (line === undefined) {
|
|
103
|
+
throw new Error(`no ${name} line under "Scopes:":\n${frame.join("\n")}`);
|
|
104
|
+
}
|
|
105
|
+
return line;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function dotOf(line: string): string | undefined {
|
|
109
|
+
return /[●○]/.exec(line)?.[0];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const V = "0.8.1";
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Detail pane, one scope at a time (REQ-4a..d on the Project line)
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
describe("REQ-4 the detail pane's Project line", () => {
|
|
119
|
+
test("REQ-4a installed with a version: ● and v0.8.1, not files missing", async () => {
|
|
120
|
+
const frame = await detailFrame(
|
|
121
|
+
pluginItem({ project: { installed: true, version: V } }),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const line = scopeLine(frame, "Project");
|
|
125
|
+
expect(dotOf(line)).toBe("●");
|
|
126
|
+
expect(line).toContain(`v${V}`);
|
|
127
|
+
expect(line).not.toContain("files missing");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("REQ-4b not installed but a version recorded: ○, no 0.8.1, and never the word installed", async () => {
|
|
131
|
+
const frame = await detailFrame(
|
|
132
|
+
pluginItem({ project: { installed: false, version: V } }),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const line = scopeLine(frame, "Project");
|
|
136
|
+
expect(dotOf(line)).toBe("○");
|
|
137
|
+
expect(line).not.toContain(V);
|
|
138
|
+
expect(line).not.toMatch(/\binstalled\b/i);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("REQ-4c installed with no version: ● and files missing", async () => {
|
|
142
|
+
const frame = await detailFrame(
|
|
143
|
+
pluginItem({ project: { installed: true } }),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const line = scopeLine(frame, "Project");
|
|
147
|
+
expect(dotOf(line)).toBe("●");
|
|
148
|
+
expect(line).toContain("files missing");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("REQ-4d { installed: false } with no version: ○ and none of version, files missing, installed", async () => {
|
|
152
|
+
const frame = await detailFrame(
|
|
153
|
+
pluginItem({ project: { installed: false } }),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const line = scopeLine(frame, "Project");
|
|
157
|
+
expect(dotOf(line)).toBe("○");
|
|
158
|
+
expect(line).not.toContain(V);
|
|
159
|
+
expect(line).not.toContain("files missing");
|
|
160
|
+
expect(line).not.toMatch(/\binstalled\b/i);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("REQ-4d an undefined scope: ○ and none of version, files missing, installed", async () => {
|
|
164
|
+
const frame = await detailFrame(pluginItem({}));
|
|
165
|
+
|
|
166
|
+
const line = scopeLine(frame, "Project");
|
|
167
|
+
expect(dotOf(line)).toBe("○");
|
|
168
|
+
expect(line).not.toContain(V);
|
|
169
|
+
expect(line).not.toContain("files missing");
|
|
170
|
+
expect(line).not.toMatch(/\binstalled\b/i);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Structure and vocabulary of every scope line (REQ-4 layout, REQ-4e)
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
const STATES: Array<[label: string, status: ScopeStatus | undefined]> = [
|
|
179
|
+
["installed with version", { installed: true, version: V }],
|
|
180
|
+
["not installed, version recorded", { installed: false, version: V }],
|
|
181
|
+
["installed, no version", { installed: true }],
|
|
182
|
+
["not installed, no version", { installed: false }],
|
|
183
|
+
["undefined", undefined],
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
describe("REQ-4 every scope line is chip, dot, name — and never says enabled", () => {
|
|
187
|
+
for (const [label, status] of STATES) {
|
|
188
|
+
test(`REQ-4 / REQ-4e scope lines for "${label}" at every scope`, async () => {
|
|
189
|
+
for (const scope of SCOPE_NAMES) {
|
|
190
|
+
const scopes: Scopes = {};
|
|
191
|
+
if (scope === "User") scopes.user = status;
|
|
192
|
+
if (scope === "Project") scopes.project = status;
|
|
193
|
+
if (scope === "Local") scopes.local = status;
|
|
194
|
+
|
|
195
|
+
const frame = await detailFrame(pluginItem(scopes));
|
|
196
|
+
|
|
197
|
+
for (const name of SCOPE_NAMES) {
|
|
198
|
+
const line = scopeLine(frame, name);
|
|
199
|
+
const layout = new RegExp(
|
|
200
|
+
`(?:^|[^A-Za-z0-9])${CHIP[name]}[^A-Za-z0-9●○]*[●○]\\s*${name}\\b`,
|
|
201
|
+
);
|
|
202
|
+
expect({ scope, name, line, layout: layout.test(line) }).toEqual({
|
|
203
|
+
scope,
|
|
204
|
+
name,
|
|
205
|
+
line,
|
|
206
|
+
layout: true,
|
|
207
|
+
});
|
|
208
|
+
expect({ scope, name, line, enabled: /enabled/i.test(line) }).toEqual(
|
|
209
|
+
{ scope, name, line, enabled: false },
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// The list row agrees with the detail pane (REQ-4f)
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
describe("REQ-4f the list row's squares equal the detail pane's dots", () => {
|
|
222
|
+
const combos: Array<[boolean, boolean, boolean]> = [];
|
|
223
|
+
for (const u of [false, true]) {
|
|
224
|
+
for (const p of [false, true]) {
|
|
225
|
+
for (const l of [false, true]) combos.push([u, p, l]);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const isSelected of [false, true]) {
|
|
230
|
+
for (const [u, p, l] of combos) {
|
|
231
|
+
const name = `user=${u} project=${p} local=${l}`;
|
|
232
|
+
test(`REQ-4f ${name}, isSelected=${isSelected}`, async () => {
|
|
233
|
+
const item = pluginItem({
|
|
234
|
+
user: { installed: u, version: V },
|
|
235
|
+
project: { installed: p, version: V },
|
|
236
|
+
local: { installed: l, version: V },
|
|
237
|
+
});
|
|
238
|
+
const expectedSquares = [u, p, l].map((on) => (on ? "■" : "□"));
|
|
239
|
+
const expectedDots = [u, p, l].map((on) => (on ? "●" : "○"));
|
|
240
|
+
|
|
241
|
+
const row = await rowFrame(item, isSelected);
|
|
242
|
+
const detail = await detailFrame(item);
|
|
243
|
+
|
|
244
|
+
const squares = row.join("\n").match(/[■□]/g) ?? [];
|
|
245
|
+
const dots = SCOPE_NAMES.map((scope) =>
|
|
246
|
+
dotOf(scopeLine(detail, scope)),
|
|
247
|
+
);
|
|
248
|
+
expect(squares).toEqual(expectedSquares);
|
|
249
|
+
expect(dots).toEqual(expectedDots);
|
|
250
|
+
expect(squares.map((s) => (s === "■" ? "●" : "○"))).toEqual(dots);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// No phantom version claim (REQ-4g)
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
describe("REQ-4g a recorded-but-not-installed version is not presented as installed", () => {
|
|
261
|
+
test("REQ-4g projectScope { installed: false, version: 0.8.1 } alone: no UPDATE AVAILABLE, no 0.8.1 installed", async () => {
|
|
262
|
+
const item = pluginItem({ project: { installed: false, version: V } });
|
|
263
|
+
expect(item.plugin.installedVersion).toBeUndefined();
|
|
264
|
+
|
|
265
|
+
const frame = (await detailFrame(item)).join("\n");
|
|
266
|
+
|
|
267
|
+
expect(frame).not.toMatch(/update available/i);
|
|
268
|
+
expect(frame).not.toContain(`${V} installed`);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -47,7 +47,6 @@ function info(over: Partial<PluginInfo> & { id: string }): PluginInfo {
|
|
|
47
47
|
description: "",
|
|
48
48
|
marketplace,
|
|
49
49
|
marketplaceDisplay: "",
|
|
50
|
-
enabled: true,
|
|
51
50
|
author: { name: "Anthropic" },
|
|
52
51
|
...over,
|
|
53
52
|
} as PluginInfo;
|
|
@@ -58,7 +57,7 @@ const CODE_REVIEW = info({
|
|
|
58
57
|
id: "code-review@claude-plugins-official",
|
|
59
58
|
version: null, // the official catalog publishes no version for it
|
|
60
59
|
installedVersion: "3ea32df27be7",
|
|
61
|
-
projectScope: {
|
|
60
|
+
projectScope: { installed: true, version: "3ea32df27be7" },
|
|
62
61
|
});
|
|
63
62
|
|
|
64
63
|
/** A registry "0.0.0" — the value the old row hid every status behind. */
|
|
@@ -66,7 +65,7 @@ const ZERO = info({
|
|
|
66
65
|
id: "zero-version@claude-plugins-official",
|
|
67
66
|
version: null,
|
|
68
67
|
installedVersion: "0.0.0",
|
|
69
|
-
projectScope: {
|
|
68
|
+
projectScope: { installed: true, version: "0.0.0" },
|
|
70
69
|
});
|
|
71
70
|
|
|
72
71
|
/** Other listed ids, so each marketplace demonstrably was read. */
|
|
@@ -147,7 +146,7 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
|
|
|
147
146
|
version: "1.8.0",
|
|
148
147
|
installedVersion: "1.8.0",
|
|
149
148
|
isOrphaned: true,
|
|
150
|
-
projectScope: {
|
|
149
|
+
projectScope: { installed: true, version: "1.8.0" },
|
|
151
150
|
});
|
|
152
151
|
const row = rows([seo]).get(seo.id) as PluginPluginItem;
|
|
153
152
|
expect(cliNote([seo], seo.id)).toBe(
|
|
@@ -165,7 +164,7 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
|
|
|
165
164
|
version: "2.0.0",
|
|
166
165
|
installedVersion: "2.0.0",
|
|
167
166
|
isOrphaned: true,
|
|
168
|
-
projectScope: {
|
|
167
|
+
projectScope: { installed: true, version: "2.0.0" },
|
|
169
168
|
});
|
|
170
169
|
const row = rows([gone]).get(gone.id) as PluginPluginItem;
|
|
171
170
|
expect(pluginRowText(row).text).toBe(
|
|
@@ -181,7 +180,7 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
|
|
|
181
180
|
id: "seo@magus",
|
|
182
181
|
installedVersion: "1.8.0",
|
|
183
182
|
isOrphaned: true,
|
|
184
|
-
projectScope: {
|
|
183
|
+
projectScope: { installed: true, version: "1.8.0" },
|
|
185
184
|
});
|
|
186
185
|
const { text } = pluginRowText(rows([seo]).get(seo.id) as PluginPluginItem);
|
|
187
186
|
expect(text).not.toContain("deprecated");
|
|
@@ -195,7 +194,7 @@ describe("pluginRowText — history badges combine with the state", () => {
|
|
|
195
194
|
id: "terminal@magus",
|
|
196
195
|
version: "3.3.1",
|
|
197
196
|
installedVersion: "3.0.1",
|
|
198
|
-
projectScope: {
|
|
197
|
+
projectScope: { installed: true, version: "3.0.1" },
|
|
199
198
|
author: { name: "MadAppGang" },
|
|
200
199
|
...over,
|
|
201
200
|
});
|
|
@@ -220,7 +219,7 @@ describe("pluginRowText — history badges combine with the state", () => {
|
|
|
220
219
|
recentlyInstalled: true,
|
|
221
220
|
version: "0.0.0",
|
|
222
221
|
installedVersion: "0.0.0",
|
|
223
|
-
projectScope: {
|
|
222
|
+
projectScope: { installed: true, version: "0.0.0" },
|
|
224
223
|
});
|
|
225
224
|
const row = rows([p]).get(p.id) as PluginPluginItem;
|
|
226
225
|
expect(pluginRowText(row).text).toBe(" new");
|
|
@@ -263,7 +262,7 @@ describe("pluginRowText — rows with no state claim none", () => {
|
|
|
263
262
|
});
|
|
264
263
|
});
|
|
265
264
|
|
|
266
|
-
describe("pluginStatusText — absent is not
|
|
265
|
+
describe("pluginStatusText — absent is not files-missing (LOW 6)", () => {
|
|
267
266
|
const status = (over: Partial<PluginUpdateItem>): PluginUpdateItem => ({
|
|
268
267
|
pluginId: "x@magus",
|
|
269
268
|
pinned: "latest",
|
|
@@ -285,32 +284,30 @@ describe("pluginStatusText — absent is not enabled-not-installed (LOW 6)", ()
|
|
|
285
284
|
});
|
|
286
285
|
});
|
|
287
286
|
|
|
288
|
-
test("
|
|
287
|
+
test("files-missing shows its note and never claims a declaration", () => {
|
|
289
288
|
const got = pluginStatusText(
|
|
290
289
|
status({
|
|
291
|
-
reason: "
|
|
292
|
-
note: "
|
|
290
|
+
reason: "files-missing",
|
|
291
|
+
note: "files missing",
|
|
293
292
|
}),
|
|
294
293
|
);
|
|
295
294
|
expect(got).toEqual({
|
|
296
|
-
suffix: "
|
|
295
|
+
suffix: " files missing",
|
|
297
296
|
tone: "danger",
|
|
298
297
|
});
|
|
299
298
|
expect(got.suffix).not.toContain("profile");
|
|
300
299
|
});
|
|
301
300
|
|
|
302
|
-
test("an undeclared
|
|
301
|
+
test("an undeclared files-missing ROW gets the files-missing text", () => {
|
|
303
302
|
const broken = info({
|
|
304
303
|
id: "kanban@magus",
|
|
305
304
|
version: "1.6.2",
|
|
306
305
|
author: { name: "MadAppGang" },
|
|
307
|
-
localScope: {
|
|
306
|
+
localScope: { installed: true },
|
|
308
307
|
});
|
|
309
308
|
const row = rows([broken]).get(broken.id) as PluginPluginItem;
|
|
310
|
-
expect(row.status?.reason).toBe("
|
|
311
|
-
expect(pluginRowText(row).text).toBe(
|
|
312
|
-
" not installed (enabled in settings, but no files on disk)",
|
|
313
|
-
);
|
|
309
|
+
expect(row.status?.reason).toBe("files-missing");
|
|
310
|
+
expect(pluginRowText(row).text).toBe(" files missing");
|
|
314
311
|
});
|
|
315
312
|
|
|
316
313
|
test("null status says nothing", () => {
|
|
@@ -357,7 +354,7 @@ describe("pluginRowText — a broken plugin says so, and names the key", () => {
|
|
|
357
354
|
version: "0.5.0",
|
|
358
355
|
installedVersion: "0.5.0",
|
|
359
356
|
loadedVersion: "0.3.0",
|
|
360
|
-
projectScope: {
|
|
357
|
+
projectScope: { installed: true, version: "0.5.0" },
|
|
361
358
|
author: { name: "MadAppGang" },
|
|
362
359
|
});
|
|
363
360
|
|
|
@@ -399,7 +396,7 @@ describe("pluginRowText — a broken plugin says so, and names the key", () => {
|
|
|
399
396
|
version: "0.5.0",
|
|
400
397
|
installedVersion: "0.5.0",
|
|
401
398
|
loadedVersion: "0.5.0",
|
|
402
|
-
projectScope: {
|
|
399
|
+
projectScope: { installed: true, version: "0.5.0" },
|
|
403
400
|
author: { name: "MadAppGang" },
|
|
404
401
|
});
|
|
405
402
|
const row = rows([healthy]).get("madbench@magus");
|
|
@@ -136,7 +136,7 @@ describe("profile switch", () => {
|
|
|
136
136
|
expect((await fs.readJson(live)).enabledPlugins["terminal@magus"]).toBe(
|
|
137
137
|
true,
|
|
138
138
|
);
|
|
139
|
-
expect(logs.some((l) => l.includes("
|
|
139
|
+
expect(logs.some((l) => l.includes("Backed up"))).toBe(false);
|
|
140
140
|
expect(await fs.pathExists(join(dir, ".claude", ".magus-backups"))).toBe(
|
|
141
141
|
false,
|
|
142
142
|
);
|
|
@@ -147,7 +147,7 @@ describe("profile switch", () => {
|
|
|
147
147
|
await switchProfile("frontend", dir);
|
|
148
148
|
await switchProfile("backend", dir);
|
|
149
149
|
await switchProfile("frontend", dir);
|
|
150
|
-
expect(logs.some((l) => l.includes("
|
|
150
|
+
expect(logs.some((l) => l.includes("Backed up"))).toBe(false);
|
|
151
151
|
expect(await fs.pathExists(join(dir, ".claude", ".magus-backups"))).toBe(
|
|
152
152
|
false,
|
|
153
153
|
);
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* update` carry on with a profile "for this run only" — resolving against a
|
|
7
7
|
* manifest the project was not running. Two worlds live at once.
|
|
8
8
|
*
|
|
9
|
-
* The interactive path
|
|
10
|
-
* IS testable is every path that must refuse, which is the
|
|
11
|
-
* changed.
|
|
12
|
-
* the refusal branch by construction — which is also what
|
|
9
|
+
* The interactive path reads a numbered answer from a terminal and cannot run
|
|
10
|
+
* here; what IS testable is every path that must refuse, which is the
|
|
11
|
+
* behaviour that changed. Without a TTY the gate refuses before it asks, so a
|
|
12
|
+
* non-TTY run takes the refusal branch by construction — which is also what
|
|
13
|
+
* CI does.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
activationNotes,
|
|
18
18
|
recordRefetchedSkills,
|
|
19
19
|
trackFetchedSkills,
|
|
20
|
+
trackedGeneratedNotices,
|
|
20
21
|
updatableSkills,
|
|
21
22
|
} from "../services/profile-activate.js";
|
|
22
23
|
import {
|
|
@@ -360,7 +361,7 @@ describe("older layout: committed custom skills the seeding displaced come back"
|
|
|
360
361
|
});
|
|
361
362
|
|
|
362
363
|
expect(prep.lines).toContain(
|
|
363
|
-
'
|
|
364
|
+
'Updated profile "team" to the current profile format',
|
|
364
365
|
);
|
|
365
366
|
expect(
|
|
366
367
|
git(
|
|
@@ -459,42 +460,36 @@ describe("older layout: committed custom skills the seeding displaced come back"
|
|
|
459
460
|
expect(again.problems).toEqual([]);
|
|
460
461
|
});
|
|
461
462
|
|
|
462
|
-
test("review
|
|
463
|
+
test("B7 review: a magus-installed folder someone committed is handed back to the team — kept, never warned about", async () => {
|
|
463
464
|
await start();
|
|
464
465
|
expect(git(project, "init", "-q").status).toBe(0);
|
|
465
466
|
expect(git(project, "add", "-f", ".claude/skills/alpha").status).toBe(0);
|
|
466
467
|
expect(git(project, "commit", "-q", "-m", "oops").status).toBe(0);
|
|
467
|
-
|
|
468
|
-
//
|
|
468
|
+
expect(await trackedGeneratedNotices(project)).toEqual([]);
|
|
469
|
+
// A teammate drops alpha from the profile. Were it still magus's, the
|
|
470
|
+
// reconcile would delete the team's committed folder.
|
|
469
471
|
const pulled = manifest();
|
|
470
472
|
(pulled.profiles.team as { skills: ProfileSkillRef[] }).skills = [];
|
|
471
473
|
await writeManifest(pulled, project);
|
|
472
|
-
const marker = await fs.readFile(
|
|
473
|
-
join(project, ".claude", "profiles", "active.json"),
|
|
474
|
-
"utf8",
|
|
475
|
-
);
|
|
476
|
-
const manifestBytes = await fs.readFile(
|
|
477
|
-
join(project, ".claude", "profiles.json"),
|
|
478
|
-
"utf8",
|
|
479
|
-
);
|
|
480
474
|
|
|
481
475
|
const prep = await prepareProfileState(project, {
|
|
482
476
|
installSkill: fakeInstall,
|
|
483
477
|
});
|
|
484
478
|
|
|
485
|
-
expect(prep.problems[
|
|
486
|
-
|
|
479
|
+
expect(prep.problems).toEqual([]);
|
|
480
|
+
expect(prep.lines).toContain(
|
|
481
|
+
"Skill alpha is committed by the team — magus no longer manages it",
|
|
487
482
|
);
|
|
488
483
|
expect(await fs.pathExists(skill("alpha"))).toBe(true);
|
|
484
|
+
expect((await readActiveMarker(project))?.skills).toEqual({});
|
|
489
485
|
expect(
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
).toBe(manifestBytes);
|
|
486
|
+
git(project, "status", "--porcelain", "--", ".claude/skills").stdout,
|
|
487
|
+
).toBe("");
|
|
488
|
+
// Said once: the marker no longer records it.
|
|
489
|
+
const again = await prepareProfileState(project, {
|
|
490
|
+
installSkill: fakeInstall,
|
|
491
|
+
});
|
|
492
|
+
expect(again.lines.join("\n")).not.toContain("committed by the team");
|
|
498
493
|
});
|
|
499
494
|
|
|
500
495
|
test("an existing folder is never overwritten; a differing copy is reported with both paths", async () => {
|