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,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* market-registry.ts — company marketplaces on this machine.
|
|
3
|
+
*
|
|
4
|
+
* A marketplace is a company marketplace here when two facts agree:
|
|
5
|
+
* - its manifest carries the marker (`market-marker.ts`) — the repository's
|
|
6
|
+
* own statement;
|
|
7
|
+
* - its name and source are on this machine's trust list — this machine's
|
|
8
|
+
* decision, made by `market create`/`connect` or by a project profile
|
|
9
|
+
* `magus install`/`update` acted on.
|
|
10
|
+
* The marker alone is not enough: a repository must not be able to rank itself
|
|
11
|
+
* above magus, or become a publish target, by committing a field.
|
|
12
|
+
*
|
|
13
|
+
* The trust list is magus's own file, `<claude config dir>/magus-market/trusted.json`.
|
|
14
|
+
* Claude Code's registry (`plugins/known_marketplaces.json`) is only ever read
|
|
15
|
+
* here.
|
|
16
|
+
*
|
|
17
|
+
* Reads resolve the config dir through `claudeConfigDirOrNull`, so a test that
|
|
18
|
+
* has not chosen one reads nothing instead of the operator's real state.
|
|
19
|
+
* Writes resolve it through `requireClaudeConfigDir`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import fs from "fs-extra";
|
|
25
|
+
import { getMarketplaceByName } from "../data/marketplaces.js";
|
|
26
|
+
import type { ProfileMarketplaceRef } from "../types/index.js";
|
|
27
|
+
import {
|
|
28
|
+
claudeConfigDirOrNull,
|
|
29
|
+
requireClaudeConfigDir,
|
|
30
|
+
} from "../utils/config-dir.js";
|
|
31
|
+
import { readMarker } from "./market-marker.js";
|
|
32
|
+
|
|
33
|
+
export const TRUST_SCHEMA = 1;
|
|
34
|
+
|
|
35
|
+
/** How a marketplace came to be trusted on this machine. */
|
|
36
|
+
export type TrustVia = "create" | "connect" | "profile";
|
|
37
|
+
|
|
38
|
+
export interface TrustEntry {
|
|
39
|
+
/** The normalised source ({@link normaliseSource}). */
|
|
40
|
+
source: string;
|
|
41
|
+
via: TrustVia;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TrustFile {
|
|
45
|
+
schema: number;
|
|
46
|
+
marketplaces: Record<string, TrustEntry>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The result of {@link trust}. */
|
|
50
|
+
export type TrustOutcome =
|
|
51
|
+
/** Newly put on the list. */
|
|
52
|
+
| "added"
|
|
53
|
+
/** Already on the list with the same source; nothing written. */
|
|
54
|
+
| "exists"
|
|
55
|
+
/** On the list with a different source; nothing written. */
|
|
56
|
+
| "conflict";
|
|
57
|
+
|
|
58
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
59
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Sources ─────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/** A GitHub `owner/repo`. Names start with a letter or digit, never a dot. */
|
|
65
|
+
const GITHUB_SLUG = /^([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9_.-]+)$/;
|
|
66
|
+
|
|
67
|
+
/** GitHub as a URL host, in each form git accepts. */
|
|
68
|
+
const GITHUB_URL =
|
|
69
|
+
/^(?:(?:https?|git|ssh|git\+ssh):\/\/(?:[^@/]+@)?github\.com(?::\d+)?\/|[^@/\s]+@github\.com:)([^/]+)\/([^/]+?)(?:\.git)?\/?$/i;
|
|
70
|
+
|
|
71
|
+
function stripGitSuffix(repo: string): string {
|
|
72
|
+
return repo.replace(/\.git$/, "");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True for a normalised source that is a GitHub `owner/repo`. */
|
|
76
|
+
export function isGithubSource(source: string): boolean {
|
|
77
|
+
return GITHUB_SLUG.test(source) && !source.startsWith(".");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One spelling per source, so two spellings of the same repository compare
|
|
82
|
+
* equal:
|
|
83
|
+
* - a GitHub URL (https, ssh, `git@github.com:`) or a bare `owner/repo` →
|
|
84
|
+
* `owner/repo`, without `.git`;
|
|
85
|
+
* - any other URL → the URL as given (trailing slash removed);
|
|
86
|
+
* - a path → absolute, a relative one resolved against `baseDir` (the project
|
|
87
|
+
* root, for a path in a profile), then its real path when it exists.
|
|
88
|
+
*/
|
|
89
|
+
export function normaliseSource(source: string, baseDir?: string): string {
|
|
90
|
+
const raw = source.trim();
|
|
91
|
+
const gh = GITHUB_URL.exec(raw);
|
|
92
|
+
if (gh?.[1] && gh[2]) return `${gh[1]}/${stripGitSuffix(gh[2])}`;
|
|
93
|
+
const slug = GITHUB_SLUG.exec(raw);
|
|
94
|
+
if (slug?.[1] && slug[2] && !raw.startsWith(".")) {
|
|
95
|
+
return `${slug[1]}/${stripGitSuffix(slug[2])}`;
|
|
96
|
+
}
|
|
97
|
+
// Any URL: scheme://… or scp-like user@host:path. A leading `-` is never
|
|
98
|
+
// a URL: it would reach git as an option, so it resolves as a path below.
|
|
99
|
+
if (
|
|
100
|
+
/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(raw) ||
|
|
101
|
+
/^[^-@/\s][^@/\s]*@[^:/\s]+:/.test(raw)
|
|
102
|
+
) {
|
|
103
|
+
return raw.length > 1 ? raw.replace(/\/+$/, "") : raw;
|
|
104
|
+
}
|
|
105
|
+
const absolute = path.resolve(baseDir ?? process.cwd(), raw);
|
|
106
|
+
try {
|
|
107
|
+
return fs.realpathSync(absolute);
|
|
108
|
+
} catch {
|
|
109
|
+
return absolute;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Two normalised sources name the same repository. GitHub treats owner and
|
|
115
|
+
* repository names case-insensitively, so `owner/repo` does too; paths and
|
|
116
|
+
* other URLs compare exactly.
|
|
117
|
+
*/
|
|
118
|
+
export function sameSource(a: string, b: string): boolean {
|
|
119
|
+
if (a === b) return true;
|
|
120
|
+
if (isGithubSource(a) && isGithubSource(b)) {
|
|
121
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A profile's marketplace ref as a normalised source, or null when it names none. */
|
|
127
|
+
export function refSource(
|
|
128
|
+
ref: ProfileMarketplaceRef,
|
|
129
|
+
projectRoot?: string,
|
|
130
|
+
): string | null {
|
|
131
|
+
if (ref.source === "github") {
|
|
132
|
+
return ref.repo ? normaliseSource(ref.repo) : null;
|
|
133
|
+
}
|
|
134
|
+
return ref.path ? normaliseSource(ref.path, projectRoot) : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Claude Code's registry (read only) ──────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
interface KnownEntry {
|
|
140
|
+
source?: {
|
|
141
|
+
source?: string;
|
|
142
|
+
repo?: string;
|
|
143
|
+
url?: string;
|
|
144
|
+
path?: string;
|
|
145
|
+
};
|
|
146
|
+
installLocation?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** `known_marketplaces.json`, or `{}` when it is absent, unreadable or not an object. */
|
|
150
|
+
async function readKnown(): Promise<Record<string, KnownEntry>> {
|
|
151
|
+
const root = claudeConfigDirOrNull();
|
|
152
|
+
if (!root) return {};
|
|
153
|
+
const file = path.join(root, "plugins", "known_marketplaces.json");
|
|
154
|
+
try {
|
|
155
|
+
const parsed: unknown = await fs.readJson(file);
|
|
156
|
+
return isRecord(parsed) ? (parsed as Record<string, KnownEntry>) : {};
|
|
157
|
+
} catch {
|
|
158
|
+
return {};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A registry entry's source, normalised; null when the entry names none. */
|
|
163
|
+
function entrySource(entry: KnownEntry): string | null {
|
|
164
|
+
const s = entry.source;
|
|
165
|
+
if (!s) return null;
|
|
166
|
+
if (s.repo) return normaliseSource(s.repo);
|
|
167
|
+
if (s.url) return normaliseSource(s.url);
|
|
168
|
+
if (s.path) return normaliseSource(s.path);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Where this machine's registration of `name` comes from, normalised, or null
|
|
174
|
+
* when the marketplace is not registered.
|
|
175
|
+
*/
|
|
176
|
+
export async function registeredSource(name: string): Promise<string | null> {
|
|
177
|
+
const entry = (await readKnown())[name];
|
|
178
|
+
return entry ? entrySource(entry) : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* True when this machine's registration of `name` reads a manifest carrying
|
|
183
|
+
* the company marker: the clone for a git source, the directory itself for a
|
|
184
|
+
* `directory` source. False when it is not registered or cannot be read.
|
|
185
|
+
*/
|
|
186
|
+
export async function registeredMarker(name: string): Promise<boolean> {
|
|
187
|
+
const entry = (await readKnown())[name];
|
|
188
|
+
if (!entry) return false;
|
|
189
|
+
const dir = entryLocation(name, entry);
|
|
190
|
+
if (!dir) return false;
|
|
191
|
+
try {
|
|
192
|
+
return readMarker(
|
|
193
|
+
await fs.readJson(path.join(dir, ".claude-plugin", "marketplace.json")),
|
|
194
|
+
);
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The registered marketplaces a profile can record for teammates: each name
|
|
202
|
+
* mapped to a `github` ref, for every registration whose source is GitHub
|
|
203
|
+
* (or a git URL on github.com) and whose name is not one of magus's default
|
|
204
|
+
* marketplaces. Other hosting is not recorded. Read once per caller and passed
|
|
205
|
+
* down; never writes.
|
|
206
|
+
*/
|
|
207
|
+
export async function registryLookup(): Promise<
|
|
208
|
+
ReadonlyMap<string, ProfileMarketplaceRef>
|
|
209
|
+
> {
|
|
210
|
+
const out = new Map<string, ProfileMarketplaceRef>();
|
|
211
|
+
for (const [name, entry] of Object.entries(await readKnown())) {
|
|
212
|
+
if (getMarketplaceByName(name)) continue;
|
|
213
|
+
const source = entrySource(entry);
|
|
214
|
+
if (!source || !isGithubSource(source)) continue;
|
|
215
|
+
out.set(name, { source: "github", repo: source });
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── The trust list ──────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
function trustFile(root: string): string {
|
|
223
|
+
return path.join(root, "magus-market", "trusted.json");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseTrust(parsed: unknown): TrustFile {
|
|
227
|
+
const out: TrustFile = { schema: TRUST_SCHEMA, marketplaces: {} };
|
|
228
|
+
if (!isRecord(parsed) || !isRecord(parsed.marketplaces)) return out;
|
|
229
|
+
for (const [name, value] of Object.entries(parsed.marketplaces)) {
|
|
230
|
+
if (!isRecord(value) || typeof value.source !== "string") continue;
|
|
231
|
+
const via =
|
|
232
|
+
value.via === "create" ||
|
|
233
|
+
value.via === "connect" ||
|
|
234
|
+
value.via === "profile"
|
|
235
|
+
? value.via
|
|
236
|
+
: "profile";
|
|
237
|
+
out.marketplaces[name] = { source: value.source, via };
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The trust list; empty when absent or unreadable. */
|
|
243
|
+
export async function readTrust(): Promise<TrustFile> {
|
|
244
|
+
const root = claudeConfigDirOrNull();
|
|
245
|
+
if (!root) return { schema: TRUST_SCHEMA, marketplaces: {} };
|
|
246
|
+
try {
|
|
247
|
+
return parseTrust(await fs.readJson(trustFile(root)));
|
|
248
|
+
} catch {
|
|
249
|
+
return { schema: TRUST_SCHEMA, marketplaces: {} };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The trust list as it stands on disk, for a writer. Absent reads as empty; a
|
|
255
|
+
* file that exists and does not parse throws, so a writer never replaces a
|
|
256
|
+
* list it could not read.
|
|
257
|
+
*/
|
|
258
|
+
async function readTrustForWrite(file: string): Promise<TrustFile> {
|
|
259
|
+
if (!(await fs.pathExists(file))) {
|
|
260
|
+
return { schema: TRUST_SCHEMA, marketplaces: {} };
|
|
261
|
+
}
|
|
262
|
+
const raw = await fs.readFile(file, "utf8");
|
|
263
|
+
let parsed: unknown;
|
|
264
|
+
try {
|
|
265
|
+
parsed = JSON.parse(raw);
|
|
266
|
+
} catch {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`The list of company marketplaces on this machine cannot be read (${file}) — fix or delete it, then run the command again`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return parseTrust(parsed);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function writeTrust(file: string, data: TrustFile): Promise<void> {
|
|
275
|
+
await fs.ensureDir(path.dirname(file));
|
|
276
|
+
const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
|
|
277
|
+
await fs.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`);
|
|
278
|
+
await fs.rename(tmp, file);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Put `name` on the trust list with `source` (normalised first). An entry for
|
|
283
|
+
* the name with a different source is never overwritten: that is a conflict.
|
|
284
|
+
*/
|
|
285
|
+
export async function trust(
|
|
286
|
+
name: string,
|
|
287
|
+
source: string,
|
|
288
|
+
via: TrustVia,
|
|
289
|
+
): Promise<TrustOutcome> {
|
|
290
|
+
const file = trustFile(requireClaudeConfigDir());
|
|
291
|
+
const data = await readTrustForWrite(file);
|
|
292
|
+
const normalised = normaliseSource(source);
|
|
293
|
+
const existing = data.marketplaces[name];
|
|
294
|
+
if (existing) {
|
|
295
|
+
return sameSource(existing.source, normalised) ? "exists" : "conflict";
|
|
296
|
+
}
|
|
297
|
+
data.marketplaces[name] = { source: normalised, via };
|
|
298
|
+
await writeTrust(file, data);
|
|
299
|
+
return "added";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Take `name` off the trust list. True when it was on it. */
|
|
303
|
+
export async function untrust(name: string): Promise<boolean> {
|
|
304
|
+
const file = trustFile(requireClaudeConfigDir());
|
|
305
|
+
const data = await readTrustForWrite(file);
|
|
306
|
+
if (!Object.hasOwn(data.marketplaces, name)) return false;
|
|
307
|
+
delete data.marketplaces[name];
|
|
308
|
+
await writeTrust(file, data);
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** True when `name` is on the trust list with a source equal to `source`. */
|
|
313
|
+
export function isTrustedIn(
|
|
314
|
+
list: TrustFile,
|
|
315
|
+
name: string,
|
|
316
|
+
source: string | null | undefined,
|
|
317
|
+
): boolean {
|
|
318
|
+
const entry = list.marketplaces[name];
|
|
319
|
+
return !!entry && !!source && sameSource(entry.source, source);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A trusted company marketplace as this machine registers it. */
|
|
323
|
+
export interface TrustedRegistration {
|
|
324
|
+
name: string;
|
|
325
|
+
/** Claude Code's source type: `github`, `git`, `directory`, … */
|
|
326
|
+
sourceType: string;
|
|
327
|
+
source: string;
|
|
328
|
+
/**
|
|
329
|
+
* Where Claude Code reads this marketplace on disk: its clone for a git
|
|
330
|
+
* source, the directory itself for a `directory` source. Null when no
|
|
331
|
+
* config dir is chosen.
|
|
332
|
+
*/
|
|
333
|
+
location: string | null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Where Claude Code reads a registered marketplace on disk. */
|
|
337
|
+
function entryLocation(name: string, entry: KnownEntry): string | null {
|
|
338
|
+
const root = claudeConfigDirOrNull();
|
|
339
|
+
if (!root) return null;
|
|
340
|
+
return (
|
|
341
|
+
entry.installLocation ||
|
|
342
|
+
(entry.source?.source === "directory" ? entry.source.path : undefined) ||
|
|
343
|
+
path.join(root, "plugins", "marketplaces", name)
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The trust list joined with Claude Code's registry: every trusted name that
|
|
349
|
+
* is registered here from the trusted source, whatever the source type.
|
|
350
|
+
*/
|
|
351
|
+
export async function trustedRegistrations(): Promise<TrustedRegistration[]> {
|
|
352
|
+
const [list, known] = await Promise.all([readTrust(), readKnown()]);
|
|
353
|
+
const out: TrustedRegistration[] = [];
|
|
354
|
+
for (const name of Object.keys(list.marketplaces)) {
|
|
355
|
+
const entry = known[name];
|
|
356
|
+
if (!entry) continue;
|
|
357
|
+
const source = entrySource(entry);
|
|
358
|
+
if (!isTrustedIn(list, name, source) || !source) continue;
|
|
359
|
+
out.push({
|
|
360
|
+
name,
|
|
361
|
+
sourceType: entry.source?.source ?? "",
|
|
362
|
+
source,
|
|
363
|
+
location: entryLocation(name, entry),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The company marketplaces on this machine: trusted, registered from the
|
|
371
|
+
* trusted source, and carrying the marker in the copy Claude Code reads.
|
|
372
|
+
* Read-only.
|
|
373
|
+
*/
|
|
374
|
+
export async function listCompanyMarketplaces(): Promise<
|
|
375
|
+
TrustedRegistration[]
|
|
376
|
+
> {
|
|
377
|
+
const out: TrustedRegistration[] = [];
|
|
378
|
+
for (const r of await trustedRegistrations()) {
|
|
379
|
+
if (await registeredMarker(r.name)) out.push(r);
|
|
380
|
+
}
|
|
381
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
382
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* market-types.ts — the shapes every `magus market` verb shares.
|
|
3
|
+
*
|
|
4
|
+
* `MarketResult` is the JSON contract: one object, printed as the last line of
|
|
5
|
+
* stdout under `--json`, and the value the text renderer and the TUI read.
|
|
6
|
+
* `MarketPlan` is what a planner answers: read-only facts, the plain-words
|
|
7
|
+
* plan, its `planId`, and the steps an apply runs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type MarketOutcome =
|
|
11
|
+
| "ok"
|
|
12
|
+
| "unchanged"
|
|
13
|
+
| "plugin-newer"
|
|
14
|
+
| "pull-request"
|
|
15
|
+
| "needs-consent"
|
|
16
|
+
| "stale"
|
|
17
|
+
| "partial"
|
|
18
|
+
| "failed";
|
|
19
|
+
|
|
20
|
+
export type MarketErrorCode =
|
|
21
|
+
| "gh-missing"
|
|
22
|
+
| "gh-auth"
|
|
23
|
+
| "no-access"
|
|
24
|
+
| "no-network"
|
|
25
|
+
| "conflict"
|
|
26
|
+
| "no-base"
|
|
27
|
+
| "name-taken"
|
|
28
|
+
| "not-found"
|
|
29
|
+
| "ambiguous"
|
|
30
|
+
| "invalid-name"
|
|
31
|
+
| "external-plugin"
|
|
32
|
+
| "claude-missing"
|
|
33
|
+
| "validate-failed"
|
|
34
|
+
| "locked"
|
|
35
|
+
| "not-company"
|
|
36
|
+
| "fork-not-allowed"
|
|
37
|
+
| "install-failed"
|
|
38
|
+
| "plan-changed"
|
|
39
|
+
| "stale"
|
|
40
|
+
| "timeout"
|
|
41
|
+
| "signing-timeout"
|
|
42
|
+
| "other";
|
|
43
|
+
|
|
44
|
+
/** A step's id, e.g. `commit`, `deliver`, `fetch:acme`. */
|
|
45
|
+
export type StepId = string;
|
|
46
|
+
|
|
47
|
+
export type FileChange = "added" | "modified" | "deleted";
|
|
48
|
+
|
|
49
|
+
export type NotPublishedReason =
|
|
50
|
+
| "dotfile"
|
|
51
|
+
| "node_modules"
|
|
52
|
+
| "symlink"
|
|
53
|
+
| "ignored-here"
|
|
54
|
+
| "ignored-by-marketplace";
|
|
55
|
+
|
|
56
|
+
export type DeliveryTarget = "push-or-pr" | "fork-pr";
|
|
57
|
+
|
|
58
|
+
export type InstallScope = "user" | "project" | "local";
|
|
59
|
+
|
|
60
|
+
export interface MarketplaceRow {
|
|
61
|
+
name: string;
|
|
62
|
+
repo: string;
|
|
63
|
+
/**
|
|
64
|
+
* The company it belongs to: the manifest's `owner.name`, else the GitHub
|
|
65
|
+
* owner of its source. Absent when neither names one.
|
|
66
|
+
*/
|
|
67
|
+
company?: string;
|
|
68
|
+
scope: "user";
|
|
69
|
+
plugins: Array<{ name: string; version: string; skills: string[] }>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A skill or plugin folder on this machine that is not part of any company
|
|
74
|
+
* marketplace: one of this project's or your USER skills, or a plugin folder
|
|
75
|
+
* among them (one holding `.claude-plugin/plugin.json`).
|
|
76
|
+
*/
|
|
77
|
+
export interface LocalItem {
|
|
78
|
+
kind: "skill" | "plugin";
|
|
79
|
+
/** The folder's name; for a plugin, its `plugin.json` name when it has one. */
|
|
80
|
+
name: string;
|
|
81
|
+
scope: "project" | "user";
|
|
82
|
+
/** The folder, absolute. */
|
|
83
|
+
path: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SkillStatusRow {
|
|
87
|
+
skill: string;
|
|
88
|
+
from: "project" | "user";
|
|
89
|
+
path: string;
|
|
90
|
+
owner: string | null;
|
|
91
|
+
owners?: string[];
|
|
92
|
+
state:
|
|
93
|
+
| "edited"
|
|
94
|
+
| "same"
|
|
95
|
+
| "plugin-newer"
|
|
96
|
+
| "conflict"
|
|
97
|
+
| "no-base"
|
|
98
|
+
| "not-checked"
|
|
99
|
+
| "ambiguous"
|
|
100
|
+
| "not-owned";
|
|
101
|
+
reason?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface InstallRow {
|
|
105
|
+
scope: InstallScope;
|
|
106
|
+
from: string | null;
|
|
107
|
+
to: string | null;
|
|
108
|
+
loaded?: string | null;
|
|
109
|
+
status: "updated" | "unchanged" | "failed";
|
|
110
|
+
message?: string;
|
|
111
|
+
fix?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface LocalCopy {
|
|
115
|
+
scope: "project" | "user";
|
|
116
|
+
name: string;
|
|
117
|
+
action: "removed" | "kept" | "created";
|
|
118
|
+
reason?: string;
|
|
119
|
+
/**
|
|
120
|
+
* Set on a kept copy whose published files were already deleted when new
|
|
121
|
+
* files turned up: it matches no commit, so no base is recorded for it.
|
|
122
|
+
*/
|
|
123
|
+
partial?: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface StepRecord {
|
|
127
|
+
id: StepId;
|
|
128
|
+
status: "done" | "skipped" | "failed";
|
|
129
|
+
detail?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface MarketResult {
|
|
133
|
+
command: `market ${string}`;
|
|
134
|
+
outcome: MarketOutcome;
|
|
135
|
+
message: string;
|
|
136
|
+
planId?: string;
|
|
137
|
+
stale?: boolean;
|
|
138
|
+
marketplace?: string;
|
|
139
|
+
plugin?: string;
|
|
140
|
+
skill?: string;
|
|
141
|
+
version?: { from: string | null; to: string | null };
|
|
142
|
+
files?: Array<{ path: string; change: FileChange }>;
|
|
143
|
+
notPublished?: Array<{ path: string; reason: NotPublishedReason }>;
|
|
144
|
+
target?: DeliveryTarget;
|
|
145
|
+
commit?: string;
|
|
146
|
+
pullRequest?: string;
|
|
147
|
+
installs?: InstallRow[];
|
|
148
|
+
otherProjects?: number;
|
|
149
|
+
localCopy?: LocalCopy;
|
|
150
|
+
/** A plugin-wide publish settles one local copy per published skill. */
|
|
151
|
+
localCopies?: LocalCopy[];
|
|
152
|
+
marketplaces?: MarketplaceRow[];
|
|
153
|
+
/** `market list`: skills and plugins on this machine that no marketplace holds. */
|
|
154
|
+
local?: LocalItem[];
|
|
155
|
+
skills?: SkillStatusRow[];
|
|
156
|
+
plan?: string[];
|
|
157
|
+
steps: StepRecord[];
|
|
158
|
+
error?: { code: MarketErrorCode; message: string; fix?: string };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* An expected failure of a market verb: a code the caller acts on, the words
|
|
163
|
+
* a user reads, and the next step. `exit` overrides the code's usual exit
|
|
164
|
+
* status (a usage error is exit 2 whatever its code).
|
|
165
|
+
*/
|
|
166
|
+
export class MarketError extends Error {
|
|
167
|
+
/** For `stale`: the marketplaces behind GitHub, when known. */
|
|
168
|
+
marketplaces?: readonly string[];
|
|
169
|
+
/**
|
|
170
|
+
* True when the failing step had already changed something outside magus's
|
|
171
|
+
* working clone (a pushed branch, a fork), so "nothing changed" is false.
|
|
172
|
+
*/
|
|
173
|
+
outsideWrite?: boolean;
|
|
174
|
+
|
|
175
|
+
constructor(
|
|
176
|
+
readonly code: MarketErrorCode,
|
|
177
|
+
message: string,
|
|
178
|
+
readonly fix?: string,
|
|
179
|
+
readonly exit?: number,
|
|
180
|
+
) {
|
|
181
|
+
super(message);
|
|
182
|
+
this.name = "MarketError";
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A `stale` error naming the marketplaces whose copy here is behind GitHub. */
|
|
187
|
+
export function staleError(
|
|
188
|
+
marketplaces: readonly string[],
|
|
189
|
+
message: string,
|
|
190
|
+
fix?: string,
|
|
191
|
+
): MarketError {
|
|
192
|
+
const error = new MarketError("stale", message, fix);
|
|
193
|
+
error.marketplaces = marketplaces;
|
|
194
|
+
return error;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** What a running step may hand back. */
|
|
198
|
+
export interface StepOutcome {
|
|
199
|
+
/** A short note for the step's row. */
|
|
200
|
+
detail?: string;
|
|
201
|
+
/** Fields this step settles on the result: commit, pull request, installs… */
|
|
202
|
+
patch?: Partial<MarketResult>;
|
|
203
|
+
/** Stop here: later steps are skipped (a pull request moves no install). */
|
|
204
|
+
stop?: boolean;
|
|
205
|
+
/** The step was not needed and wrote nothing. */
|
|
206
|
+
skipped?: boolean;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface StepContext {
|
|
210
|
+
/** This project's root, where `.claude/skills` and project scope live. */
|
|
211
|
+
cwd: string;
|
|
212
|
+
/** The working clone the apply synced, when the plan works in one. */
|
|
213
|
+
clone: string | null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface MarketStep {
|
|
217
|
+
id: StepId;
|
|
218
|
+
/** What the step does, in the user's words, for progress and results. */
|
|
219
|
+
label: string;
|
|
220
|
+
/** True when the step writes anywhere but magus's own working clone. */
|
|
221
|
+
outside: boolean;
|
|
222
|
+
run(ctx: StepContext): Promise<StepOutcome | undefined>;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A plan, ready for consent. Read-only to build; only {@link MarketStep.run}
|
|
227
|
+
* writes, and only inside an apply.
|
|
228
|
+
*/
|
|
229
|
+
export interface MarketPlan {
|
|
230
|
+
command: `market ${string}`;
|
|
231
|
+
/** One line per thing the apply will do, in plain words. */
|
|
232
|
+
lines: string[];
|
|
233
|
+
/** Hash of every material fact; consent binds to it. */
|
|
234
|
+
planId: string;
|
|
235
|
+
/** Fields the result carries from the plan (version, files, target…). */
|
|
236
|
+
summary: Partial<MarketResult>;
|
|
237
|
+
steps: MarketStep[];
|
|
238
|
+
/**
|
|
239
|
+
* The marketplace whose working clone the apply locks and syncs before it
|
|
240
|
+
* plans again. Null for a plan that needs no working clone.
|
|
241
|
+
*/
|
|
242
|
+
sync: { name: string; url: string } | null;
|
|
243
|
+
/** The marketplace to lock even without a sync (remove). */
|
|
244
|
+
lock: string | null;
|
|
245
|
+
/** Plan again from fresh state; the apply compares the new planId. */
|
|
246
|
+
replan(): Promise<PlanAnswer>;
|
|
247
|
+
/** The message of a finished apply. */
|
|
248
|
+
doneMessage: string;
|
|
249
|
+
/**
|
|
250
|
+
* A second question a person must answer yes to (default no) before this
|
|
251
|
+
* plan applies: a publish that replaces the plugin's version with --force.
|
|
252
|
+
*/
|
|
253
|
+
secondConfirm?: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* What a planner answers: a plan to consent to, or a result that needs none
|
|
258
|
+
* (nothing to do), or an error.
|
|
259
|
+
*/
|
|
260
|
+
export type PlanAnswer =
|
|
261
|
+
| { kind: "plan"; plan: MarketPlan }
|
|
262
|
+
| { kind: "result"; result: MarketResult }
|
|
263
|
+
| {
|
|
264
|
+
kind: "error";
|
|
265
|
+
command: `market ${string}`;
|
|
266
|
+
error: MarketError;
|
|
267
|
+
stale?: boolean;
|
|
268
|
+
};
|