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,686 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* market-ops.ts — the one path every `magus market` verb takes, CLI and TUI.
|
|
3
|
+
*
|
|
4
|
+
* A planner (`planX` in market-ops-repo.ts and market-ops-content.ts) is
|
|
5
|
+
* read-only and answers a {@link MarketPlan}. {@link applyMarketPlan} is the
|
|
6
|
+
* only writer: it takes the marketplace's lock, syncs magus's working clone,
|
|
7
|
+
* plans again from that fresh state, refuses when the `planId` differs from
|
|
8
|
+
* the one consented to, then runs the plan's steps in order and stops at the
|
|
9
|
+
* first failure. The working clone is reset and cleaned however it ends.
|
|
10
|
+
*
|
|
11
|
+
* `market list` and `market fetch` live here too: list reads, fetch writes
|
|
12
|
+
* only magus's own working clones.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import {
|
|
19
|
+
type Snapshot,
|
|
20
|
+
acquireMarketLock,
|
|
21
|
+
cloneUrl,
|
|
22
|
+
readRemoteState,
|
|
23
|
+
resetWorkingClone,
|
|
24
|
+
syncWorkingClone,
|
|
25
|
+
} from "./market-clone.js";
|
|
26
|
+
import {
|
|
27
|
+
currentVersion,
|
|
28
|
+
joinRel,
|
|
29
|
+
pluginDirFor,
|
|
30
|
+
pluginEntries,
|
|
31
|
+
pluginRelDir,
|
|
32
|
+
pluginRootOf,
|
|
33
|
+
} from "./market-layout.js";
|
|
34
|
+
import { localItems } from "./market-local.js";
|
|
35
|
+
import {
|
|
36
|
+
type TrustedRegistration,
|
|
37
|
+
isGithubSource,
|
|
38
|
+
listCompanyMarketplaces,
|
|
39
|
+
} from "./market-registry.js";
|
|
40
|
+
import {
|
|
41
|
+
MarketError,
|
|
42
|
+
type MarketErrorCode,
|
|
43
|
+
type MarketPlan,
|
|
44
|
+
type MarketResult,
|
|
45
|
+
type MarketplaceRow,
|
|
46
|
+
type PlanAnswer,
|
|
47
|
+
type StepRecord,
|
|
48
|
+
staleError,
|
|
49
|
+
} from "./market-types.js";
|
|
50
|
+
|
|
51
|
+
export type MarketCommand = `market ${string}`;
|
|
52
|
+
|
|
53
|
+
// ─── Exit codes ──────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
const EXIT_BY_CODE: Partial<Record<MarketErrorCode, number>> = {
|
|
56
|
+
"gh-auth": 4,
|
|
57
|
+
"plan-changed": 3,
|
|
58
|
+
stale: 3,
|
|
59
|
+
"not-found": 2,
|
|
60
|
+
ambiguous: 2,
|
|
61
|
+
"invalid-name": 2,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** The process exit status for a result. */
|
|
65
|
+
export function exitCodeFor(result: MarketResult, usage = false): number {
|
|
66
|
+
if (usage) return 2;
|
|
67
|
+
switch (result.outcome) {
|
|
68
|
+
case "ok":
|
|
69
|
+
case "unchanged":
|
|
70
|
+
case "plugin-newer":
|
|
71
|
+
case "pull-request":
|
|
72
|
+
return 0;
|
|
73
|
+
case "needs-consent":
|
|
74
|
+
case "stale":
|
|
75
|
+
return 3;
|
|
76
|
+
default:
|
|
77
|
+
return result.error ? (EXIT_BY_CODE[result.error.code] ?? 1) : 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Results ─────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
function errorText(error: unknown): string {
|
|
84
|
+
return error instanceof Error ? error.message : String(error);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A failure as a result. A `MarketError` keeps its code; anything else is `other`. */
|
|
88
|
+
export function errorResult(
|
|
89
|
+
command: MarketCommand,
|
|
90
|
+
error: unknown,
|
|
91
|
+
extra: Partial<MarketResult> = {},
|
|
92
|
+
steps: StepRecord[] = [],
|
|
93
|
+
): MarketResult {
|
|
94
|
+
const e =
|
|
95
|
+
error instanceof MarketError
|
|
96
|
+
? error
|
|
97
|
+
: new MarketError(
|
|
98
|
+
/claude CLI not found/i.test(errorText(error))
|
|
99
|
+
? "claude-missing"
|
|
100
|
+
: "other",
|
|
101
|
+
errorText(error),
|
|
102
|
+
);
|
|
103
|
+
const stale = e.code === "stale";
|
|
104
|
+
const changed = e.code === "plan-changed";
|
|
105
|
+
return {
|
|
106
|
+
...extra,
|
|
107
|
+
command,
|
|
108
|
+
outcome: stale ? "stale" : changed ? "needs-consent" : "failed",
|
|
109
|
+
message: e.message,
|
|
110
|
+
...(stale ? { stale: true } : {}),
|
|
111
|
+
steps,
|
|
112
|
+
error: {
|
|
113
|
+
code: e.code,
|
|
114
|
+
message: e.message,
|
|
115
|
+
...(e.fix ? { fix: e.fix } : {}),
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Exit status of a usage error carried by a `MarketError`. */
|
|
121
|
+
export function errorExit(error: unknown): number | undefined {
|
|
122
|
+
return error instanceof MarketError ? error.exit : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A planner's answer for an error. */
|
|
126
|
+
export function planError(command: MarketCommand, error: unknown): PlanAnswer {
|
|
127
|
+
if (error instanceof MarketError) {
|
|
128
|
+
return { kind: "error", command, error };
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
kind: "error",
|
|
132
|
+
command,
|
|
133
|
+
error: new MarketError("other", errorText(error)),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The result for a plan nobody has consented to: nothing written, exit 3. */
|
|
138
|
+
export function needsConsent(plan: MarketPlan): MarketResult {
|
|
139
|
+
return {
|
|
140
|
+
command: plan.command,
|
|
141
|
+
outcome: "needs-consent",
|
|
142
|
+
message: "Nothing was changed: this plan needs consent (--yes=<planId>).",
|
|
143
|
+
planId: plan.planId,
|
|
144
|
+
...plan.summary,
|
|
145
|
+
plan: plan.lines,
|
|
146
|
+
steps: [],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── planId ──────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
function stable(value: unknown): unknown {
|
|
153
|
+
if (Array.isArray(value)) return value.map(stable);
|
|
154
|
+
if (value && typeof value === "object") {
|
|
155
|
+
const out: Record<string, unknown> = {};
|
|
156
|
+
for (const key of Object.keys(value as object).sort()) {
|
|
157
|
+
const v = (value as Record<string, unknown>)[key];
|
|
158
|
+
if (v !== undefined) out[key] = stable(v);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The hash of every material fact of a plan; consent binds to it. */
|
|
166
|
+
export function computePlanId(facts: Record<string, unknown>): string {
|
|
167
|
+
return createHash("sha256")
|
|
168
|
+
.update(JSON.stringify(stable(facts)))
|
|
169
|
+
.digest("hex")
|
|
170
|
+
.slice(0, 16);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Resolving names ─────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
/** The company marketplace named `name`, or `not-found` (exit 2). */
|
|
176
|
+
export async function companyByName(
|
|
177
|
+
name: string,
|
|
178
|
+
): Promise<TrustedRegistration> {
|
|
179
|
+
const all = await listCompanyMarketplaces();
|
|
180
|
+
const found = all.find((m) => m.name === name);
|
|
181
|
+
if (found) return found;
|
|
182
|
+
throw new MarketError(
|
|
183
|
+
"not-found",
|
|
184
|
+
`${name} is not a company marketplace on this machine`,
|
|
185
|
+
all.length > 0
|
|
186
|
+
? `company marketplaces here: ${all.map((m) => m.name).join(", ")}`
|
|
187
|
+
: "run `magus market create` or `magus market connect` first",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Split `plugin@marketplace`. The `@marketplace` part may be left out when
|
|
193
|
+
* this machine has exactly one company marketplace.
|
|
194
|
+
*/
|
|
195
|
+
export async function resolvePluginRef(
|
|
196
|
+
ref: string,
|
|
197
|
+
): Promise<{ plugin: string; m: TrustedRegistration }> {
|
|
198
|
+
const at = ref.lastIndexOf("@");
|
|
199
|
+
if (at > 0) {
|
|
200
|
+
return {
|
|
201
|
+
plugin: ref.slice(0, at),
|
|
202
|
+
m: await companyByName(ref.slice(at + 1)),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const all = await listCompanyMarketplaces();
|
|
206
|
+
if (all.length === 1 && all[0]) return { plugin: ref, m: all[0] };
|
|
207
|
+
if (all.length === 0) {
|
|
208
|
+
throw new MarketError(
|
|
209
|
+
"not-found",
|
|
210
|
+
"There is no company marketplace on this machine",
|
|
211
|
+
"run `magus market create` or `magus market connect` first",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
throw new MarketError(
|
|
215
|
+
"ambiguous",
|
|
216
|
+
`More than one company marketplace is on this machine (${all.map((m) => m.name).join(", ")})`,
|
|
217
|
+
`name it: ${ref}@<marketplace>`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The marketplace as GitHub has it now, or a `stale` error: a write verb
|
|
223
|
+
* decides nothing from state older than GitHub's.
|
|
224
|
+
*/
|
|
225
|
+
export async function freshSnapshot(m: TrustedRegistration): Promise<Snapshot> {
|
|
226
|
+
const state = await readRemoteState(m);
|
|
227
|
+
if (state.fresh) return state.snapshot;
|
|
228
|
+
if (state.failure === "auth") {
|
|
229
|
+
throw new MarketError(
|
|
230
|
+
"gh-auth",
|
|
231
|
+
`Could not read ${m.name}: ${state.reason}`,
|
|
232
|
+
state.fix,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (state.failure === "no-network") {
|
|
236
|
+
throw new MarketError(
|
|
237
|
+
"no-network",
|
|
238
|
+
`Could not reach ${m.name}'s repository`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
throw staleError([m.name], `${m.name}: ${state.reason}`, state.fix);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The manifest at the snapshot, as an object. */
|
|
245
|
+
export async function manifestAt(
|
|
246
|
+
snap: Snapshot,
|
|
247
|
+
label: string,
|
|
248
|
+
): Promise<Record<string, unknown>> {
|
|
249
|
+
const manifest = await snap.readJson(".claude-plugin/marketplace.json");
|
|
250
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
251
|
+
throw new MarketError("other", `${label} holds no marketplace manifest`);
|
|
252
|
+
}
|
|
253
|
+
return manifest as Record<string, unknown>;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── Apply ───────────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
export interface ApplyOptions {
|
|
259
|
+
/** The planId consented to; null applies whatever the fresh plan is. */
|
|
260
|
+
approved: string | null;
|
|
261
|
+
cwd: string;
|
|
262
|
+
/** Called as each step settles. */
|
|
263
|
+
onStep?: (step: StepRecord, label: string) => void;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Apply a plan. Lock, sync, plan again, compare the planId, run the steps.
|
|
268
|
+
* A changed plan writes nothing and answers `needs-consent` with
|
|
269
|
+
* `error.code: "plan-changed"` and the fresh plan.
|
|
270
|
+
*/
|
|
271
|
+
export async function applyMarketPlan(
|
|
272
|
+
plan: MarketPlan,
|
|
273
|
+
opts: ApplyOptions,
|
|
274
|
+
): Promise<MarketResult> {
|
|
275
|
+
const steps: StepRecord[] = [];
|
|
276
|
+
let release: (() => void) | null = null;
|
|
277
|
+
let synced: { dir: string; branch: string | null } | null = null;
|
|
278
|
+
let wroteOutside = false;
|
|
279
|
+
const lockName = plan.sync?.name ?? plan.lock;
|
|
280
|
+
try {
|
|
281
|
+
if (lockName) release = await acquireMarketLock(lockName);
|
|
282
|
+
if (plan.sync) {
|
|
283
|
+
synced = await syncWorkingClone(plan.sync.name, plan.sync.url);
|
|
284
|
+
}
|
|
285
|
+
const fresh = await plan.replan();
|
|
286
|
+
if (fresh.kind === "result") return { ...fresh.result, steps };
|
|
287
|
+
if (fresh.kind === "error") {
|
|
288
|
+
return errorResult(fresh.command, fresh.error, {}, steps);
|
|
289
|
+
}
|
|
290
|
+
const p = fresh.plan;
|
|
291
|
+
if (opts.approved !== null && p.planId !== opts.approved) {
|
|
292
|
+
return {
|
|
293
|
+
command: p.command,
|
|
294
|
+
outcome: "needs-consent",
|
|
295
|
+
message: "The plan changed since it was shown — nothing was changed.",
|
|
296
|
+
planId: p.planId,
|
|
297
|
+
...p.summary,
|
|
298
|
+
plan: p.lines,
|
|
299
|
+
steps,
|
|
300
|
+
error: {
|
|
301
|
+
code: "plan-changed",
|
|
302
|
+
message: "The plan changed since it was shown",
|
|
303
|
+
fix: "review the new plan, then run again with its planId",
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
let result: MarketResult = {
|
|
308
|
+
command: p.command,
|
|
309
|
+
outcome: "ok",
|
|
310
|
+
message: p.doneMessage,
|
|
311
|
+
planId: p.planId,
|
|
312
|
+
...p.summary,
|
|
313
|
+
plan: p.lines,
|
|
314
|
+
steps,
|
|
315
|
+
};
|
|
316
|
+
const ctx = { cwd: opts.cwd, clone: synced?.dir ?? null };
|
|
317
|
+
for (let i = 0; i < p.steps.length; i++) {
|
|
318
|
+
const step = p.steps[i];
|
|
319
|
+
if (!step) continue;
|
|
320
|
+
try {
|
|
321
|
+
const out = await step.run(ctx);
|
|
322
|
+
if (step.outside && !out?.skipped) wroteOutside = true;
|
|
323
|
+
const record: StepRecord = {
|
|
324
|
+
id: step.id,
|
|
325
|
+
status: out?.skipped ? "skipped" : "done",
|
|
326
|
+
...(out?.detail ? { detail: out.detail } : {}),
|
|
327
|
+
};
|
|
328
|
+
steps.push(record);
|
|
329
|
+
opts.onStep?.(record, step.label);
|
|
330
|
+
if (out?.patch) result = { ...result, ...out.patch, steps };
|
|
331
|
+
if (out?.stop) {
|
|
332
|
+
for (const rest of p.steps.slice(i + 1)) {
|
|
333
|
+
steps.push({ id: rest.id, status: "skipped" });
|
|
334
|
+
}
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (error instanceof MarketError && error.outsideWrite) {
|
|
339
|
+
wroteOutside = true;
|
|
340
|
+
}
|
|
341
|
+
const record: StepRecord = {
|
|
342
|
+
id: step.id,
|
|
343
|
+
status: "failed",
|
|
344
|
+
detail: errorText(error),
|
|
345
|
+
};
|
|
346
|
+
steps.push(record);
|
|
347
|
+
opts.onStep?.(record, step.label);
|
|
348
|
+
for (const rest of p.steps.slice(i + 1)) {
|
|
349
|
+
steps.push({ id: rest.id, status: "skipped" });
|
|
350
|
+
}
|
|
351
|
+
const failed = errorResult(p.command, error, result, steps);
|
|
352
|
+
// "Nothing changed" only while every write so far stayed inside
|
|
353
|
+
// magus's own working clone.
|
|
354
|
+
if (!wroteOutside && failed.error?.code !== "plan-changed") {
|
|
355
|
+
failed.message = `${failed.message} — nothing changed`;
|
|
356
|
+
}
|
|
357
|
+
// GitHub moved while this ran (a rejected non-fast-forward push):
|
|
358
|
+
// nothing was pushed, and the answer carries the plan as it now
|
|
359
|
+
// stands, for a fresh consent.
|
|
360
|
+
if (failed.error?.code === "plan-changed" && plan.sync) {
|
|
361
|
+
try {
|
|
362
|
+
synced = await syncWorkingClone(plan.sync.name, plan.sync.url);
|
|
363
|
+
const again = await p.replan();
|
|
364
|
+
if (again.kind === "plan") {
|
|
365
|
+
return {
|
|
366
|
+
...failed,
|
|
367
|
+
...again.plan.summary,
|
|
368
|
+
planId: again.plan.planId,
|
|
369
|
+
plan: again.plan.lines,
|
|
370
|
+
steps,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
} catch {
|
|
374
|
+
// The failure stands without a fresh plan.
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return failed;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return result;
|
|
381
|
+
} catch (error) {
|
|
382
|
+
return errorResult(plan.command, error, {}, steps);
|
|
383
|
+
} finally {
|
|
384
|
+
if (synced) {
|
|
385
|
+
try {
|
|
386
|
+
await resetWorkingClone(synced.dir, synced.branch);
|
|
387
|
+
} catch {
|
|
388
|
+
// The next sync resets it again.
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
release?.();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ─── list ────────────────────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
function readJsonFile(file: string): unknown {
|
|
398
|
+
try {
|
|
399
|
+
return JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
|
|
400
|
+
} catch {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The company a marketplace belongs to: its manifest's `owner.name`, else the
|
|
407
|
+
* GitHub owner of its source. Undefined for a non-GitHub source whose
|
|
408
|
+
* manifest names no owner.
|
|
409
|
+
*/
|
|
410
|
+
export function companyOf(
|
|
411
|
+
manifest: unknown,
|
|
412
|
+
source: string,
|
|
413
|
+
): string | undefined {
|
|
414
|
+
const owner =
|
|
415
|
+
manifest && typeof manifest === "object" && !Array.isArray(manifest)
|
|
416
|
+
? (manifest as { owner?: unknown }).owner
|
|
417
|
+
: undefined;
|
|
418
|
+
const name =
|
|
419
|
+
owner && typeof owner === "object"
|
|
420
|
+
? (owner as { name?: unknown }).name
|
|
421
|
+
: undefined;
|
|
422
|
+
if (typeof name === "string" && name.trim()) return name.trim();
|
|
423
|
+
if (isGithubSource(source)) return source.split("/")[0] || undefined;
|
|
424
|
+
return undefined;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The plugins and skills of a marketplace as Claude Code's copy holds them. */
|
|
428
|
+
function rowFor(m: TrustedRegistration): MarketplaceRow {
|
|
429
|
+
const row: MarketplaceRow = {
|
|
430
|
+
name: m.name,
|
|
431
|
+
repo: m.source,
|
|
432
|
+
scope: "user",
|
|
433
|
+
plugins: [],
|
|
434
|
+
};
|
|
435
|
+
const fallback = companyOf(null, m.source);
|
|
436
|
+
if (fallback) row.company = fallback;
|
|
437
|
+
if (!m.location) return row;
|
|
438
|
+
const manifest = readJsonFile(
|
|
439
|
+
path.join(m.location, ".claude-plugin", "marketplace.json"),
|
|
440
|
+
);
|
|
441
|
+
const company = companyOf(manifest, m.source);
|
|
442
|
+
if (company) row.company = company;
|
|
443
|
+
const root = pluginRootOf(manifest);
|
|
444
|
+
for (const entry of pluginEntries(manifest)) {
|
|
445
|
+
const dir = pluginDirFor(m.location, entry.source, root);
|
|
446
|
+
const pluginJson = dir
|
|
447
|
+
? readJsonFile(path.join(dir, ".claude-plugin", "plugin.json"))
|
|
448
|
+
: null;
|
|
449
|
+
let skills: string[] = [];
|
|
450
|
+
if (dir) {
|
|
451
|
+
try {
|
|
452
|
+
skills = fs
|
|
453
|
+
.readdirSync(path.join(dir, "skills"), { withFileTypes: true })
|
|
454
|
+
.filter(
|
|
455
|
+
(d) =>
|
|
456
|
+
d.isDirectory() &&
|
|
457
|
+
fs.existsSync(path.join(dir, "skills", d.name, "SKILL.md")),
|
|
458
|
+
)
|
|
459
|
+
.map((d) => d.name)
|
|
460
|
+
.sort();
|
|
461
|
+
} catch {
|
|
462
|
+
skills = [];
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
row.plugins.push({
|
|
466
|
+
name: entry.name,
|
|
467
|
+
version: currentVersion(pluginJson, entry) ?? "0.0.0",
|
|
468
|
+
skills,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
return row;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Every plugin name among `marketplaces`. */
|
|
475
|
+
export function companyPluginNames(
|
|
476
|
+
marketplaces: ReadonlyArray<{ plugins: MarketplaceRow["plugins"] }>,
|
|
477
|
+
): Set<string> {
|
|
478
|
+
return new Set(marketplaces.flatMap((m) => m.plugins.map((p) => p.name)));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Every skill name a company plugin among `marketplaces` holds. */
|
|
482
|
+
export function companySkillNames(
|
|
483
|
+
marketplaces: ReadonlyArray<{ plugins: MarketplaceRow["plugins"] }>,
|
|
484
|
+
): Set<string> {
|
|
485
|
+
return new Set(
|
|
486
|
+
marketplaces.flatMap((m) => m.plugins.flatMap((p) => p.skills)),
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* `market list`: every company marketplace here with its plugins, and the
|
|
492
|
+
* skills and plugin folders here that none of them holds. Read-only.
|
|
493
|
+
*/
|
|
494
|
+
export async function marketList(
|
|
495
|
+
cwd: string = process.cwd(),
|
|
496
|
+
): Promise<MarketResult> {
|
|
497
|
+
const marketplaces = (await listCompanyMarketplaces()).map(rowFor);
|
|
498
|
+
const local = await localItems(
|
|
499
|
+
cwd,
|
|
500
|
+
companySkillNames(marketplaces),
|
|
501
|
+
companyPluginNames(marketplaces),
|
|
502
|
+
);
|
|
503
|
+
return {
|
|
504
|
+
command: "market list",
|
|
505
|
+
outcome: "ok",
|
|
506
|
+
message:
|
|
507
|
+
marketplaces.length === 0
|
|
508
|
+
? "No company marketplaces on this machine"
|
|
509
|
+
: `${marketplaces.length} company marketplace${marketplaces.length === 1 ? "" : "s"} on this machine`,
|
|
510
|
+
marketplaces,
|
|
511
|
+
local,
|
|
512
|
+
steps: [],
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ─── overview ────────────────────────────────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
/** How current the overview of one marketplace is. */
|
|
519
|
+
export type OverviewFreshness =
|
|
520
|
+
/** At GitHub's commit. */
|
|
521
|
+
| "fresh"
|
|
522
|
+
/** Read from an older local copy, or from nothing; `reason` says which. */
|
|
523
|
+
| "stale";
|
|
524
|
+
|
|
525
|
+
/** One company marketplace as the market screens show it. */
|
|
526
|
+
export interface MarketOverviewEntry {
|
|
527
|
+
name: string;
|
|
528
|
+
/** The normalised source: `owner/repo`, a URL, or a path. */
|
|
529
|
+
repo: string;
|
|
530
|
+
scope: "user";
|
|
531
|
+
freshness: OverviewFreshness;
|
|
532
|
+
/** Why the overview is not current, in the user's words; empty when fresh. */
|
|
533
|
+
reason: string;
|
|
534
|
+
/** What to do about it; empty when fresh. */
|
|
535
|
+
fix: string;
|
|
536
|
+
/** The commit the plugins were read at, when a local copy had one. */
|
|
537
|
+
commit: string | null;
|
|
538
|
+
/** The company it belongs to, as {@link companyOf} reads it. */
|
|
539
|
+
company?: string;
|
|
540
|
+
plugins: MarketplaceRow["plugins"];
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** The plugins of a marketplace snapshot, with their versions and skills. */
|
|
544
|
+
async function pluginsAt(
|
|
545
|
+
snap: Snapshot,
|
|
546
|
+
manifest: Record<string, unknown>,
|
|
547
|
+
): Promise<MarketplaceRow["plugins"]> {
|
|
548
|
+
const root = pluginRootOf(manifest);
|
|
549
|
+
const out: MarketplaceRow["plugins"] = [];
|
|
550
|
+
for (const entry of pluginEntries(manifest)) {
|
|
551
|
+
const dir = pluginRelDir(entry.source, root);
|
|
552
|
+
let pluginJson: unknown = null;
|
|
553
|
+
const skills: string[] = [];
|
|
554
|
+
if (dir !== null) {
|
|
555
|
+
try {
|
|
556
|
+
pluginJson = await snap.readJson(
|
|
557
|
+
joinRel(dir, ".claude-plugin", "plugin.json"),
|
|
558
|
+
);
|
|
559
|
+
} catch {
|
|
560
|
+
pluginJson = null;
|
|
561
|
+
}
|
|
562
|
+
const prefix = joinRel(dir, "skills", "");
|
|
563
|
+
for (const file of snap.entries.keys()) {
|
|
564
|
+
if (!file.startsWith(`${prefix}/`)) continue;
|
|
565
|
+
const rest = file.slice(prefix.length + 1).split("/");
|
|
566
|
+
if (rest.length === 2 && rest[1] === "SKILL.md" && rest[0]) {
|
|
567
|
+
skills.push(rest[0]);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
out.push({
|
|
572
|
+
name: entry.name,
|
|
573
|
+
version: currentVersion(pluginJson, entry) ?? "0.0.0",
|
|
574
|
+
skills: skills.sort(),
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
return out;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Every company marketplace here, with its plugins and skills as GitHub has
|
|
582
|
+
* them now when a local copy is at GitHub's commit, else as the newest local
|
|
583
|
+
* copy has them, marked stale. Read-only: nothing is cloned, fetched, reset
|
|
584
|
+
* or locked — the market screens open with this.
|
|
585
|
+
*/
|
|
586
|
+
export async function marketOverview(): Promise<MarketOverviewEntry[]> {
|
|
587
|
+
const out: MarketOverviewEntry[] = [];
|
|
588
|
+
for (const m of await listCompanyMarketplaces()) {
|
|
589
|
+
const state = await readRemoteState(m);
|
|
590
|
+
const entry: MarketOverviewEntry = {
|
|
591
|
+
name: m.name,
|
|
592
|
+
repo: m.source,
|
|
593
|
+
scope: "user",
|
|
594
|
+
freshness: state.fresh ? "fresh" : "stale",
|
|
595
|
+
reason: state.fresh ? "" : state.reason,
|
|
596
|
+
fix: state.fresh ? "" : state.fix,
|
|
597
|
+
commit: state.snapshot?.commit ?? null,
|
|
598
|
+
plugins: [],
|
|
599
|
+
};
|
|
600
|
+
let fromSnapshot = false;
|
|
601
|
+
if (state.snapshot) {
|
|
602
|
+
try {
|
|
603
|
+
const manifest = await manifestAt(state.snapshot, m.name);
|
|
604
|
+
entry.plugins = await pluginsAt(state.snapshot, manifest);
|
|
605
|
+
const company = companyOf(manifest, m.source);
|
|
606
|
+
if (company) entry.company = company;
|
|
607
|
+
fromSnapshot = true;
|
|
608
|
+
} catch {
|
|
609
|
+
fromSnapshot = false;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (!fromSnapshot) {
|
|
613
|
+
const row = rowFor(m);
|
|
614
|
+
entry.plugins = row.plugins;
|
|
615
|
+
if (row.company) entry.company = row.company;
|
|
616
|
+
}
|
|
617
|
+
out.push(entry);
|
|
618
|
+
}
|
|
619
|
+
return out;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ─── fetch ───────────────────────────────────────────────────────────────────
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* `market fetch [<m>]`: bring magus's working clones up to GitHub. Writes
|
|
626
|
+
* nothing else, so it needs no consent. Given several names (the ones a
|
|
627
|
+
* `stale` error carries), fetches only those; given none, every one.
|
|
628
|
+
*/
|
|
629
|
+
export async function marketFetch(
|
|
630
|
+
only?: string | readonly string[],
|
|
631
|
+
onStep?: ApplyOptions["onStep"],
|
|
632
|
+
): Promise<MarketResult> {
|
|
633
|
+
const command: MarketCommand = "market fetch";
|
|
634
|
+
const names = typeof only === "string" ? [only] : (only ?? []);
|
|
635
|
+
const name = names.length === 1 ? names[0] : undefined;
|
|
636
|
+
let targets: TrustedRegistration[];
|
|
637
|
+
try {
|
|
638
|
+
targets =
|
|
639
|
+
names.length > 0
|
|
640
|
+
? await Promise.all(names.map((n) => companyByName(n)))
|
|
641
|
+
: await listCompanyMarketplaces();
|
|
642
|
+
} catch (error) {
|
|
643
|
+
return errorResult(command, error);
|
|
644
|
+
}
|
|
645
|
+
const steps: StepRecord[] = [];
|
|
646
|
+
let firstError: MarketError | null = null;
|
|
647
|
+
for (const m of targets) {
|
|
648
|
+
let release: (() => void) | null = null;
|
|
649
|
+
const id = `fetch:${m.name}`;
|
|
650
|
+
try {
|
|
651
|
+
release = await acquireMarketLock(m.name);
|
|
652
|
+
const synced = await syncWorkingClone(m.name, cloneUrl(m.source));
|
|
653
|
+
const record: StepRecord = {
|
|
654
|
+
id,
|
|
655
|
+
status: "done",
|
|
656
|
+
detail: synced.commit ? synced.commit.slice(0, 7) : "empty",
|
|
657
|
+
};
|
|
658
|
+
steps.push(record);
|
|
659
|
+
onStep?.(record, `fetched ${m.name}`);
|
|
660
|
+
} catch (error) {
|
|
661
|
+
const e =
|
|
662
|
+
error instanceof MarketError
|
|
663
|
+
? error
|
|
664
|
+
: new MarketError("other", errorText(error));
|
|
665
|
+
if (!firstError || e.code === "gh-auth") firstError = e;
|
|
666
|
+
const record: StepRecord = { id, status: "failed", detail: e.message };
|
|
667
|
+
steps.push(record);
|
|
668
|
+
onStep?.(record, `fetch ${m.name}`);
|
|
669
|
+
} finally {
|
|
670
|
+
release?.();
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
if (firstError) {
|
|
674
|
+
return errorResult(command, firstError, {}, steps);
|
|
675
|
+
}
|
|
676
|
+
return {
|
|
677
|
+
command,
|
|
678
|
+
outcome: "ok",
|
|
679
|
+
message:
|
|
680
|
+
targets.length === 0
|
|
681
|
+
? "No company marketplaces on this machine"
|
|
682
|
+
: `Up to date with GitHub: ${targets.map((m) => m.name).join(", ")}`,
|
|
683
|
+
...(name ? { marketplace: name } : {}),
|
|
684
|
+
steps,
|
|
685
|
+
};
|
|
686
|
+
}
|