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.
Files changed (111) hide show
  1. package/README.md +21 -0
  2. package/package.json +4 -4
  3. package/src/__tests__/market-bases.test.ts +112 -0
  4. package/src/__tests__/market-config.test.ts +84 -0
  5. package/src/__tests__/market-conflicts.test.ts +175 -0
  6. package/src/__tests__/market-consent.test.ts +185 -0
  7. package/src/__tests__/market-content.test.ts +183 -0
  8. package/src/__tests__/market-delivery.test.ts +308 -0
  9. package/src/__tests__/market-fixture.ts +1167 -0
  10. package/src/__tests__/market-git.test.ts +130 -0
  11. package/src/__tests__/market-identity.test.ts +127 -0
  12. package/src/__tests__/market-install.test.ts +143 -0
  13. package/src/__tests__/market-layout.test.ts +206 -0
  14. package/src/__tests__/market-publish.test.ts +263 -0
  15. package/src/__tests__/market-r2-adapters.test.ts +190 -0
  16. package/src/__tests__/market-r2-company-cli.test.ts +350 -0
  17. package/src/__tests__/market-r2-company-unit.test.ts +224 -0
  18. package/src/__tests__/market-r2-local-items.test.ts +197 -0
  19. package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
  20. package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
  21. package/src/__tests__/market-r2-routing.test.ts +127 -0
  22. package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
  23. package/src/__tests__/market-r2b-display-path.test.ts +180 -0
  24. package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
  25. package/src/__tests__/market-r2b-local-items.test.ts +253 -0
  26. package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
  27. package/src/__tests__/market-readonly.test.ts +162 -0
  28. package/src/__tests__/market-repositories.test.ts +263 -0
  29. package/src/__tests__/market-results.test.ts +198 -0
  30. package/src/__tests__/market-router.test.ts +65 -0
  31. package/src/__tests__/market-safety.test.ts +357 -0
  32. package/src/__tests__/market-teammates.test.ts +304 -0
  33. package/src/cli/install.ts +64 -5
  34. package/src/cli/market-view.ts +329 -0
  35. package/src/cli/market.ts +664 -0
  36. package/src/cli/router.ts +69 -3
  37. package/src/cli/update-view.ts +56 -0
  38. package/src/cli/update.ts +111 -9
  39. package/src/data/marketplaces.ts +8 -2
  40. package/src/main.tsx +20 -2
  41. package/src/market-tui.tsx +21 -0
  42. package/src/services/active-closure.ts +71 -9
  43. package/src/services/claude-cli.ts +140 -10
  44. package/src/services/claude-settings.ts +19 -25
  45. package/src/services/doctor.ts +5 -5
  46. package/src/services/git-write.ts +615 -0
  47. package/src/services/github-cli.ts +309 -0
  48. package/src/services/local-marketplace.ts +112 -50
  49. package/src/services/market-bases.ts +191 -0
  50. package/src/services/market-change.ts +242 -0
  51. package/src/services/market-clone.ts +572 -0
  52. package/src/services/market-fs.ts +138 -0
  53. package/src/services/market-install.ts +172 -0
  54. package/src/services/market-layout.ts +311 -0
  55. package/src/services/market-local.ts +93 -0
  56. package/src/services/market-marker.ts +49 -0
  57. package/src/services/market-ops-content.ts +277 -0
  58. package/src/services/market-ops-plugin.ts +655 -0
  59. package/src/services/market-ops-publish.ts +1227 -0
  60. package/src/services/market-ops-repo.ts +916 -0
  61. package/src/services/market-ops-skill.ts +788 -0
  62. package/src/services/market-ops.ts +686 -0
  63. package/src/services/market-publish.ts +468 -0
  64. package/src/services/market-registry.ts +382 -0
  65. package/src/services/market-types.ts +268 -0
  66. package/src/services/marketplace-conflicts.ts +127 -0
  67. package/src/services/marketplace-refresh.ts +33 -3
  68. package/src/services/marketplace-sync.ts +253 -22
  69. package/src/services/plugin-dependencies.ts +17 -0
  70. package/src/services/plugin-manager.ts +1 -1
  71. package/src/services/plugin-mcp-config.ts +6 -9
  72. package/src/services/plugin-requires.ts +20 -8
  73. package/src/services/profile-absorb.ts +88 -3
  74. package/src/services/profile-adopt.ts +10 -1
  75. package/src/services/profiles.ts +64 -1
  76. package/src/services/resolver.ts +17 -5
  77. package/src/services/settings-manager.ts +2 -2
  78. package/src/services/skills-manager.ts +2 -2
  79. package/src/services/update-plan.ts +67 -8
  80. package/src/tui-runtime.tsx +142 -0
  81. package/src/tui.tsx +7 -124
  82. package/src/types/index.ts +7 -0
  83. package/src/ui/adapters/marketAdapter.ts +366 -0
  84. package/src/ui/adapters/pluginsAdapter.ts +45 -3
  85. package/src/ui/components/layout/ScreenLayout.tsx +43 -17
  86. package/src/ui/components/modals/ConfirmModal.tsx +145 -8
  87. package/src/ui/components/modals/HelpOverlay.tsx +10 -3
  88. package/src/ui/components/modals/ModalContainer.tsx +47 -5
  89. package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
  90. package/src/ui/components/primitives/ScopeBadge.tsx +28 -0
  91. package/src/ui/components/primitives/ScopeVersionLine.tsx +27 -0
  92. package/src/ui/components/primitives/SelectableRow.tsx +14 -1
  93. package/src/ui/components/primitives/ToneLine.tsx +25 -0
  94. package/src/ui/components/primitives/index.ts +5 -0
  95. package/src/ui/keymap.ts +21 -5
  96. package/src/ui/market/MarketApp.tsx +59 -0
  97. package/src/ui/market/MarketplaceScreen.tsx +280 -0
  98. package/src/ui/market/MarketplacesDetail.snowflake.tsx +161 -0
  99. package/src/ui/market/MarketplacesScreen.tsx +264 -0
  100. package/src/ui/market/PluginDetail.snowflake.tsx +61 -0
  101. package/src/ui/market/SkillDetail.snowflake.tsx +71 -0
  102. package/src/ui/market/marketKeymap.ts +243 -0
  103. package/src/ui/market/useMarketState.ts +694 -0
  104. package/src/ui/renderers/pluginRenderers.tsx +31 -3
  105. package/src/ui/renderers/pluginStatusText.ts +6 -0
  106. package/src/ui/screens/PluginsScreen.tsx +49 -5
  107. package/src/ui/screens/SkillsScreen.tsx +2 -2
  108. package/src/ui/state/AppContext.tsx +14 -2
  109. package/src/ui/state/types.ts +9 -2
  110. package/src/utils/config-dir.ts +29 -9
  111. package/src/utils/display-path.ts +33 -0
@@ -0,0 +1,1227 @@
1
+ /**
2
+ * market-ops-publish.ts — `market status` and `market publish`: how a local
3
+ * copy of a company skill stands against its plugin, and sending its edits
4
+ * back as the plugin's next patch version.
5
+ *
6
+ * One compare serves both. Three trees, each restricted to the compare
7
+ * domain (no symlinks, dotfiles, `node_modules/`, or files either side
8
+ * ignores) before hashing:
9
+ * - `ours`: the files the copy would publish;
10
+ * - `theirs`: the plugin's skill at GitHub's current commit;
11
+ * - `base`: the skill at the commit the copy was made from, known only while
12
+ * the folder holds the provenance token of its base record.
13
+ *
14
+ * Status is read-only. Publish plans read-only too; its apply replaces only
15
+ * the in-domain part of the plugin's skill, commits exactly the planned paths,
16
+ * delivers, moves this machine's installs through `claude plugin` commands,
17
+ * and removes the local copy only when it holds exactly the committed files.
18
+ * A plugin-wide publish does the same for every skill of one plugin edited
19
+ * here, as one commit and one version.
20
+ */
21
+
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { checkIgnore, hashObject, lsTree } from "./git-write.js";
25
+ import { readBase, recordBase } from "./market-bases.js";
26
+ import {
27
+ type ChangeSpec,
28
+ changeSteps,
29
+ cloneOf,
30
+ deliveryTarget,
31
+ targetLine,
32
+ } from "./market-change.js";
33
+ import {
34
+ type RemoteState,
35
+ cloneUrl,
36
+ readRemoteState,
37
+ workingClonePath,
38
+ } from "./market-clone.js";
39
+ import { refuseLinkUnder } from "./market-fs.js";
40
+ import {
41
+ type CompareState,
42
+ type SkillFile,
43
+ compareSkillTrees,
44
+ excludedByShape,
45
+ findSkillOwners,
46
+ inDomain,
47
+ joinRel,
48
+ pluginEntries,
49
+ pluginRelDir,
50
+ pluginRootOf,
51
+ treeHash,
52
+ } from "./market-layout.js";
53
+ import {
54
+ type PluginView,
55
+ bumpFrom,
56
+ bumpStep,
57
+ filesUnder,
58
+ installLines,
59
+ installStep,
60
+ pluginViewAt,
61
+ readPlugin,
62
+ versionFiles,
63
+ versionLine,
64
+ } from "./market-ops-content.js";
65
+ import {
66
+ PROFILE_KEEPS,
67
+ checkSkillName,
68
+ ownerOf,
69
+ skillFolder,
70
+ } from "./market-ops-skill.js";
71
+ import {
72
+ type MarketCommand,
73
+ computePlanId,
74
+ errorResult,
75
+ manifestAt,
76
+ planError,
77
+ } from "./market-ops.js";
78
+ import {
79
+ type InstalledAt,
80
+ type SourceSets,
81
+ copyMismatch,
82
+ installReaches,
83
+ installedScopes,
84
+ inventory,
85
+ offHereReason,
86
+ profileManaged,
87
+ readSourceSets,
88
+ settleLocalCopy,
89
+ } from "./market-publish.js";
90
+ import {
91
+ type TrustedRegistration,
92
+ listCompanyMarketplaces,
93
+ } from "./market-registry.js";
94
+ import {
95
+ type FileChange,
96
+ type LocalCopy,
97
+ MarketError,
98
+ type MarketPlan,
99
+ type MarketResult,
100
+ type MarketStep,
101
+ type PlanAnswer,
102
+ type SkillStatusRow,
103
+ } from "./market-types.js";
104
+ import { getUserSkillsDir } from "./skills-manager.js";
105
+
106
+ type TreeFile = SkillFile & { mode: string };
107
+
108
+ // ─── The compare ─────────────────────────────────────────────────────────────
109
+
110
+ export interface LocalCompare {
111
+ state: CompareState;
112
+ /** The copy's inventory, published set and files not published. */
113
+ sets: SourceSets;
114
+ /** The plugin's skill now, inside the compare domain. */
115
+ theirsIn: TreeFile[];
116
+ /** The plugin's skill now, outside it: publish never touches these. */
117
+ theirsOut: TreeFile[];
118
+ /** The commit the copy was made from, when its record is usable. */
119
+ baseCommit: string | null;
120
+ /** Why there is no base, in the user's words. */
121
+ noBaseReason?: string;
122
+ /** What publishing changes in the plugin's skill, skill-relative. */
123
+ changes: Array<{ path: string; change: FileChange }>;
124
+ }
125
+
126
+ /** Which of `paths` (skill-relative) each side ignores. */
127
+ async function ignoredBy(
128
+ folder: string,
129
+ folderIgnored: boolean,
130
+ repo: string,
131
+ skillDir: string,
132
+ paths: readonly string[],
133
+ ): Promise<{ here: Set<string>; there: Set<string> }> {
134
+ if (paths.length === 0) return { here: new Set(), there: new Set() };
135
+ // The source's own rules do not apply to a folder its repository ignores
136
+ // as a whole (magus ignores the skill folders a profile installs).
137
+ const here = folderIgnored ? null : await checkIgnore(folder, paths);
138
+ const there = await checkIgnore(
139
+ repo,
140
+ paths.map((p) => joinRel(skillDir, p)),
141
+ );
142
+ const prefix = `${skillDir}/`;
143
+ return {
144
+ here: here?.ok ? here.value : new Set(),
145
+ there: new Set(
146
+ [...(there.ok ? there.value : [])].map((p) =>
147
+ p.startsWith(prefix) ? p.slice(prefix.length) : p,
148
+ ),
149
+ ),
150
+ };
151
+ }
152
+
153
+ /** The skill's files at `commit`, from whichever local copy holds it. */
154
+ async function treeAt(
155
+ view: PluginView,
156
+ commit: string,
157
+ skillDir: string,
158
+ ): Promise<TreeFile[] | null> {
159
+ const dirs = [
160
+ view.snap.dir,
161
+ workingClonePath(view.m.name),
162
+ view.m.location,
163
+ ].filter((d, i, all): d is string => !!d && all.indexOf(d) === i);
164
+ const prefix = `${skillDir}/`;
165
+ for (const dir of dirs) {
166
+ if (!fs.existsSync(dir)) continue;
167
+ const r = await lsTree(dir, commit, skillDir);
168
+ if (r.ok) {
169
+ return r.value
170
+ .filter((e) => e.path.startsWith(prefix))
171
+ .map((e) => ({
172
+ path: e.path.slice(prefix.length),
173
+ oid: e.oid,
174
+ mode: e.mode,
175
+ }));
176
+ }
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * Compare the local copy at `folder` with `view`'s skill `skill`, from the
183
+ * fresh snapshot the view was read at. Read-only.
184
+ */
185
+ export async function compareLocal(
186
+ folder: string,
187
+ view: PluginView,
188
+ skill: string,
189
+ ): Promise<LocalCompare> {
190
+ const skillDir = joinRel(view.dir, "skills", skill);
191
+ const sets = await readSourceSets(folder, view.snap.dir, skillDir);
192
+ const theirs = filesUnder(view.snap, skillDir);
193
+
194
+ const record = readBase(folder);
195
+ let base: TreeFile[] | null = null;
196
+ let noBaseReason: string | undefined;
197
+ if (!record) {
198
+ noBaseReason = "this copy has no record of which version it came from";
199
+ } else if (
200
+ record.marketplace !== view.m.name ||
201
+ record.plugin !== view.plugin ||
202
+ record.skill !== skill
203
+ ) {
204
+ noBaseReason = `this copy was made from ${record.plugin}@${record.marketplace}, not ${view.plugin}@${view.m.name}`;
205
+ } else {
206
+ base = await treeAt(view, record.commit, skillDir);
207
+ if (!base) {
208
+ noBaseReason =
209
+ "the version this copy came from is not on this machine any more";
210
+ }
211
+ }
212
+
213
+ const paths = [...new Set([...theirs, ...(base ?? [])].map((f) => f.path))];
214
+ const ignored = await ignoredBy(
215
+ folder,
216
+ sets.untrackedFolder,
217
+ view.snap.dir,
218
+ skillDir,
219
+ paths,
220
+ );
221
+ const domain = (f: TreeFile) =>
222
+ inDomain(f.path, f.mode, ignored.here, ignored.there);
223
+ const theirsIn = theirs.filter(domain);
224
+ const theirsOut = theirs.filter((f) => !domain(f));
225
+ const state = compareSkillTrees(
226
+ base ? treeHash(base.filter(domain)) : null,
227
+ treeHash(theirsIn),
228
+ sets.hash,
229
+ );
230
+
231
+ const now = new Map(theirsIn.map((f) => [f.path, f.oid]));
232
+ const ours = new Set(sets.published.map((f) => f.path));
233
+ const changes: LocalCompare["changes"] = [];
234
+ for (const f of sets.published) {
235
+ const was = now.get(f.path);
236
+ if (was === undefined) changes.push({ path: f.path, change: "added" });
237
+ else if (was !== f.oid) changes.push({ path: f.path, change: "modified" });
238
+ }
239
+ for (const f of theirsIn) {
240
+ if (!ours.has(f.path)) changes.push({ path: f.path, change: "deleted" });
241
+ }
242
+ changes.sort((a, b) => (a.path < b.path ? -1 : 1));
243
+
244
+ return {
245
+ state,
246
+ sets,
247
+ theirsIn,
248
+ theirsOut,
249
+ baseCommit: base && record ? record.commit : null,
250
+ ...(noBaseReason ? { noBaseReason } : {}),
251
+ changes,
252
+ };
253
+ }
254
+
255
+ function changeList(changes: LocalCompare["changes"]): string {
256
+ return changes.map((c) => `${c.path} (${c.change})`).join(", ");
257
+ }
258
+
259
+ function plural(n: number, word: string): string {
260
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
261
+ }
262
+
263
+ // ─── status ──────────────────────────────────────────────────────────────────
264
+
265
+ export interface LocalFolder {
266
+ skill: string;
267
+ from: "project" | "user";
268
+ path: string;
269
+ }
270
+
271
+ /** Skill folders in this project and in the USER skills, real folders only. */
272
+ export function localSkillFolders(cwd: string, only?: string): LocalFolder[] {
273
+ const out: LocalFolder[] = [];
274
+ const roots = [
275
+ { from: "project" as const, dir: path.join(cwd, ".claude", "skills") },
276
+ { from: "user" as const, dir: getUserSkillsDir() },
277
+ ];
278
+ const seen = new Set<string>();
279
+ for (const { from, dir } of roots) {
280
+ let entries: fs.Dirent[];
281
+ try {
282
+ entries = fs.readdirSync(dir, { withFileTypes: true });
283
+ } catch {
284
+ continue;
285
+ }
286
+ for (const d of entries) {
287
+ if (!d.isDirectory() || (only && d.name !== only)) continue;
288
+ const folder = path.join(dir, d.name);
289
+ if (seen.has(folder)) continue;
290
+ if (!fs.existsSync(path.join(folder, "SKILL.md"))) continue;
291
+ seen.add(folder);
292
+ out.push({ skill: d.name, from, path: folder });
293
+ }
294
+ }
295
+ return out.sort((a, b) =>
296
+ a.skill === b.skill
297
+ ? a.from.localeCompare(b.from)
298
+ : a.skill.localeCompare(b.skill),
299
+ );
300
+ }
301
+
302
+ interface MarketRead {
303
+ m: TrustedRegistration;
304
+ state: RemoteState;
305
+ manifest: Record<string, unknown> | null;
306
+ }
307
+
308
+ async function readMarket(m: TrustedRegistration): Promise<MarketRead> {
309
+ const state = await readRemoteState(m);
310
+ let manifest: Record<string, unknown> | null = null;
311
+ if (state.snapshot) {
312
+ try {
313
+ manifest = await manifestAt(state.snapshot, m.name);
314
+ } catch {
315
+ manifest = null;
316
+ }
317
+ }
318
+ return { m, state, manifest };
319
+ }
320
+
321
+ function staleReason(r: MarketRead): string {
322
+ return r.state.fresh ? "" : `${r.m.name}: ${r.state.reason}`;
323
+ }
324
+
325
+ const ROW_STATE: Record<CompareState, SkillStatusRow["state"]> = {
326
+ fast: "edited",
327
+ same: "same",
328
+ "plugin-newer": "plugin-newer",
329
+ "no-base": "no-base",
330
+ conflict: "conflict",
331
+ };
332
+
333
+ /**
334
+ * `market status [<skill>]`: every skill folder here and in the USER skills
335
+ * that a company plugin holds, and how it stands. Read-only: no clone is
336
+ * created, fetched, reset or locked.
337
+ */
338
+ export async function marketStatus(
339
+ skill: string | undefined,
340
+ cwd: string,
341
+ ): Promise<MarketResult> {
342
+ const command: MarketCommand = "market status";
343
+ try {
344
+ if (skill) checkSkillName(skill);
345
+ const folders = localSkillFolders(cwd, skill);
346
+ if (skill && folders.length === 0) {
347
+ throw new MarketError(
348
+ "not-found",
349
+ `Neither this project nor your USER skills have a skill named ${skill}`,
350
+ );
351
+ }
352
+ const reads = await Promise.all(
353
+ (await listCompanyMarketplaces()).map(readMarket),
354
+ );
355
+ const candidates = reads.flatMap((r) =>
356
+ r.state.snapshot && r.manifest
357
+ ? [
358
+ {
359
+ marketplace: r.m.name,
360
+ plugins: pluginEntries(r.manifest).map((p) => ({
361
+ name: p.name,
362
+ dir: pluginRelDir(p.source, pluginRootOf(r.manifest)),
363
+ })),
364
+ paths: new Set(r.state.snapshot.entries.keys()),
365
+ },
366
+ ]
367
+ : [],
368
+ );
369
+ const unread = reads.filter((r) => !r.state.snapshot || !r.manifest);
370
+
371
+ const rows: SkillStatusRow[] = [];
372
+ for (const f of folders) {
373
+ const row = {
374
+ skill: f.skill,
375
+ from: f.from,
376
+ path: f.path,
377
+ owner: null as string | null,
378
+ };
379
+ const owners = findSkillOwners(candidates, f.skill);
380
+ if (owners.length > 1) {
381
+ rows.push({ ...row, owners, state: "ambiguous" });
382
+ continue;
383
+ }
384
+ if (owners.length === 0) {
385
+ const record = readBase(f.path);
386
+ const from = record
387
+ ? reads.find((r) => r.m.name === record.marketplace)
388
+ : undefined;
389
+ if (record && from && !from.state.fresh) {
390
+ rows.push({
391
+ ...row,
392
+ owner: `${record.plugin}@${record.marketplace}`,
393
+ state: "not-checked",
394
+ reason: staleReason(from),
395
+ });
396
+ } else if (skill && unread.length > 0) {
397
+ rows.push({
398
+ ...row,
399
+ state: "not-checked",
400
+ reason: unread.map(staleReason).join("; "),
401
+ });
402
+ } else if (skill) {
403
+ rows.push({ ...row, state: "not-owned" });
404
+ }
405
+ continue;
406
+ }
407
+ const owner = owners[0] as string;
408
+ const at = owner.lastIndexOf("@");
409
+ const read = reads.find((r) => r.m.name === owner.slice(at + 1));
410
+ if (!read?.state.fresh || !read.manifest) {
411
+ rows.push({
412
+ ...row,
413
+ owner,
414
+ state: "not-checked",
415
+ reason: read ? staleReason(read) : "not read",
416
+ });
417
+ continue;
418
+ }
419
+ try {
420
+ const view = await pluginViewAt(
421
+ read.m,
422
+ read.state.snapshot,
423
+ read.manifest,
424
+ owner.slice(0, at),
425
+ );
426
+ const cmp = await compareLocal(f.path, view, f.skill);
427
+ rows.push({ ...row, owner, state: ROW_STATE[cmp.state] });
428
+ } catch (error) {
429
+ rows.push({
430
+ ...row,
431
+ owner,
432
+ state: "not-checked",
433
+ reason: error instanceof Error ? error.message : String(error),
434
+ });
435
+ }
436
+ }
437
+ const stale = reads.some((r) => !r.state.fresh);
438
+ const edited = rows.filter((r) =>
439
+ ["edited", "no-base", "conflict"].includes(r.state),
440
+ ).length;
441
+ return {
442
+ command,
443
+ outcome: "ok",
444
+ message:
445
+ rows.length === 0
446
+ ? "No local skill here belongs to a company plugin"
447
+ : edited === 0
448
+ ? `${plural(rows.length, "local skill")} from company plugins; none has edits to publish`
449
+ : `${plural(rows.length, "local skill")} from company plugins; ${edited} with edits to publish`,
450
+ ...(skill ? { skill } : {}),
451
+ ...(stale ? { stale: true } : {}),
452
+ skills: rows,
453
+ steps: [],
454
+ };
455
+ } catch (error) {
456
+ return errorResult(command, error);
457
+ }
458
+ }
459
+
460
+ // ─── publish ─────────────────────────────────────────────────────────────────
461
+
462
+ export interface PublishArgs {
463
+ skill: string;
464
+ /** `plugin@marketplace`; inferred from fresh state when left out. */
465
+ plugin?: string;
466
+ from: "project" | "global";
467
+ keepLocal: boolean;
468
+ force: boolean;
469
+ }
470
+
471
+ function whoseCopy(from: "project" | "global"): string {
472
+ return from === "global" ? "your USER copy" : "this project's copy";
473
+ }
474
+
475
+ /** The local copy to publish: a real folder holding SKILL.md. */
476
+ function sourceFolder(cwd: string, args: PublishArgs): string {
477
+ checkSkillName(args.skill);
478
+ const folder = skillFolder(cwd, args.from, args.skill);
479
+ const where = args.from === "global" ? "Your USER skills" : "This project";
480
+ let stat: fs.Stats | null = null;
481
+ try {
482
+ stat = fs.lstatSync(folder);
483
+ } catch {
484
+ stat = null;
485
+ }
486
+ if (!stat || !fs.existsSync(path.join(folder, "SKILL.md"))) {
487
+ throw new MarketError(
488
+ "not-found",
489
+ `${where} ${args.from === "global" ? "have" : "has"} no skill named ${args.skill}`,
490
+ `to change a company skill, copy it here first with \`magus market skill edit ${args.skill}\``,
491
+ );
492
+ }
493
+ if (stat.isSymbolicLink()) {
494
+ throw new MarketError(
495
+ "other",
496
+ `${args.skill} here is a link to another folder; magus publishes only a folder of its own`,
497
+ );
498
+ }
499
+ return folder;
500
+ }
501
+
502
+ const FORCE_FIX =
503
+ "to replace the plugin's version with this copy, run again with --force";
504
+
505
+ /**
506
+ * Why a publish keeps the local copy at `folder`, or null when it will be
507
+ * removed once the push lands and every install moves. Read-only.
508
+ */
509
+ async function keepReasonFor(input: {
510
+ folder: string;
511
+ skill: string;
512
+ from: "project" | "global";
513
+ keepLocal: boolean;
514
+ plugin: string;
515
+ pluginId: string;
516
+ installs: readonly InstalledAt[];
517
+ committed: readonly SkillFile[];
518
+ cwd: string;
519
+ }): Promise<{ reason: string | null; managed: boolean }> {
520
+ const managed =
521
+ input.from === "project" && (await profileManaged(input.cwd, input.skill));
522
+ const reason = input.keepLocal
523
+ ? "--keep-local"
524
+ : managed
525
+ ? PROFILE_KEEPS
526
+ : !installReaches(
527
+ input.from === "global" ? "user" : "project",
528
+ input.installs.map((i) => i.scope),
529
+ )
530
+ ? `${input.plugin} is not installed where this copy loads`
531
+ : ((await offHereReason(input.plugin, input.pluginId, input.cwd)) ??
532
+ (await copyMismatch(
533
+ input.folder,
534
+ input.committed,
535
+ "will not be published",
536
+ )));
537
+ return { reason, managed };
538
+ }
539
+
540
+ export async function planPublish(
541
+ args: PublishArgs,
542
+ cwd: string,
543
+ ): Promise<PlanAnswer> {
544
+ const command: MarketCommand = "market publish";
545
+ try {
546
+ const folder = sourceFolder(cwd, args);
547
+ const view = await ownerOf(args.skill, args.plugin);
548
+ const { m, snap, plugin } = view;
549
+ const pluginId = `${plugin}@${m.name}`;
550
+ const skillDir = joinRel(view.dir, "skills", args.skill);
551
+ const cmp = await compareLocal(folder, view, args.skill);
552
+ const copyWord = whoseCopy(args.from);
553
+ const files = cmp.changes.map((c) => ({
554
+ path: joinRel(skillDir, c.path),
555
+ change: c.change,
556
+ }));
557
+ const base = {
558
+ marketplace: m.name,
559
+ plugin,
560
+ skill: args.skill,
561
+ ...(cmp.sets.notPublished.length > 0
562
+ ? { notPublished: cmp.sets.notPublished }
563
+ : {}),
564
+ };
565
+
566
+ if (cmp.state === "same") {
567
+ return {
568
+ kind: "result",
569
+ result: {
570
+ command,
571
+ outcome: "unchanged",
572
+ message: `${copyWord} of ${args.skill} is the same as ${pluginId}'s — nothing to publish`,
573
+ ...base,
574
+ steps: [],
575
+ },
576
+ };
577
+ }
578
+ if (cmp.state === "plugin-newer") {
579
+ return {
580
+ kind: "result",
581
+ result: {
582
+ command,
583
+ outcome: "plugin-newer",
584
+ message: `${pluginId} has a newer version of ${args.skill}, and ${copyWord} has no edits — nothing to publish. Delete ${copyWord} to use the plugin's version`,
585
+ ...base,
586
+ steps: [],
587
+ },
588
+ };
589
+ }
590
+ const forced = cmp.state === "no-base" || cmp.state === "conflict";
591
+ const compareLine =
592
+ cmp.state === "fast"
593
+ ? `${capital(copyWord)} has edits, and ${pluginId} has not changed ${args.skill} since the copy was made`
594
+ : cmp.state === "no-base"
595
+ ? `${capital(cmp.noBaseReason ?? "this copy has no record of which version it came from")}; publishing replaces the plugin's version. Next time, start an edit with \`magus market skill edit\``
596
+ : `${args.skill} changed both here and in ${pluginId} on GitHub since the copy was made; publishing replaces the plugin's version`;
597
+ const diffLine = `Your copy vs the plugin: ${changeList(cmp.changes)}`;
598
+ if (forced && !args.force) {
599
+ const message =
600
+ cmp.state === "no-base"
601
+ ? `${capital(cmp.noBaseReason ?? "this copy has no record of which version it came from")} — nothing changed`
602
+ : `${args.skill} changed both here and in ${pluginId} on GitHub — nothing changed`;
603
+ return {
604
+ kind: "result",
605
+ result: {
606
+ command,
607
+ outcome: "failed",
608
+ message,
609
+ ...base,
610
+ files,
611
+ plan: [compareLine, diffLine],
612
+ steps: [],
613
+ error: {
614
+ code: cmp.state === "no-base" ? "no-base" : "conflict",
615
+ message,
616
+ fix: FORCE_FIX,
617
+ },
618
+ },
619
+ };
620
+ }
621
+
622
+ const { from, to } = bumpFrom(view);
623
+ const target = await deliveryTarget(m.source, m.name);
624
+ const installs = await installedScopes(pluginId, cwd);
625
+ const copyScope = args.from === "global" ? "user" : "project";
626
+ const committed: SkillFile[] = [
627
+ ...cmp.sets.published,
628
+ ...cmp.theirsOut.map((f) => ({ path: f.path, oid: f.oid })),
629
+ ];
630
+ const { reason: keepReason, managed } = await keepReasonFor({
631
+ folder,
632
+ skill: args.skill,
633
+ from: args.from,
634
+ keepLocal: args.keepLocal,
635
+ plugin,
636
+ pluginId,
637
+ installs,
638
+ committed,
639
+ cwd,
640
+ });
641
+ const predicted: LocalCopy = keepReason
642
+ ? {
643
+ scope: copyScope,
644
+ name: args.skill,
645
+ action: "kept",
646
+ reason: keepReason,
647
+ }
648
+ : { scope: copyScope, name: args.skill, action: "removed" };
649
+
650
+ const state: ChangeSpec["state"] & { moved?: boolean } = {};
651
+ const steps: MarketStep[] = [
652
+ replaceStep(folder, skillDir, plugin, args.skill, cmp, committed),
653
+ bumpStep(view, to),
654
+ ...changeSteps({
655
+ label: m.name,
656
+ source: m.source,
657
+ branch: snap.branch,
658
+ target,
659
+ stage: [skillDir, ...versionFiles(view).map((f) => f.path)],
660
+ files: [...files, ...versionFiles(view)],
661
+ message: `${plugin}: update ${args.skill} skill (${to})`,
662
+ slug: `${plugin}-${to}`,
663
+ prBody: `Updates the skill \`${args.skill}\` in \`${plugin}\` and bumps it to ${to}.\n\nChanged: ${changeList(cmp.changes)}.`,
664
+ verify: [{ dir: skillDir, files: committed }],
665
+ state,
666
+ }),
667
+ installStep(view, installs, to, state),
668
+ {
669
+ id: "local-copy",
670
+ label: "settled the local copy",
671
+ outside: true,
672
+ async run() {
673
+ const copy = await settleLocalCopy({
674
+ folder,
675
+ committed,
676
+ copy: { scope: copyScope, name: args.skill },
677
+ keepReason,
678
+ moved: state.moved === true,
679
+ plugin,
680
+ pluginId,
681
+ cwd,
682
+ });
683
+ // A kept copy now matches the pushed commit: that is its base
684
+ // for the next publish, bound to it by a fresh token.
685
+ if (
686
+ copy.action === "kept" &&
687
+ !copy.partial &&
688
+ !managed &&
689
+ state.commit
690
+ ) {
691
+ recordBase(folder, {
692
+ marketplace: m.name,
693
+ repo: m.source,
694
+ plugin,
695
+ skill: args.skill,
696
+ commit: state.commit,
697
+ tree: cmp.sets.hash,
698
+ });
699
+ }
700
+ return { patch: { localCopy: copy } };
701
+ },
702
+ },
703
+ ];
704
+
705
+ const lines = [
706
+ `Publishes ${copyWord} of ${args.skill} to ${pluginId}`,
707
+ compareLine,
708
+ versionLine(plugin, from, to),
709
+ `Changes ${plural(cmp.changes.length, "file")} in the ${args.skill} skill: ${changeList(cmp.changes)}`,
710
+ ...(cmp.sets.untrackedFolder
711
+ ? [
712
+ "This folder is not tracked in the project's git; every file is listed",
713
+ ]
714
+ : []),
715
+ ...(cmp.sets.notPublished.length > 0
716
+ ? [
717
+ `Not published: ${cmp.sets.notPublished.map((n) => `${n.path} (${n.reason})`).join(", ")}`,
718
+ ]
719
+ : []),
720
+ targetLine(target, snap.branch),
721
+ "A pull request moves no install and keeps the local copy",
722
+ ...installLines(plugin, installs, to),
723
+ predicted.action === "removed"
724
+ ? `Afterwards removes ${copyWord} of ${args.skill}`
725
+ : `Keeps ${copyWord} of ${args.skill}: ${predicted.reason}`,
726
+ ];
727
+ const plan: MarketPlan = {
728
+ command,
729
+ lines,
730
+ planId: computePlanId({
731
+ op: "publish",
732
+ skill: args.skill,
733
+ from: args.from,
734
+ keepLocal: args.keepLocal,
735
+ force: args.force,
736
+ compare: cmp.state,
737
+ base: cmp.baseCommit,
738
+ plugin: pluginId,
739
+ branch: snap.branch,
740
+ commit: snap.commit,
741
+ hash: cmp.sets.hash,
742
+ notPublished: cmp.sets.notPublished,
743
+ target,
744
+ version: { from, to },
745
+ installs,
746
+ localCopy: predicted,
747
+ }),
748
+ summary: {
749
+ ...base,
750
+ version: { from, to },
751
+ files,
752
+ target,
753
+ },
754
+ steps,
755
+ sync: { name: m.name, url: cloneUrl(m.source) },
756
+ lock: m.name,
757
+ replan: () =>
758
+ planPublish({ ...args, plugin: args.plugin ?? pluginId }, cwd),
759
+ doneMessage: `${args.skill} is published in ${pluginId} ${to}. Takes effect in new sessions, or after /reload-plugins.`,
760
+ ...(forced
761
+ ? { secondConfirm: `Replace the plugin's version of ${args.skill}?` }
762
+ : {}),
763
+ };
764
+ return { kind: "plan", plan };
765
+ } catch (error) {
766
+ return planError(command, error);
767
+ }
768
+ }
769
+
770
+ // ─── publish a plugin ────────────────────────────────────────────────────────
771
+
772
+ export interface PluginPublishArgs {
773
+ /** `plugin@marketplace`; the marketplace may be left out when there is one. */
774
+ plugin: string;
775
+ keepLocal: boolean;
776
+ }
777
+
778
+ const STATE_WORDS: Record<"no-base" | "conflict", string> = {
779
+ "no-base": "origin unknown",
780
+ conflict: "changed here and on GitHub",
781
+ };
782
+
783
+ /** One local copy a plugin-wide publish sends. */
784
+ interface PublishedCopy {
785
+ skill: string;
786
+ from: "project" | "global";
787
+ folder: string;
788
+ skillDir: string;
789
+ cmp: LocalCompare;
790
+ committed: SkillFile[];
791
+ keepReason: string | null;
792
+ managed: boolean;
793
+ predicted: LocalCopy;
794
+ }
795
+
796
+ /**
797
+ * `market publish --plugin <p@m>` with no skill: every skill of the plugin
798
+ * whose local copy here has edits the plugin has not seen (`edited` in
799
+ * `market status`), sent as one commit and one patch version. A copy whose
800
+ * origin is unknown, or that changed on both sides, is listed and left out:
801
+ * it goes on its own, by name, with --force. Read-only.
802
+ */
803
+ export async function planPluginPublish(
804
+ args: PluginPublishArgs,
805
+ cwd: string,
806
+ ): Promise<PlanAnswer> {
807
+ const command: MarketCommand = "market publish";
808
+ try {
809
+ const view = await readPlugin(args.plugin);
810
+ const { m, snap, plugin } = view;
811
+ const pluginId = `${plugin}@${m.name}`;
812
+ const prefix = joinRel(view.dir, "skills", "");
813
+ const skills = new Set<string>();
814
+ for (const p of snap.entries.keys()) {
815
+ if (!p.startsWith(`${prefix}/`)) continue;
816
+ const rest = p.slice(prefix.length + 1).split("/");
817
+ if (rest.length === 2 && rest[1] === "SKILL.md" && rest[0]) {
818
+ skills.add(rest[0]);
819
+ }
820
+ }
821
+ const folders = localSkillFolders(cwd).filter((f) => skills.has(f.skill));
822
+ const edited: Array<{ f: LocalFolder; cmp: LocalCompare }> = [];
823
+ const leftOut: Array<{
824
+ skill: string;
825
+ from: "project" | "global";
826
+ state: "no-base" | "conflict";
827
+ }> = [];
828
+ for (const f of folders) {
829
+ const cmp = await compareLocal(f.path, view, f.skill);
830
+ if (cmp.state === "fast") edited.push({ f, cmp });
831
+ else if (cmp.state === "no-base" || cmp.state === "conflict") {
832
+ leftOut.push({
833
+ skill: f.skill,
834
+ from: f.from === "user" ? "global" : "project",
835
+ state: cmp.state,
836
+ });
837
+ }
838
+ }
839
+ const twice = edited.find(
840
+ (e, i) => edited.findIndex((o) => o.f.skill === e.f.skill) !== i,
841
+ );
842
+ if (twice) {
843
+ throw new MarketError(
844
+ "ambiguous",
845
+ `Both this project and your USER skills have edits to ${twice.f.skill}`,
846
+ `publish one at a time: \`magus market publish ${twice.f.skill} --plugin ${pluginId} --from project\` (or --from global)`,
847
+ );
848
+ }
849
+ const leftOutLines = leftOut.map(
850
+ (l) =>
851
+ `Not included: ${l.skill} (${STATE_WORDS[l.state]}) — publish it on its own with \`magus market publish ${l.skill} --plugin ${pluginId}${l.from === "global" ? " --from global" : ""} --force\``,
852
+ );
853
+ const base = { marketplace: m.name, plugin };
854
+ if (edited.length === 0) {
855
+ if (leftOut.length > 0) {
856
+ const message = `No skill of ${pluginId} has edits here that it can publish as they are — nothing changed`;
857
+ return {
858
+ kind: "result",
859
+ result: {
860
+ command,
861
+ outcome: "failed",
862
+ message,
863
+ ...base,
864
+ plan: leftOutLines,
865
+ steps: [],
866
+ error: {
867
+ code: leftOut[0]?.state ?? "no-base",
868
+ message,
869
+ fix: "publish each one on its own with --force, which asks before it replaces the plugin's version",
870
+ },
871
+ },
872
+ };
873
+ }
874
+ return {
875
+ kind: "result",
876
+ result: {
877
+ command,
878
+ outcome: "unchanged",
879
+ message: `No skill of ${pluginId} has edits here to publish`,
880
+ ...base,
881
+ steps: [],
882
+ },
883
+ };
884
+ }
885
+
886
+ const { from, to } = bumpFrom(view);
887
+ const target = await deliveryTarget(m.source, m.name);
888
+ const installs = await installedScopes(pluginId, cwd);
889
+ const copies: PublishedCopy[] = [];
890
+ for (const { f, cmp } of edited) {
891
+ const fromWord = f.from === "user" ? "global" : "project";
892
+ const committed: SkillFile[] = [
893
+ ...cmp.sets.published,
894
+ ...cmp.theirsOut.map((t) => ({ path: t.path, oid: t.oid })),
895
+ ];
896
+ const { reason, managed } = await keepReasonFor({
897
+ folder: f.path,
898
+ skill: f.skill,
899
+ from: fromWord,
900
+ keepLocal: args.keepLocal,
901
+ plugin,
902
+ pluginId,
903
+ installs,
904
+ committed,
905
+ cwd,
906
+ });
907
+ const scope = f.from;
908
+ copies.push({
909
+ skill: f.skill,
910
+ from: fromWord,
911
+ folder: f.path,
912
+ skillDir: joinRel(view.dir, "skills", f.skill),
913
+ cmp,
914
+ committed,
915
+ keepReason: reason,
916
+ managed,
917
+ predicted: reason
918
+ ? { scope, name: f.skill, action: "kept", reason }
919
+ : { scope, name: f.skill, action: "removed" },
920
+ });
921
+ }
922
+ const files = [
923
+ ...copies.flatMap((c) =>
924
+ c.cmp.changes.map((ch) => ({
925
+ path: joinRel(c.skillDir, ch.path),
926
+ change: ch.change,
927
+ })),
928
+ ),
929
+ ...versionFiles(view),
930
+ ];
931
+ const names = copies.map((c) => c.skill);
932
+ const state: ChangeSpec["state"] & { moved?: boolean } = {};
933
+ const steps: MarketStep[] = [
934
+ ...copies.map((c) =>
935
+ replaceStep(
936
+ c.folder,
937
+ c.skillDir,
938
+ plugin,
939
+ c.skill,
940
+ c.cmp,
941
+ c.committed,
942
+ `replace:${c.skill}`,
943
+ ),
944
+ ),
945
+ bumpStep(view, to),
946
+ ...changeSteps({
947
+ label: m.name,
948
+ source: m.source,
949
+ branch: snap.branch,
950
+ target,
951
+ stage: [
952
+ ...copies.map((c) => c.skillDir),
953
+ ...versionFiles(view).map((f) => f.path),
954
+ ],
955
+ files,
956
+ message: `${plugin}: update ${names.join(", ")} (${to})`,
957
+ slug: `${plugin}-${to}`,
958
+ prBody: `Updates ${plural(names.length, "skill")} in \`${plugin}\` and bumps it to ${to}.\n\n${copies.map((c) => `- \`${c.skill}\`: ${changeList(c.cmp.changes)}`).join("\n")}`,
959
+ verify: copies.map((c) => ({ dir: c.skillDir, files: c.committed })),
960
+ state,
961
+ }).map((s) =>
962
+ s.id === "deliver"
963
+ ? {
964
+ ...s,
965
+ // A pull request stops the apply here: nothing is installed,
966
+ // so every local copy stays until it is merged.
967
+ run: async (ctx: Parameters<MarketStep["run"]>[0]) => {
968
+ const out = await s.run(ctx);
969
+ if (!out?.stop) return out;
970
+ const kept: LocalCopy[] = copies.map((c) => ({
971
+ scope: c.predicted.scope,
972
+ name: c.skill,
973
+ action: "kept",
974
+ reason:
975
+ "the change went out as a pull request; the plugin's version replaces this copy once it is merged",
976
+ }));
977
+ return {
978
+ ...out,
979
+ patch: { ...out.patch, localCopies: kept },
980
+ };
981
+ },
982
+ }
983
+ : s,
984
+ ),
985
+ installStep(view, installs, to, state),
986
+ {
987
+ id: "local-copy",
988
+ label: "settled the local copies",
989
+ outside: true,
990
+ async run() {
991
+ const settled: LocalCopy[] = [];
992
+ for (const c of copies) {
993
+ const copy = await settleLocalCopy({
994
+ folder: c.folder,
995
+ committed: c.committed,
996
+ copy: { scope: c.predicted.scope, name: c.skill },
997
+ keepReason: c.keepReason,
998
+ moved: state.moved === true,
999
+ plugin,
1000
+ pluginId,
1001
+ cwd,
1002
+ });
1003
+ if (
1004
+ copy.action === "kept" &&
1005
+ !copy.partial &&
1006
+ !c.managed &&
1007
+ state.commit
1008
+ ) {
1009
+ recordBase(c.folder, {
1010
+ marketplace: m.name,
1011
+ repo: m.source,
1012
+ plugin,
1013
+ skill: c.skill,
1014
+ commit: state.commit,
1015
+ tree: c.cmp.sets.hash,
1016
+ });
1017
+ }
1018
+ settled.push(copy);
1019
+ }
1020
+ return { patch: { localCopies: settled } };
1021
+ },
1022
+ },
1023
+ ];
1024
+ const lines = [
1025
+ `Publishes ${plural(copies.length, "edited skill")} to ${pluginId} as one new version: ${names.join(", ")}`,
1026
+ versionLine(plugin, from, to),
1027
+ ...copies.flatMap((c) => [
1028
+ `${c.skill} (${whoseCopy(c.from)}): ${plural(c.cmp.changes.length, "file")} — ${changeList(c.cmp.changes)}`,
1029
+ ...(c.cmp.sets.notPublished.length > 0
1030
+ ? [
1031
+ `${c.skill}, not published: ${c.cmp.sets.notPublished.map((n) => `${n.path} (${n.reason})`).join(", ")}`,
1032
+ ]
1033
+ : []),
1034
+ ]),
1035
+ ...leftOutLines,
1036
+ targetLine(target, snap.branch),
1037
+ "A pull request moves no install and keeps the local copies",
1038
+ ...installLines(plugin, installs, to),
1039
+ ...copies.map((c) =>
1040
+ c.predicted.action === "removed"
1041
+ ? `Afterwards removes ${whoseCopy(c.from)} of ${c.skill}`
1042
+ : `Keeps ${whoseCopy(c.from)} of ${c.skill}: ${c.predicted.reason}`,
1043
+ ),
1044
+ ];
1045
+ const plan: MarketPlan = {
1046
+ command,
1047
+ lines,
1048
+ planId: computePlanId({
1049
+ op: "publish-plugin",
1050
+ plugin: pluginId,
1051
+ keepLocal: args.keepLocal,
1052
+ branch: snap.branch,
1053
+ commit: snap.commit,
1054
+ skills: copies.map((c) => ({
1055
+ skill: c.skill,
1056
+ from: c.from,
1057
+ base: c.cmp.baseCommit,
1058
+ hash: c.cmp.sets.hash,
1059
+ notPublished: c.cmp.sets.notPublished,
1060
+ localCopy: c.predicted,
1061
+ })),
1062
+ leftOut,
1063
+ target,
1064
+ version: { from, to },
1065
+ installs,
1066
+ }),
1067
+ summary: { ...base, version: { from, to }, files, target },
1068
+ steps,
1069
+ sync: { name: m.name, url: cloneUrl(m.source) },
1070
+ lock: m.name,
1071
+ replan: () => planPluginPublish({ ...args, plugin: pluginId }, cwd),
1072
+ doneMessage: `${names.join(", ")} ${names.length === 1 ? "is" : "are"} published in ${pluginId} ${to}. Takes effect in new sessions, or after /reload-plugins.`,
1073
+ };
1074
+ return { kind: "plan", plan };
1075
+ } catch (error) {
1076
+ return planError(command, error);
1077
+ }
1078
+ }
1079
+
1080
+ function capital(text: string): string {
1081
+ return text.charAt(0).toUpperCase() + text.slice(1);
1082
+ }
1083
+
1084
+ /** Remove `rel`'s now-empty parent directories, up to (not including) `root`. */
1085
+ function pruneEmptyParents(root: string, rel: string): void {
1086
+ let dir = path.posix.dirname(rel);
1087
+ while (dir !== "." && dir !== "") {
1088
+ try {
1089
+ fs.rmdirSync(path.join(root, dir));
1090
+ } catch {
1091
+ return;
1092
+ }
1093
+ dir = path.posix.dirname(dir);
1094
+ }
1095
+ }
1096
+
1097
+ /**
1098
+ * Replace the in-domain part of the plugin's skill with the published set:
1099
+ * write every published file, delete in-domain files the copy no longer has,
1100
+ * leave everything outside the domain as it is. Then check the result holds
1101
+ * exactly the committed files, the published ones with their planned ids.
1102
+ */
1103
+ function replaceStep(
1104
+ folder: string,
1105
+ skillDir: string,
1106
+ plugin: string,
1107
+ skill: string,
1108
+ cmp: LocalCompare,
1109
+ committed: readonly SkillFile[],
1110
+ id = "replace",
1111
+ ): MarketStep {
1112
+ return {
1113
+ id,
1114
+ label: `copied ${skill} into ${plugin}`,
1115
+ outside: false,
1116
+ async run(ctx) {
1117
+ const clone = cloneOf(ctx);
1118
+ const dest = path.join(clone, skillDir);
1119
+ const changed = new MarketError(
1120
+ "plan-changed",
1121
+ `${skill} changed while this ran — nothing was pushed`,
1122
+ "run the command again to see the new plan",
1123
+ );
1124
+ // A link committed in the marketplace would carry a write or a
1125
+ // delete outside the clone: refuse before touching anything.
1126
+ const linked = (link: string) =>
1127
+ new MarketError(
1128
+ "other",
1129
+ `${plugin} holds a link at ${link}, and ${skill} would be written through it — nothing was changed`,
1130
+ "replace the link with a folder in the marketplace repository, then publish again",
1131
+ );
1132
+ for (const f of [...cmp.theirsIn, ...cmp.sets.published]) {
1133
+ refuseLinkUnder(clone, joinRel(skillDir, f.path), linked);
1134
+ }
1135
+ const keep = new Set(cmp.sets.published.map((f) => f.path));
1136
+ // The compare is by content: a file the plugin already holds keeps
1137
+ // its committed mode, so a mode that differs only here is neither an
1138
+ // unplanned change nor a silently lost executable bit.
1139
+ const modes = new Map(cmp.theirsIn.map((f) => [f.path, f.mode]));
1140
+ for (const f of cmp.theirsIn) {
1141
+ if (keep.has(f.path)) continue;
1142
+ fs.rmSync(path.join(dest, f.path), { force: true });
1143
+ pruneEmptyParents(dest, f.path);
1144
+ }
1145
+ for (const f of cmp.sets.published) {
1146
+ const out = path.join(dest, f.path);
1147
+ fs.mkdirSync(path.dirname(out), { recursive: true });
1148
+ refuseLinkUnder(clone, joinRel(skillDir, f.path), linked);
1149
+ // A new file every time: never written through what was there.
1150
+ fs.rmSync(out, { force: true });
1151
+ fs.copyFileSync(
1152
+ path.join(folder, f.path),
1153
+ out,
1154
+ fs.constants.COPYFILE_EXCL,
1155
+ );
1156
+ const mode = modes.get(f.path);
1157
+ if (mode === "100755") fs.chmodSync(out, 0o755);
1158
+ else if (mode === "100644") fs.chmodSync(out, 0o644);
1159
+ }
1160
+ const want = new Set(committed.map((f) => f.path));
1161
+ const now = inventory(dest);
1162
+ if (now.length !== want.size || now.some((i) => !want.has(i.path))) {
1163
+ throw changed;
1164
+ }
1165
+ const ids = await hashObject(
1166
+ dest,
1167
+ cmp.sets.published.map((f) => f.path),
1168
+ );
1169
+ if (
1170
+ !ids.ok ||
1171
+ cmp.sets.published.some((f, i) => ids.value[i] !== f.oid)
1172
+ ) {
1173
+ throw changed;
1174
+ }
1175
+ return undefined;
1176
+ },
1177
+ };
1178
+ }
1179
+
1180
+ /**
1181
+ * A publish dry run on state older than GitHub's: what the local copy holds,
1182
+ * and no compare. Nothing is decided from it.
1183
+ */
1184
+ export function provisionalPublish(
1185
+ args: PublishArgs,
1186
+ cwd: string,
1187
+ error: MarketError,
1188
+ ): MarketResult {
1189
+ const command: MarketCommand = "market publish";
1190
+ let lines: string[] = [];
1191
+ try {
1192
+ const folder = sourceFolder(cwd, args);
1193
+ const items = inventory(folder);
1194
+ const shaped = items.map((i) => ({
1195
+ path: i.path,
1196
+ why: excludedByShape(i.path, i.symlink),
1197
+ }));
1198
+ const kept = shaped.filter((s) => s.why === null).map((s) => s.path);
1199
+ const left = shaped.filter((s) => s.why !== null);
1200
+ lines = [
1201
+ `Would publish ${whoseCopy(args.from)} of ${args.skill}${args.plugin ? ` to ${args.plugin}` : ""}`,
1202
+ `Files here: ${kept.join(", ") || "none"}`,
1203
+ ...(left.length > 0
1204
+ ? [
1205
+ `Never published: ${left.map((s) => `${s.path} (${s.why})`).join(", ")}`,
1206
+ ]
1207
+ : []),
1208
+ "Not compared with the plugin: GitHub's current state could not be read",
1209
+ ];
1210
+ } catch (inner) {
1211
+ return errorResult(command, inner);
1212
+ }
1213
+ return {
1214
+ command,
1215
+ outcome: "stale",
1216
+ stale: true,
1217
+ message: `Provisional — ${error.message}. Nothing was compared or changed.`,
1218
+ skill: args.skill,
1219
+ plan: lines,
1220
+ steps: [],
1221
+ error: {
1222
+ code: error.code,
1223
+ message: error.message,
1224
+ fix: error.fix ?? "run `magus market fetch`",
1225
+ },
1226
+ };
1227
+ }