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
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* market-ops-skill.ts — plans for `skill add`, `skill edit` and
|
|
3
|
+
* `skill remove`.
|
|
4
|
+
*
|
|
5
|
+
* Read-only planners. Each plan syncs magus's working clone in its apply and
|
|
6
|
+
* is planned again there before its first write.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { checkIgnore } from "./git-write.js";
|
|
12
|
+
import { ORIGIN_FILE, forgetBasesFor, recordBase } from "./market-bases.js";
|
|
13
|
+
import {
|
|
14
|
+
type ChangeSpec,
|
|
15
|
+
changeSteps,
|
|
16
|
+
cloneOf,
|
|
17
|
+
deliveryTarget,
|
|
18
|
+
linkRefusal,
|
|
19
|
+
targetLine,
|
|
20
|
+
writeJson,
|
|
21
|
+
} from "./market-change.js";
|
|
22
|
+
import { type Snapshot, cloneUrl } from "./market-clone.js";
|
|
23
|
+
import {
|
|
24
|
+
copyFileUnder,
|
|
25
|
+
linkUnder,
|
|
26
|
+
refuseLinkUnder,
|
|
27
|
+
removeUnder,
|
|
28
|
+
} from "./market-fs.js";
|
|
29
|
+
import {
|
|
30
|
+
SYMLINK_MODE,
|
|
31
|
+
type SkillFile,
|
|
32
|
+
addPluginEntry,
|
|
33
|
+
currentVersion,
|
|
34
|
+
findSkillOwners,
|
|
35
|
+
inDomain,
|
|
36
|
+
joinRel,
|
|
37
|
+
nextVersion,
|
|
38
|
+
pluginEntries,
|
|
39
|
+
pluginRelDir,
|
|
40
|
+
pluginRootOf,
|
|
41
|
+
removePluginEntry,
|
|
42
|
+
setEntryVersion,
|
|
43
|
+
treeHash,
|
|
44
|
+
validPackageName,
|
|
45
|
+
validSkillName,
|
|
46
|
+
} from "./market-layout.js";
|
|
47
|
+
import {
|
|
48
|
+
type MarketCommand,
|
|
49
|
+
computePlanId,
|
|
50
|
+
freshSnapshot,
|
|
51
|
+
manifestAt,
|
|
52
|
+
planError,
|
|
53
|
+
resolvePluginRef,
|
|
54
|
+
} from "./market-ops.js";
|
|
55
|
+
import {
|
|
56
|
+
type InstalledAt,
|
|
57
|
+
installReaches,
|
|
58
|
+
installedScopes,
|
|
59
|
+
moveInstalls,
|
|
60
|
+
offHereReason,
|
|
61
|
+
profileManaged,
|
|
62
|
+
readSourceSets,
|
|
63
|
+
removeLocalCopy,
|
|
64
|
+
settleLocalCopy,
|
|
65
|
+
} from "./market-publish.js";
|
|
66
|
+
import {
|
|
67
|
+
type TrustedRegistration,
|
|
68
|
+
listCompanyMarketplaces,
|
|
69
|
+
} from "./market-registry.js";
|
|
70
|
+
import {
|
|
71
|
+
type FileChange,
|
|
72
|
+
type LocalCopy,
|
|
73
|
+
MarketError,
|
|
74
|
+
type MarketPlan,
|
|
75
|
+
type MarketStep,
|
|
76
|
+
type PlanAnswer,
|
|
77
|
+
staleError,
|
|
78
|
+
} from "./market-types.js";
|
|
79
|
+
import { fastForwardCloneIfPresent } from "./marketplace-refresh.js";
|
|
80
|
+
import { getUserSkillsDir } from "./skills-manager.js";
|
|
81
|
+
|
|
82
|
+
import {
|
|
83
|
+
MANIFEST,
|
|
84
|
+
type PluginView,
|
|
85
|
+
SCOPE_WORD,
|
|
86
|
+
bumpFrom,
|
|
87
|
+
bumpStep,
|
|
88
|
+
filesUnder,
|
|
89
|
+
installLines,
|
|
90
|
+
installStep,
|
|
91
|
+
pluginViewAt,
|
|
92
|
+
readPlugin,
|
|
93
|
+
versionFiles,
|
|
94
|
+
versionLine,
|
|
95
|
+
} from "./market-ops-content.js";
|
|
96
|
+
import {
|
|
97
|
+
NEW_PLUGIN_VERSION,
|
|
98
|
+
newPluginLayout,
|
|
99
|
+
refreshStep,
|
|
100
|
+
writeNewPlugin,
|
|
101
|
+
} from "./market-ops-plugin.js";
|
|
102
|
+
|
|
103
|
+
// ─── skill add ───────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
export interface SkillAddArgs {
|
|
106
|
+
skill: string;
|
|
107
|
+
plugin: string;
|
|
108
|
+
from: "project" | "global";
|
|
109
|
+
keepLocal: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Create the plugin, in the same commit, when the marketplace has none of
|
|
112
|
+
* that name. Without it a missing plugin is `not-found`.
|
|
113
|
+
*/
|
|
114
|
+
createPlugin?: boolean;
|
|
115
|
+
/** The new plugin's description; read only when it is created. */
|
|
116
|
+
description?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function skillFolder(
|
|
120
|
+
cwd: string,
|
|
121
|
+
where: "project" | "global",
|
|
122
|
+
skill: string,
|
|
123
|
+
) {
|
|
124
|
+
return where === "global"
|
|
125
|
+
? path.join(getUserSkillsDir(), skill)
|
|
126
|
+
: path.join(cwd, ".claude", "skills", skill);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Refuse a skill folder destination reached through a link: a linked
|
|
131
|
+
* `.claude/skills` (or a linked skill folder) would put the copy somewhere
|
|
132
|
+
* other than the project or USER skills it names. The project folder, and
|
|
133
|
+
* the Claude config folder for USER skills, are trusted; every existing part
|
|
134
|
+
* below them is checked.
|
|
135
|
+
*/
|
|
136
|
+
export function refuseLinkedSkillFolder(
|
|
137
|
+
cwd: string,
|
|
138
|
+
where: "project" | "global",
|
|
139
|
+
skill: string,
|
|
140
|
+
): void {
|
|
141
|
+
const root = where === "global" ? path.dirname(getUserSkillsDir()) : cwd;
|
|
142
|
+
const rel =
|
|
143
|
+
where === "global"
|
|
144
|
+
? `${path.basename(getUserSkillsDir())}/${skill}`
|
|
145
|
+
: `.claude/skills/${skill}`;
|
|
146
|
+
const link = linkUnder(root, rel);
|
|
147
|
+
if (link === null) return;
|
|
148
|
+
const reached =
|
|
149
|
+
where === "global"
|
|
150
|
+
? "Your USER skills are reached through a link"
|
|
151
|
+
: `This project's skills are reached through a link at ${link}`;
|
|
152
|
+
throw new MarketError(
|
|
153
|
+
"other",
|
|
154
|
+
`${reached}, so magus will not copy ${skill} there — nothing was changed`,
|
|
155
|
+
"replace the link with a real folder, then run the command again",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function checkSkillName(skill: string): void {
|
|
160
|
+
if (!validSkillName(skill)) {
|
|
161
|
+
throw new MarketError(
|
|
162
|
+
"invalid-name",
|
|
163
|
+
`${skill} is not a skill folder name (letters, digits, dots, dashes, underscores)`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const PROFILE_KEEPS =
|
|
169
|
+
"this project's profile installs this skill folder; remove the skill from the profile in magus to use the plugin's version";
|
|
170
|
+
|
|
171
|
+
export async function planSkillAdd(
|
|
172
|
+
args: SkillAddArgs,
|
|
173
|
+
cwd: string,
|
|
174
|
+
): Promise<PlanAnswer> {
|
|
175
|
+
const command: MarketCommand = "market skill add";
|
|
176
|
+
try {
|
|
177
|
+
checkSkillName(args.skill);
|
|
178
|
+
const folder = skillFolder(cwd, args.from, args.skill);
|
|
179
|
+
const where = args.from === "global" ? "your USER skills" : "this project";
|
|
180
|
+
if (!fs.existsSync(path.join(folder, "SKILL.md"))) {
|
|
181
|
+
throw new MarketError(
|
|
182
|
+
"not-found",
|
|
183
|
+
`${where} has no skill named ${args.skill}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const ref = await resolvePluginRef(args.plugin);
|
|
187
|
+
const snapNow = await freshSnapshot(ref.m);
|
|
188
|
+
const manifestNow = await manifestAt(snapNow, ref.m.name);
|
|
189
|
+
const exists = pluginEntries(manifestNow).some(
|
|
190
|
+
(p) => p.name === ref.plugin,
|
|
191
|
+
);
|
|
192
|
+
if (!exists && !args.createPlugin) {
|
|
193
|
+
throw new MarketError(
|
|
194
|
+
"not-found",
|
|
195
|
+
`${ref.m.name} has no plugin named ${ref.plugin}`,
|
|
196
|
+
"`magus market list` shows its plugins; add --create-plugin to create it with this skill",
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
// A plugin created here starts at its first version: nothing to bump.
|
|
200
|
+
const layout = exists
|
|
201
|
+
? null
|
|
202
|
+
: newPluginLayout(ref.m, snapNow, manifestNow, ref.plugin);
|
|
203
|
+
const view: PluginView = layout
|
|
204
|
+
? {
|
|
205
|
+
m: ref.m,
|
|
206
|
+
snap: snapNow,
|
|
207
|
+
manifest: manifestNow,
|
|
208
|
+
plugin: ref.plugin,
|
|
209
|
+
dir: layout.dir,
|
|
210
|
+
pluginJsonPath: layout.pluginJsonPath,
|
|
211
|
+
pluginJson: null,
|
|
212
|
+
}
|
|
213
|
+
: await pluginViewAt(ref.m, snapNow, manifestNow, ref.plugin);
|
|
214
|
+
const { m, snap, plugin, dir } = view;
|
|
215
|
+
const skillDir = joinRel(dir, "skills", args.skill);
|
|
216
|
+
if (filesUnder(snap, skillDir).length > 0) {
|
|
217
|
+
throw new MarketError(
|
|
218
|
+
"name-taken",
|
|
219
|
+
`${plugin} already has a skill named ${args.skill}`,
|
|
220
|
+
"to send your edits to it, use `magus market publish`",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const { from, to } = layout
|
|
224
|
+
? { from: null, to: NEW_PLUGIN_VERSION }
|
|
225
|
+
: bumpFrom(view);
|
|
226
|
+
const sets = await readSourceSets(folder, snap.dir, skillDir);
|
|
227
|
+
if (sets.published.length === 0) {
|
|
228
|
+
throw new MarketError(
|
|
229
|
+
"other",
|
|
230
|
+
`Nothing in ${args.skill} can be published (every file is excluded)`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const target = await deliveryTarget(m.source, m.name);
|
|
234
|
+
const pluginId = `${plugin}@${m.name}`;
|
|
235
|
+
const installs = await installedScopes(pluginId, cwd);
|
|
236
|
+
const copyScope = args.from === "global" ? "user" : "project";
|
|
237
|
+
const managed =
|
|
238
|
+
args.from === "project" && (await profileManaged(cwd, args.skill));
|
|
239
|
+
const keepReason = args.keepLocal
|
|
240
|
+
? "--keep-local"
|
|
241
|
+
: managed
|
|
242
|
+
? PROFILE_KEEPS
|
|
243
|
+
: !installReaches(
|
|
244
|
+
copyScope,
|
|
245
|
+
installs.map((i) => i.scope),
|
|
246
|
+
)
|
|
247
|
+
? `${plugin} is not installed where this copy loads`
|
|
248
|
+
: sets.notPublished.length > 0
|
|
249
|
+
? `holds files that will not be published: ${sets.notPublished.map((n) => n.path).join(", ")}`
|
|
250
|
+
: await offHereReason(plugin, pluginId, cwd);
|
|
251
|
+
const predicted: LocalCopy = keepReason
|
|
252
|
+
? {
|
|
253
|
+
scope: copyScope,
|
|
254
|
+
name: args.skill,
|
|
255
|
+
action: "kept",
|
|
256
|
+
reason: keepReason,
|
|
257
|
+
}
|
|
258
|
+
: { scope: copyScope, name: args.skill, action: "removed" };
|
|
259
|
+
const created = layout
|
|
260
|
+
? [
|
|
261
|
+
{ path: MANIFEST, change: "modified" as const },
|
|
262
|
+
{ path: layout.pluginJsonPath, change: "added" as const },
|
|
263
|
+
]
|
|
264
|
+
: versionFiles(view);
|
|
265
|
+
const files = [
|
|
266
|
+
...sets.published.map((f) => ({
|
|
267
|
+
path: joinRel(skillDir, f.path),
|
|
268
|
+
change: "added" as const,
|
|
269
|
+
})),
|
|
270
|
+
...created,
|
|
271
|
+
];
|
|
272
|
+
const state: ChangeSpec["state"] & { moved?: boolean } = {};
|
|
273
|
+
const steps: MarketStep[] = [
|
|
274
|
+
...(layout
|
|
275
|
+
? [
|
|
276
|
+
{
|
|
277
|
+
id: "edit",
|
|
278
|
+
label: `wrote ${plugin}`,
|
|
279
|
+
outside: false,
|
|
280
|
+
async run(ctx: Parameters<MarketStep["run"]>[0]) {
|
|
281
|
+
writeNewPlugin(
|
|
282
|
+
cloneOf(ctx),
|
|
283
|
+
m,
|
|
284
|
+
view.manifest,
|
|
285
|
+
layout,
|
|
286
|
+
args.description,
|
|
287
|
+
);
|
|
288
|
+
return undefined;
|
|
289
|
+
},
|
|
290
|
+
} satisfies MarketStep,
|
|
291
|
+
]
|
|
292
|
+
: []),
|
|
293
|
+
{
|
|
294
|
+
id: "copy",
|
|
295
|
+
label: `copied ${args.skill} into ${plugin}`,
|
|
296
|
+
outside: false,
|
|
297
|
+
async run(ctx) {
|
|
298
|
+
const clone = cloneOf(ctx);
|
|
299
|
+
// A link committed at `plugins/<p>/skills`, or at the new
|
|
300
|
+
// skill's folder, would carry the copy outside the clone:
|
|
301
|
+
// every target is checked before the first write.
|
|
302
|
+
const linked = linkRefusal(m.name);
|
|
303
|
+
for (const f of sets.published) {
|
|
304
|
+
refuseLinkUnder(clone, joinRel(skillDir, f.path), linked);
|
|
305
|
+
}
|
|
306
|
+
for (const f of sets.published) {
|
|
307
|
+
copyFileUnder(
|
|
308
|
+
clone,
|
|
309
|
+
joinRel(skillDir, f.path),
|
|
310
|
+
path.join(folder, f.path),
|
|
311
|
+
linked,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return undefined;
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
...(layout ? [] : [bumpStep(view, to)]),
|
|
318
|
+
...changeSteps({
|
|
319
|
+
label: m.name,
|
|
320
|
+
source: m.source,
|
|
321
|
+
branch: snap.branch,
|
|
322
|
+
target,
|
|
323
|
+
stage: layout
|
|
324
|
+
? [MANIFEST, dir]
|
|
325
|
+
: [skillDir, ...versionFiles(view).map((f) => f.path)],
|
|
326
|
+
files,
|
|
327
|
+
message: layout
|
|
328
|
+
? `${plugin}: create plugin with ${args.skill} skill (${to})`
|
|
329
|
+
: `${plugin}: add ${args.skill} skill (${to})`,
|
|
330
|
+
slug: `${plugin}-${to}`,
|
|
331
|
+
prBody: layout
|
|
332
|
+
? `Creates the plugin \`${plugin}\` (${to}) in ${m.name} with the skill \`${args.skill}\`.`
|
|
333
|
+
: `Adds the skill \`${args.skill}\` to \`${plugin}\` and bumps it to ${to}.`,
|
|
334
|
+
verify: [{ dir: skillDir, files: sets.published }],
|
|
335
|
+
state,
|
|
336
|
+
}),
|
|
337
|
+
...(layout ? [refreshStep(m.name, state)] : []),
|
|
338
|
+
installStep(view, installs, to, state),
|
|
339
|
+
{
|
|
340
|
+
id: "local-copy",
|
|
341
|
+
label: "settled the local copy",
|
|
342
|
+
outside: true,
|
|
343
|
+
async run() {
|
|
344
|
+
const copy = await settleLocalCopy({
|
|
345
|
+
folder,
|
|
346
|
+
committed: sets.published,
|
|
347
|
+
copy: { scope: copyScope, name: args.skill },
|
|
348
|
+
keepReason,
|
|
349
|
+
moved: state.moved === true,
|
|
350
|
+
plugin,
|
|
351
|
+
pluginId,
|
|
352
|
+
cwd,
|
|
353
|
+
});
|
|
354
|
+
if (
|
|
355
|
+
copy.action === "kept" &&
|
|
356
|
+
!copy.partial &&
|
|
357
|
+
!managed &&
|
|
358
|
+
state.commit
|
|
359
|
+
) {
|
|
360
|
+
recordBase(folder, {
|
|
361
|
+
marketplace: m.name,
|
|
362
|
+
repo: m.source,
|
|
363
|
+
plugin,
|
|
364
|
+
skill: args.skill,
|
|
365
|
+
commit: state.commit,
|
|
366
|
+
tree: sets.hash,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
return { patch: { localCopy: copy } };
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
];
|
|
373
|
+
const lines = [
|
|
374
|
+
...(layout
|
|
375
|
+
? [
|
|
376
|
+
`Creates the plugin ${plugin} (${to}) in ${m.name}, with the skill ${args.skill} from ${where}, in one commit`,
|
|
377
|
+
]
|
|
378
|
+
: [
|
|
379
|
+
`Adds the skill ${args.skill} from ${where} to ${pluginId}`,
|
|
380
|
+
versionLine(plugin, from, to),
|
|
381
|
+
]),
|
|
382
|
+
`Publishes ${sets.published.length} file${sets.published.length === 1 ? "" : "s"}: ${sets.published.map((f) => f.path).join(", ")}`,
|
|
383
|
+
...(sets.untrackedFolder
|
|
384
|
+
? [
|
|
385
|
+
"This folder is not tracked in the project's git; every file is listed",
|
|
386
|
+
]
|
|
387
|
+
: []),
|
|
388
|
+
...(sets.notPublished.length > 0
|
|
389
|
+
? [
|
|
390
|
+
`Not published: ${sets.notPublished.map((n) => `${n.path} (${n.reason})`).join(", ")}`,
|
|
391
|
+
]
|
|
392
|
+
: []),
|
|
393
|
+
targetLine(target, snap.branch),
|
|
394
|
+
...installLines(plugin, installs, to),
|
|
395
|
+
predicted.action === "removed"
|
|
396
|
+
? `Afterwards removes ${args.from === "global" ? "your USER" : "this project's"} copy of ${args.skill}`
|
|
397
|
+
: `Keeps ${args.from === "global" ? "your USER" : "this project's"} copy of ${args.skill}: ${predicted.reason}`,
|
|
398
|
+
];
|
|
399
|
+
const plan: MarketPlan = {
|
|
400
|
+
command,
|
|
401
|
+
lines,
|
|
402
|
+
planId: computePlanId({
|
|
403
|
+
op: "skill-add",
|
|
404
|
+
skill: args.skill,
|
|
405
|
+
from: args.from,
|
|
406
|
+
keepLocal: args.keepLocal,
|
|
407
|
+
plugin: pluginId,
|
|
408
|
+
createPlugin: layout ? { description: args.description ?? null } : null,
|
|
409
|
+
commit: snap.commit,
|
|
410
|
+
hash: sets.hash,
|
|
411
|
+
notPublished: sets.notPublished,
|
|
412
|
+
target,
|
|
413
|
+
version: { from, to },
|
|
414
|
+
installs,
|
|
415
|
+
localCopy: predicted,
|
|
416
|
+
}),
|
|
417
|
+
summary: {
|
|
418
|
+
marketplace: m.name,
|
|
419
|
+
plugin,
|
|
420
|
+
skill: args.skill,
|
|
421
|
+
version: { from, to },
|
|
422
|
+
files,
|
|
423
|
+
notPublished: sets.notPublished,
|
|
424
|
+
target,
|
|
425
|
+
},
|
|
426
|
+
steps,
|
|
427
|
+
sync: { name: m.name, url: cloneUrl(m.source) },
|
|
428
|
+
lock: m.name,
|
|
429
|
+
replan: () => planSkillAdd({ ...args, plugin: pluginId }, cwd),
|
|
430
|
+
doneMessage: `${args.skill} is in ${pluginId} ${to}. Takes effect in new sessions, or after /reload-plugins.`,
|
|
431
|
+
};
|
|
432
|
+
return { kind: "plan", plan };
|
|
433
|
+
} catch (error) {
|
|
434
|
+
return planError(command, error);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ─── skill edit ──────────────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
export interface SkillEditArgs {
|
|
441
|
+
skill: string;
|
|
442
|
+
plugin?: string;
|
|
443
|
+
to: "project" | "global";
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** The one plugin holding `skill`, read from fresh state. */
|
|
447
|
+
export async function ownerOf(
|
|
448
|
+
skill: string,
|
|
449
|
+
ref: string | undefined,
|
|
450
|
+
): Promise<PluginView> {
|
|
451
|
+
if (ref) {
|
|
452
|
+
const view = await readPlugin(ref);
|
|
453
|
+
if (
|
|
454
|
+
!view.snap.entries.has(joinRel(view.dir, "skills", skill, "SKILL.md"))
|
|
455
|
+
) {
|
|
456
|
+
throw new MarketError(
|
|
457
|
+
"not-found",
|
|
458
|
+
`${view.plugin}@${view.m.name} has no skill named ${skill}`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
return view;
|
|
462
|
+
}
|
|
463
|
+
const all = await listCompanyMarketplaces();
|
|
464
|
+
const reads = await Promise.all(
|
|
465
|
+
all.map(async (m) => {
|
|
466
|
+
try {
|
|
467
|
+
const snap = await freshSnapshot(m);
|
|
468
|
+
return { m, snap, manifest: await manifestAt(snap, m.name) };
|
|
469
|
+
} catch (error) {
|
|
470
|
+
return { m, error };
|
|
471
|
+
}
|
|
472
|
+
}),
|
|
473
|
+
);
|
|
474
|
+
const failed = reads.filter((r) => "error" in r);
|
|
475
|
+
if (failed.length > 0) {
|
|
476
|
+
throw staleError(
|
|
477
|
+
failed.map((r) => r.m.name),
|
|
478
|
+
`Could not read the current state of ${failed.map((r) => r.m.name).join(", ")}`,
|
|
479
|
+
"run `magus market fetch`, or name the target with --plugin <plugin@marketplace>",
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
const ok = reads.filter(
|
|
483
|
+
(
|
|
484
|
+
r,
|
|
485
|
+
): r is {
|
|
486
|
+
m: TrustedRegistration;
|
|
487
|
+
snap: Snapshot;
|
|
488
|
+
manifest: Record<string, unknown>;
|
|
489
|
+
} => !("error" in r),
|
|
490
|
+
);
|
|
491
|
+
const owners = findSkillOwners(
|
|
492
|
+
ok.map((r) => ({
|
|
493
|
+
marketplace: r.m.name,
|
|
494
|
+
plugins: pluginEntries(r.manifest).map((p) => ({
|
|
495
|
+
name: p.name,
|
|
496
|
+
dir: pluginRelDir(p.source, pluginRootOf(r.manifest)),
|
|
497
|
+
})),
|
|
498
|
+
paths: new Set(r.snap.entries.keys()),
|
|
499
|
+
})),
|
|
500
|
+
skill,
|
|
501
|
+
);
|
|
502
|
+
if (owners.length === 0) {
|
|
503
|
+
throw new MarketError(
|
|
504
|
+
"not-found",
|
|
505
|
+
`No company plugin holds a skill named ${skill}`,
|
|
506
|
+
"`magus market list` shows each plugin's skills",
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
if (owners.length > 1) {
|
|
510
|
+
throw new MarketError(
|
|
511
|
+
"ambiguous",
|
|
512
|
+
`More than one company plugin holds ${skill}: ${owners.join(", ")}`,
|
|
513
|
+
"name one with --plugin <plugin@marketplace>",
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
return readPlugin(owners[0] as string);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export async function planSkillEdit(
|
|
520
|
+
args: SkillEditArgs,
|
|
521
|
+
cwd: string,
|
|
522
|
+
): Promise<PlanAnswer> {
|
|
523
|
+
const command: MarketCommand = "market skill edit";
|
|
524
|
+
try {
|
|
525
|
+
checkSkillName(args.skill);
|
|
526
|
+
const folder = skillFolder(cwd, args.to, args.skill);
|
|
527
|
+
refuseLinkedSkillFolder(cwd, args.to, args.skill);
|
|
528
|
+
if (fs.existsSync(folder)) {
|
|
529
|
+
throw new MarketError(
|
|
530
|
+
"name-taken",
|
|
531
|
+
args.to === "global"
|
|
532
|
+
? `Your USER skills already have their own ${args.skill} skill`
|
|
533
|
+
: `This project already has its own ${args.skill} skill`,
|
|
534
|
+
"publish it, or delete it first",
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
const view = await ownerOf(args.skill, args.plugin);
|
|
538
|
+
const { m, snap, plugin, dir } = view;
|
|
539
|
+
const skillDir = joinRel(dir, "skills", args.skill);
|
|
540
|
+
const files = filesUnder(snap, skillDir);
|
|
541
|
+
// The provenance file is magus's own: never taken from a repository.
|
|
542
|
+
const copied = files.filter((f) => f.path !== ORIGIN_FILE);
|
|
543
|
+
const entry = pluginEntries(view.manifest).find((p) => p.name === plugin);
|
|
544
|
+
const version = currentVersion(view.pluginJson, entry) ?? "unversioned";
|
|
545
|
+
const where = args.to === "global" ? "your USER skills" : "this project";
|
|
546
|
+
const loads =
|
|
547
|
+
args.to === "global"
|
|
548
|
+
? "every project loads your copy instead of the plugin's until you publish it"
|
|
549
|
+
: "this project loads your copy instead of the plugin's until you publish it";
|
|
550
|
+
const steps: MarketStep[] = [
|
|
551
|
+
{
|
|
552
|
+
id: "copy",
|
|
553
|
+
label: `copied ${args.skill} into ${where}`,
|
|
554
|
+
outside: true,
|
|
555
|
+
async run(ctx) {
|
|
556
|
+
const clone = cloneOf(ctx);
|
|
557
|
+
const src = path.join(clone, skillDir);
|
|
558
|
+
// Neither side may be reached through a link: a committed link
|
|
559
|
+
// above a file would read from, or write to, outside both.
|
|
560
|
+
const linked = (link: string) =>
|
|
561
|
+
new MarketError(
|
|
562
|
+
"other",
|
|
563
|
+
`${plugin} holds a link at ${link}, so ${args.skill} cannot be copied safely — nothing was changed`,
|
|
564
|
+
"replace the link with a folder in the marketplace repository, then run the command again",
|
|
565
|
+
);
|
|
566
|
+
for (const f of copied) {
|
|
567
|
+
const parent = path.posix.dirname(f.path);
|
|
568
|
+
refuseLinkUnder(
|
|
569
|
+
clone,
|
|
570
|
+
joinRel(skillDir, f.mode === SYMLINK_MODE ? parent : f.path),
|
|
571
|
+
linked,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
refuseLinkedSkillFolder(cwd, args.to, args.skill);
|
|
575
|
+
fs.mkdirSync(folder, { recursive: true });
|
|
576
|
+
for (const f of copied) {
|
|
577
|
+
const from = path.join(src, f.path);
|
|
578
|
+
const out = path.join(folder, f.path);
|
|
579
|
+
refuseLinkUnder(folder, path.posix.dirname(f.path), linked);
|
|
580
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
581
|
+
if (f.mode === SYMLINK_MODE) {
|
|
582
|
+
fs.symlinkSync(fs.readlinkSync(from), out);
|
|
583
|
+
} else {
|
|
584
|
+
fs.copyFileSync(from, out, fs.constants.COPYFILE_EXCL);
|
|
585
|
+
if (f.mode === "100755") fs.chmodSync(out, 0o755);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return undefined;
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
id: "base",
|
|
593
|
+
label: "recorded where the copy came from",
|
|
594
|
+
outside: true,
|
|
595
|
+
async run(ctx) {
|
|
596
|
+
const here = await checkIgnore(
|
|
597
|
+
folder,
|
|
598
|
+
files.map((f) => f.path),
|
|
599
|
+
);
|
|
600
|
+
const there = await checkIgnore(
|
|
601
|
+
cloneOf(ctx),
|
|
602
|
+
files.map((f) => joinRel(skillDir, f.path)),
|
|
603
|
+
);
|
|
604
|
+
const ignoredHere = here.ok ? here.value : new Set<string>();
|
|
605
|
+
const ignoredThere = new Set(
|
|
606
|
+
[...(there.ok ? there.value : [])].map((p) =>
|
|
607
|
+
p.slice(skillDir.length + 1),
|
|
608
|
+
),
|
|
609
|
+
);
|
|
610
|
+
const domain = files.filter((f) =>
|
|
611
|
+
inDomain(f.path, f.mode, ignoredHere, ignoredThere),
|
|
612
|
+
);
|
|
613
|
+
recordBase(folder, {
|
|
614
|
+
marketplace: m.name,
|
|
615
|
+
repo: m.source,
|
|
616
|
+
plugin,
|
|
617
|
+
skill: args.skill,
|
|
618
|
+
commit: snap.commit,
|
|
619
|
+
tree: treeHash(domain),
|
|
620
|
+
});
|
|
621
|
+
return {
|
|
622
|
+
patch: {
|
|
623
|
+
localCopy: {
|
|
624
|
+
scope: args.to === "global" ? "user" : "project",
|
|
625
|
+
name: args.skill,
|
|
626
|
+
action: "created",
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
];
|
|
633
|
+
const plan: MarketPlan = {
|
|
634
|
+
command,
|
|
635
|
+
lines: [
|
|
636
|
+
`Copies ${args.skill} from ${plugin} ${version} (GitHub) into ${where}; ${loads}`,
|
|
637
|
+
`${copied.length} file${copied.length === 1 ? "" : "s"}: ${copied.map((f) => f.path).join(", ")}`,
|
|
638
|
+
"Changes no repository",
|
|
639
|
+
],
|
|
640
|
+
planId: computePlanId({
|
|
641
|
+
op: "skill-edit",
|
|
642
|
+
skill: args.skill,
|
|
643
|
+
owner: `${plugin}@${m.name}`,
|
|
644
|
+
commit: snap.commit,
|
|
645
|
+
to: args.to,
|
|
646
|
+
files: files.map((f) => [f.path, f.oid, f.mode]),
|
|
647
|
+
}),
|
|
648
|
+
summary: { marketplace: m.name, plugin, skill: args.skill },
|
|
649
|
+
steps,
|
|
650
|
+
sync: { name: m.name, url: cloneUrl(m.source) },
|
|
651
|
+
lock: m.name,
|
|
652
|
+
replan: () =>
|
|
653
|
+
planSkillEdit({ ...args, plugin: `${plugin}@${m.name}` }, cwd),
|
|
654
|
+
doneMessage: `${where === "this project" ? "This project" : "Your USER skills"} now hold${where === "this project" ? "s" : ""} a copy of ${args.skill} to edit. Publish it with \`magus market publish ${args.skill}\`.`,
|
|
655
|
+
};
|
|
656
|
+
return { kind: "plan", plan };
|
|
657
|
+
} catch (error) {
|
|
658
|
+
return planError(command, error);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ─── skill remove ────────────────────────────────────────────────────────────
|
|
663
|
+
|
|
664
|
+
export interface SkillRemoveArgs {
|
|
665
|
+
skill: string;
|
|
666
|
+
plugin: string;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export async function planSkillRemove(
|
|
670
|
+
args: SkillRemoveArgs,
|
|
671
|
+
cwd: string,
|
|
672
|
+
): Promise<PlanAnswer> {
|
|
673
|
+
const command: MarketCommand = "market skill remove";
|
|
674
|
+
try {
|
|
675
|
+
checkSkillName(args.skill);
|
|
676
|
+
const view = await readPlugin(args.plugin);
|
|
677
|
+
const { m, snap, plugin, dir } = view;
|
|
678
|
+
const skillDir = joinRel(dir, "skills", args.skill);
|
|
679
|
+
const under = filesUnder(snap, skillDir);
|
|
680
|
+
if (!snap.entries.has(joinRel(skillDir, "SKILL.md"))) {
|
|
681
|
+
throw new MarketError(
|
|
682
|
+
"not-found",
|
|
683
|
+
`${plugin}@${m.name} has no skill named ${args.skill}`,
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
const { from, to } = bumpFrom(view);
|
|
687
|
+
const target = await deliveryTarget(m.source, m.name);
|
|
688
|
+
const pluginId = `${plugin}@${m.name}`;
|
|
689
|
+
const installs = await installedScopes(pluginId, cwd);
|
|
690
|
+
const locals = (["project", "global"] as const)
|
|
691
|
+
.map((w) => ({ where: w, folder: skillFolder(cwd, w, args.skill) }))
|
|
692
|
+
.filter((l) => fs.existsSync(l.folder));
|
|
693
|
+
const files = [
|
|
694
|
+
...under.map((f) => ({
|
|
695
|
+
path: joinRel(skillDir, f.path),
|
|
696
|
+
change: "deleted" as const,
|
|
697
|
+
})),
|
|
698
|
+
...versionFiles(view),
|
|
699
|
+
];
|
|
700
|
+
const state: ChangeSpec["state"] & { moved?: boolean } = {};
|
|
701
|
+
const firstLocal = locals[0];
|
|
702
|
+
const kept: LocalCopy | undefined = firstLocal
|
|
703
|
+
? {
|
|
704
|
+
scope: firstLocal.where === "global" ? "user" : "project",
|
|
705
|
+
name: args.skill,
|
|
706
|
+
action: "kept",
|
|
707
|
+
reason:
|
|
708
|
+
"the plugin no longer holds this skill, so this copy is the only one left",
|
|
709
|
+
}
|
|
710
|
+
: undefined;
|
|
711
|
+
const steps: MarketStep[] = [
|
|
712
|
+
{
|
|
713
|
+
id: "remove",
|
|
714
|
+
label: `removed ${args.skill} from ${plugin}`,
|
|
715
|
+
outside: false,
|
|
716
|
+
async run(ctx) {
|
|
717
|
+
removeUnder(cloneOf(ctx), skillDir, linkRefusal(m.name));
|
|
718
|
+
return undefined;
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
bumpStep(view, to),
|
|
722
|
+
...changeSteps({
|
|
723
|
+
label: m.name,
|
|
724
|
+
source: m.source,
|
|
725
|
+
branch: snap.branch,
|
|
726
|
+
target,
|
|
727
|
+
stage: [skillDir, ...versionFiles(view).map((f) => f.path)],
|
|
728
|
+
files,
|
|
729
|
+
message: `${plugin}: remove ${args.skill} skill (${to})`,
|
|
730
|
+
slug: `${plugin}-${to}`,
|
|
731
|
+
prBody: `Removes the skill \`${args.skill}\` from \`${plugin}\` and bumps it to ${to}.`,
|
|
732
|
+
state,
|
|
733
|
+
}),
|
|
734
|
+
installStep(view, installs, to, state),
|
|
735
|
+
{
|
|
736
|
+
id: "bases",
|
|
737
|
+
label: "forgot where local copies of the skill came from",
|
|
738
|
+
outside: true,
|
|
739
|
+
async run() {
|
|
740
|
+
// Only this plugin's records: a same-named folder copied from
|
|
741
|
+
// another plugin keeps its own.
|
|
742
|
+
forgetBasesFor(m.name, { plugin, skill: args.skill });
|
|
743
|
+
return kept ? { patch: { localCopy: kept } } : undefined;
|
|
744
|
+
},
|
|
745
|
+
},
|
|
746
|
+
];
|
|
747
|
+
const lines = [
|
|
748
|
+
`Removes the skill ${args.skill} from ${pluginId}: ${under.length} file${under.length === 1 ? "" : "s"}`,
|
|
749
|
+
versionLine(plugin, from, to),
|
|
750
|
+
targetLine(target, snap.branch),
|
|
751
|
+
...installLines(plugin, installs, to),
|
|
752
|
+
...locals.map(
|
|
753
|
+
(l) =>
|
|
754
|
+
`Keeps ${l.where === "global" ? "your USER" : "this project's"} copy of ${args.skill}: it is the only one left`,
|
|
755
|
+
),
|
|
756
|
+
];
|
|
757
|
+
const plan: MarketPlan = {
|
|
758
|
+
command,
|
|
759
|
+
lines,
|
|
760
|
+
planId: computePlanId({
|
|
761
|
+
op: "skill-remove",
|
|
762
|
+
skill: args.skill,
|
|
763
|
+
plugin: pluginId,
|
|
764
|
+
commit: snap.commit,
|
|
765
|
+
target,
|
|
766
|
+
version: { from, to },
|
|
767
|
+
installs,
|
|
768
|
+
locals: locals.map((l) => l.where),
|
|
769
|
+
}),
|
|
770
|
+
summary: {
|
|
771
|
+
marketplace: m.name,
|
|
772
|
+
plugin,
|
|
773
|
+
skill: args.skill,
|
|
774
|
+
version: { from, to },
|
|
775
|
+
files,
|
|
776
|
+
target,
|
|
777
|
+
},
|
|
778
|
+
steps,
|
|
779
|
+
sync: { name: m.name, url: cloneUrl(m.source) },
|
|
780
|
+
lock: m.name,
|
|
781
|
+
replan: () => planSkillRemove(args, cwd),
|
|
782
|
+
doneMessage: `${args.skill} is no longer in ${pluginId} ${to}. Takes effect in new sessions, or after /reload-plugins.`,
|
|
783
|
+
};
|
|
784
|
+
return { kind: "plan", plan };
|
|
785
|
+
} catch (error) {
|
|
786
|
+
return planError(command, error);
|
|
787
|
+
}
|
|
788
|
+
}
|