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,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* market-install.ts — move a plugin's installs to a version just published,
|
|
3
|
+
* through Claude Code's CLI and nothing else.
|
|
4
|
+
*
|
|
5
|
+
* The only things this module may call are `runPluginVerb` (spawns `claude
|
|
6
|
+
* plugin install|update`), `readInstalledVersionForScope` and
|
|
7
|
+
* `loadedVersionHere` (both only read `installed_plugins.json`). None of them
|
|
8
|
+
* reaches a function that writes Claude Code's files directly, so nothing here
|
|
9
|
+
* can repair, redirect or rewrite an install: a scope Claude Code did not move
|
|
10
|
+
* is reported as not moved, with `magus update` as the fix.
|
|
11
|
+
*
|
|
12
|
+
* Keep the import list exactly as it is. Anything more is a path to a direct
|
|
13
|
+
* writer of Claude Code's state.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { type PluginScope, runPluginVerb } from "./claude-cli.js";
|
|
17
|
+
import { loadedVersionHere } from "./claude-settings.js";
|
|
18
|
+
import { readInstalledVersionForScope } from "./plugin-manager.js";
|
|
19
|
+
|
|
20
|
+
/** What happened at one scope. */
|
|
21
|
+
export interface InstallMove {
|
|
22
|
+
scope: PluginScope;
|
|
23
|
+
/** This folder's own row at the scope before the verb; null when none. */
|
|
24
|
+
from: string | null;
|
|
25
|
+
/** The row after every verb ran; null when there is none. */
|
|
26
|
+
to: string | null;
|
|
27
|
+
/** The version Claude Code loads in this folder, read once after every verb. */
|
|
28
|
+
loaded: string | null;
|
|
29
|
+
status: "updated" | "unchanged" | "failed";
|
|
30
|
+
message?: string;
|
|
31
|
+
fix?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AdvanceResult {
|
|
35
|
+
installs: InstallMove[];
|
|
36
|
+
/** The version Claude Code loads in this folder after the move. */
|
|
37
|
+
loaded: string | null;
|
|
38
|
+
/** `partial` when any scope failed or this folder does not load the target. */
|
|
39
|
+
outcome: "ok" | "partial";
|
|
40
|
+
message?: string;
|
|
41
|
+
fix?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The three calls {@link advanceViaCli} makes; replaceable for tests. */
|
|
45
|
+
export interface AdvanceDeps {
|
|
46
|
+
runPluginVerb: typeof runPluginVerb;
|
|
47
|
+
readInstalledVersionForScope: typeof readInstalledVersionForScope;
|
|
48
|
+
loadedVersionHere: typeof loadedVersionHere;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const realDeps: AdvanceDeps = {
|
|
52
|
+
runPluginVerb,
|
|
53
|
+
readInstalledVersionForScope,
|
|
54
|
+
loadedVersionHere,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const FIX = "magus update";
|
|
58
|
+
|
|
59
|
+
function errorText(error: unknown): string {
|
|
60
|
+
return error instanceof Error ? error.message : String(error);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Bring `pluginId` to `target` at each of `scopes`, as seen from `cwd`.
|
|
65
|
+
*
|
|
66
|
+
* Two passes. First the verbs, scope by scope: `update` where this folder
|
|
67
|
+
* already has a row at that scope, else `install`. Then one read-back, after
|
|
68
|
+
* every verb has run: each scope's own row, and once the version the loader
|
|
69
|
+
* picks in this folder. Judging the loaded version inside the first pass
|
|
70
|
+
* would depend on registry order, since the loader takes the first relevant
|
|
71
|
+
* row, not the one just moved.
|
|
72
|
+
*
|
|
73
|
+
* Each scope is judged by its own row. The move as a whole is judged by what
|
|
74
|
+
* this folder loads. Claude Code's marketplace clone must already hold the
|
|
75
|
+
* new version: `claude plugin update` does not fetch it.
|
|
76
|
+
*/
|
|
77
|
+
export async function advanceViaCli(
|
|
78
|
+
pluginId: string,
|
|
79
|
+
scopes: readonly PluginScope[],
|
|
80
|
+
cwd: string,
|
|
81
|
+
target: string,
|
|
82
|
+
deps: AdvanceDeps = realDeps,
|
|
83
|
+
): Promise<AdvanceResult> {
|
|
84
|
+
const pending: Array<{
|
|
85
|
+
scope: PluginScope;
|
|
86
|
+
from: string | null;
|
|
87
|
+
error?: string;
|
|
88
|
+
}> = [];
|
|
89
|
+
|
|
90
|
+
for (const scope of scopes) {
|
|
91
|
+
let from: string | null = null;
|
|
92
|
+
try {
|
|
93
|
+
from = await deps.readInstalledVersionForScope(pluginId, scope, cwd);
|
|
94
|
+
await deps.runPluginVerb(
|
|
95
|
+
from ? "update" : "install",
|
|
96
|
+
pluginId,
|
|
97
|
+
scope,
|
|
98
|
+
cwd,
|
|
99
|
+
);
|
|
100
|
+
pending.push({ scope, from });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
pending.push({ scope, from, error: errorText(error) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const loaded = await deps.loadedVersionHere(pluginId, cwd);
|
|
107
|
+
const installs: InstallMove[] = [];
|
|
108
|
+
for (const { scope, from, error } of pending) {
|
|
109
|
+
let to: string | null = null;
|
|
110
|
+
try {
|
|
111
|
+
to = await deps.readInstalledVersionForScope(pluginId, scope, cwd);
|
|
112
|
+
} catch (readError) {
|
|
113
|
+
installs.push({
|
|
114
|
+
scope,
|
|
115
|
+
from,
|
|
116
|
+
to: null,
|
|
117
|
+
loaded,
|
|
118
|
+
status: "failed",
|
|
119
|
+
message: `could not read what Claude Code installed: ${errorText(readError)}`,
|
|
120
|
+
fix: FIX,
|
|
121
|
+
});
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (error !== undefined) {
|
|
125
|
+
installs.push({
|
|
126
|
+
scope,
|
|
127
|
+
from,
|
|
128
|
+
to,
|
|
129
|
+
loaded,
|
|
130
|
+
status: "failed",
|
|
131
|
+
message: error,
|
|
132
|
+
...(/not found in marketplace/i.test(error) ? { fix: FIX } : {}),
|
|
133
|
+
});
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (to === target) {
|
|
137
|
+
installs.push({
|
|
138
|
+
scope,
|
|
139
|
+
from,
|
|
140
|
+
to,
|
|
141
|
+
loaded,
|
|
142
|
+
status: from === target ? "unchanged" : "updated",
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
installs.push({
|
|
147
|
+
scope,
|
|
148
|
+
from,
|
|
149
|
+
to,
|
|
150
|
+
loaded,
|
|
151
|
+
status: "failed",
|
|
152
|
+
message: `Claude Code did not move this folder to v${target}`,
|
|
153
|
+
fix: FIX,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (scopes.length > 0 && loaded !== target) {
|
|
158
|
+
return {
|
|
159
|
+
installs,
|
|
160
|
+
loaded,
|
|
161
|
+
outcome: "partial",
|
|
162
|
+
message: loaded
|
|
163
|
+
? `installed v${target}, but this folder still loads v${loaded}`
|
|
164
|
+
: `installed v${target}, but this folder loads no version of it`,
|
|
165
|
+
fix: FIX,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const failed = installs.some((i) => i.status === "failed");
|
|
169
|
+
return failed
|
|
170
|
+
? { installs, loaded, outcome: "partial", fix: FIX }
|
|
171
|
+
: { installs, loaded, outcome: "ok" };
|
|
172
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* market-layout.ts — pure rules about how a marketplace repository is laid out.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here reads or writes the disk: every function takes what it needs
|
|
5
|
+
* and answers. The scan, dependency resolution and the market verbs all ask
|
|
6
|
+
* the same question — where does this plugin live in its marketplace? — and
|
|
7
|
+
* get one answer.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
/** True when `child` is `root` or sits below it. Both must be absolute. */
|
|
14
|
+
function isInside(root: string, child: string): boolean {
|
|
15
|
+
const rel = path.relative(root, child);
|
|
16
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The directory a plugin entry's `source` names inside a marketplace, or null
|
|
21
|
+
* when it names none there.
|
|
22
|
+
*
|
|
23
|
+
* Claude Code's two relative forms:
|
|
24
|
+
* - `"./plugins/kit"` (or `"../…"`, `"."`) is relative to the marketplace root.
|
|
25
|
+
* - A bare name, `"kit"`, resolves under `metadata.pluginRoot` — a relative
|
|
26
|
+
* path inside the marketplace such as `"./plugins"`. With no `pluginRoot`,
|
|
27
|
+
* or one pointing outside the marketplace, a bare name does not resolve.
|
|
28
|
+
*
|
|
29
|
+
* Anything else — an object source (a git repository, a URL), an absolute
|
|
30
|
+
* path, or a path that leaves the marketplace — is not a directory here.
|
|
31
|
+
*/
|
|
32
|
+
export function pluginDirFor(
|
|
33
|
+
marketplaceRoot: string,
|
|
34
|
+
source: unknown,
|
|
35
|
+
pluginRoot?: unknown,
|
|
36
|
+
): string | null {
|
|
37
|
+
if (typeof source !== "string" || source.trim() === "") return null;
|
|
38
|
+
if (path.isAbsolute(source)) return null;
|
|
39
|
+
const root = path.resolve(marketplaceRoot);
|
|
40
|
+
|
|
41
|
+
const relative =
|
|
42
|
+
source === "." ||
|
|
43
|
+
source === ".." ||
|
|
44
|
+
source.startsWith("./") ||
|
|
45
|
+
source.startsWith("../");
|
|
46
|
+
if (relative) {
|
|
47
|
+
const dir = path.resolve(root, source);
|
|
48
|
+
return isInside(root, dir) ? dir : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (typeof pluginRoot !== "string" || pluginRoot.trim() === "") return null;
|
|
52
|
+
if (path.isAbsolute(pluginRoot)) return null;
|
|
53
|
+
const base = path.resolve(root, pluginRoot);
|
|
54
|
+
if (!isInside(root, base)) return null;
|
|
55
|
+
const dir = path.resolve(base, source);
|
|
56
|
+
return isInside(base, dir) ? dir : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Names ───────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/** A marketplace or plugin name: lower case, digits and dashes. */
|
|
62
|
+
const PACKAGE_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
63
|
+
/** A skill folder name: letters, digits, dots, dashes and underscores. */
|
|
64
|
+
const SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
65
|
+
|
|
66
|
+
/** True for a marketplace or plugin name magus will write. */
|
|
67
|
+
export function validPackageName(name: string): boolean {
|
|
68
|
+
return PACKAGE_NAME.test(name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True for a skill folder name: one path segment, never `.` or `..`. */
|
|
72
|
+
export function validSkillName(name: string): boolean {
|
|
73
|
+
return SKILL_NAME.test(name);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A plugin's directory relative to the marketplace root, with forward
|
|
78
|
+
* slashes ("" for the root itself), or null when its source names no
|
|
79
|
+
* directory inside it.
|
|
80
|
+
*/
|
|
81
|
+
export function pluginRelDir(
|
|
82
|
+
source: unknown,
|
|
83
|
+
pluginRoot?: unknown,
|
|
84
|
+
): string | null {
|
|
85
|
+
const root = path.resolve(path.sep, "marketplace-root");
|
|
86
|
+
const dir = pluginDirFor(root, source, pluginRoot);
|
|
87
|
+
if (dir === null) return null;
|
|
88
|
+
return path.relative(root, dir).split(path.sep).join("/");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Manifest entries ────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
export interface PluginEntry {
|
|
94
|
+
name: string;
|
|
95
|
+
source?: unknown;
|
|
96
|
+
version?: unknown;
|
|
97
|
+
description?: unknown;
|
|
98
|
+
[key: string]: unknown;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
102
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The manifest's plugin entries that carry a name. */
|
|
106
|
+
export function pluginEntries(manifest: unknown): PluginEntry[] {
|
|
107
|
+
if (!isRecord(manifest) || !Array.isArray(manifest.plugins)) return [];
|
|
108
|
+
return manifest.plugins.filter(
|
|
109
|
+
(p): p is PluginEntry => isRecord(p) && typeof p.name === "string",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `metadata.pluginRoot`, when the manifest declares one. */
|
|
114
|
+
export function pluginRootOf(manifest: unknown): unknown {
|
|
115
|
+
if (!isRecord(manifest) || !isRecord(manifest.metadata)) return undefined;
|
|
116
|
+
return manifest.metadata.pluginRoot;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The manifest with `entry` appended to `plugins`. Returns a copy. */
|
|
120
|
+
export function addPluginEntry<T extends Record<string, unknown>>(
|
|
121
|
+
manifest: T,
|
|
122
|
+
entry: PluginEntry,
|
|
123
|
+
): T {
|
|
124
|
+
const plugins = Array.isArray(manifest.plugins) ? manifest.plugins : [];
|
|
125
|
+
return { ...manifest, plugins: [...plugins, entry] };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The manifest without the entry named `name`. Returns a copy. */
|
|
129
|
+
export function removePluginEntry<T extends Record<string, unknown>>(
|
|
130
|
+
manifest: T,
|
|
131
|
+
name: string,
|
|
132
|
+
): T {
|
|
133
|
+
const plugins = Array.isArray(manifest.plugins) ? manifest.plugins : [];
|
|
134
|
+
return {
|
|
135
|
+
...manifest,
|
|
136
|
+
plugins: plugins.filter((p) => !(isRecord(p) && p.name === name)),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The manifest with the entry named `name` at `version`. Returns a copy. */
|
|
141
|
+
export function setEntryVersion<T extends Record<string, unknown>>(
|
|
142
|
+
manifest: T,
|
|
143
|
+
name: string,
|
|
144
|
+
version: string,
|
|
145
|
+
): T {
|
|
146
|
+
const plugins = Array.isArray(manifest.plugins) ? manifest.plugins : [];
|
|
147
|
+
return {
|
|
148
|
+
...manifest,
|
|
149
|
+
plugins: plugins.map((p) =>
|
|
150
|
+
isRecord(p) && p.name === name ? { ...p, version } : p,
|
|
151
|
+
),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── Versions ────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The version a plugin is at: `plugin.json`'s `version` first (what Claude
|
|
161
|
+
* Code honours when both exist), else the marketplace entry's, else null for
|
|
162
|
+
* an unversioned plugin.
|
|
163
|
+
*/
|
|
164
|
+
export function currentVersion(
|
|
165
|
+
pluginJson: unknown,
|
|
166
|
+
entry: unknown,
|
|
167
|
+
): string | null {
|
|
168
|
+
for (const source of [pluginJson, entry]) {
|
|
169
|
+
if (isRecord(source) && typeof source.version === "string") {
|
|
170
|
+
const v = source.version.trim();
|
|
171
|
+
if (v) return v;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The patch bump of `current`; an unversioned plugin goes to `0.0.1`. Null
|
|
179
|
+
* when `current` is not `major.minor.patch`, which magus will not guess at.
|
|
180
|
+
*/
|
|
181
|
+
export function nextVersion(current: string | null): string | null {
|
|
182
|
+
if (current === null) return "0.0.1";
|
|
183
|
+
const m = SEMVER.exec(current);
|
|
184
|
+
if (!m) return null;
|
|
185
|
+
return `${m[1]}.${m[2]}.${Number(m[3]) + 1}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── Ownership ───────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
export interface OwnerCandidate {
|
|
191
|
+
marketplace: string;
|
|
192
|
+
/** Each plugin with its directory relative to the root (null: none here). */
|
|
193
|
+
plugins: Array<{ name: string; dir: string | null }>;
|
|
194
|
+
/** Every file path in the marketplace at the commit read. */
|
|
195
|
+
paths: ReadonlySet<string>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Every `plugin@marketplace` whose plugin holds `skills/<skill>/SKILL.md`. */
|
|
199
|
+
export function findSkillOwners(
|
|
200
|
+
candidates: readonly OwnerCandidate[],
|
|
201
|
+
skill: string,
|
|
202
|
+
): string[] {
|
|
203
|
+
const owners: string[] = [];
|
|
204
|
+
for (const c of candidates) {
|
|
205
|
+
for (const p of c.plugins) {
|
|
206
|
+
if (p.dir === null) continue;
|
|
207
|
+
const file = joinRel(p.dir, "skills", skill, "SKILL.md");
|
|
208
|
+
if (c.paths.has(file)) owners.push(`${p.name}@${c.marketplace}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return owners;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Join path segments with `/`, dropping empty ones (a plugin at the root). */
|
|
215
|
+
export function joinRel(...parts: string[]): string {
|
|
216
|
+
return parts.filter((p) => p !== "" && p !== ".").join("/");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── Copy sets ───────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
/** The file mode git records for a symlink. */
|
|
222
|
+
export const SYMLINK_MODE = "120000";
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Why a path inside a skill folder is never copied, by path and mode alone,
|
|
226
|
+
* or null when it may be: a symlink, any segment starting with `.`, or a path
|
|
227
|
+
* under `node_modules/`.
|
|
228
|
+
*/
|
|
229
|
+
export function excludedByShape(
|
|
230
|
+
relPath: string,
|
|
231
|
+
symlink: boolean,
|
|
232
|
+
): "symlink" | "dotfile" | "node_modules" | null {
|
|
233
|
+
if (symlink) return "symlink";
|
|
234
|
+
const segments = relPath.split("/");
|
|
235
|
+
if (segments.some((s) => s.startsWith("."))) return "dotfile";
|
|
236
|
+
if (segments.includes("node_modules")) return "node_modules";
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The compare domain: a path counts when its shape allows it and neither
|
|
242
|
+
* side ignores it. Decided from path and mode alone, so it applies equally to
|
|
243
|
+
* a file present on one side only.
|
|
244
|
+
*/
|
|
245
|
+
export function inDomain(
|
|
246
|
+
relPath: string,
|
|
247
|
+
mode: string,
|
|
248
|
+
ignoredHere: ReadonlySet<string>,
|
|
249
|
+
ignoredThere: ReadonlySet<string>,
|
|
250
|
+
): boolean {
|
|
251
|
+
return (
|
|
252
|
+
excludedByShape(relPath, mode === SYMLINK_MODE) === null &&
|
|
253
|
+
!ignoredHere.has(relPath) &&
|
|
254
|
+
!ignoredThere.has(relPath)
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A file in a skill: its path inside the skill folder and its blob id. */
|
|
259
|
+
export interface SkillFile {
|
|
260
|
+
path: string;
|
|
261
|
+
oid: string;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** One hash for a file set, the same function for a stored tree and a folder. */
|
|
265
|
+
export function treeHash(files: readonly SkillFile[]): string {
|
|
266
|
+
const lines = [...files]
|
|
267
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
268
|
+
.map((f) => `${f.oid} ${f.path}`)
|
|
269
|
+
.join("\n");
|
|
270
|
+
return createHash("sha256").update(lines).digest("hex");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* How a local copy of a skill stands against its plugin, from three tree
|
|
275
|
+
* hashes over the compare domain: `base` (what the copy started from; null
|
|
276
|
+
* when there is no record), `theirs` (the plugin now) and `ours` (the copy).
|
|
277
|
+
*
|
|
278
|
+
* - `same`: the copy equals the plugin; nothing to publish.
|
|
279
|
+
* - `plugin-newer`: the copy has no edits and the plugin moved on.
|
|
280
|
+
* - `fast`: the plugin has not changed since the copy was made.
|
|
281
|
+
* - `no-base`: nobody knows what the copy started from.
|
|
282
|
+
* - `conflict`: both the copy and the plugin changed.
|
|
283
|
+
*/
|
|
284
|
+
export type CompareState =
|
|
285
|
+
| "same"
|
|
286
|
+
| "fast"
|
|
287
|
+
| "plugin-newer"
|
|
288
|
+
| "no-base"
|
|
289
|
+
| "conflict";
|
|
290
|
+
|
|
291
|
+
export function compareSkillTrees(
|
|
292
|
+
base: string | null,
|
|
293
|
+
theirs: string,
|
|
294
|
+
ours: string,
|
|
295
|
+
): CompareState {
|
|
296
|
+
if (ours === theirs) return "same";
|
|
297
|
+
if (base === null) return "no-base";
|
|
298
|
+
if (ours === base) return "plugin-newer";
|
|
299
|
+
if (theirs === base) return "fast";
|
|
300
|
+
return "conflict";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** True when both sets hold the same paths with the same blob ids. */
|
|
304
|
+
export function sameFiles(
|
|
305
|
+
a: readonly SkillFile[],
|
|
306
|
+
b: readonly SkillFile[],
|
|
307
|
+
): boolean {
|
|
308
|
+
if (a.length !== b.length) return false;
|
|
309
|
+
const map = new Map(a.map((f) => [f.path, f.oid]));
|
|
310
|
+
return b.every((f) => map.get(f.path) === f.oid);
|
|
311
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* market-local.ts — the skills and plugins on this machine that no company
|
|
3
|
+
* marketplace holds yet: what the market screens offer to add to one.
|
|
4
|
+
*
|
|
5
|
+
* Read-only: folders are listed and two small files read, nothing written.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { ORIGIN_FILE } from "./market-bases.js";
|
|
11
|
+
import { profileManaged } from "./market-publish.js";
|
|
12
|
+
import type { LocalItem } from "./market-types.js";
|
|
13
|
+
import { getUserSkillsDir } from "./skills-manager.js";
|
|
14
|
+
|
|
15
|
+
const PLUGIN_JSON = path.join(".claude-plugin", "plugin.json");
|
|
16
|
+
|
|
17
|
+
function isFile(file: string): boolean {
|
|
18
|
+
try {
|
|
19
|
+
return fs.lstatSync(file).isFile();
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The `name` a plugin folder's plugin.json gives, or null. */
|
|
26
|
+
function pluginJsonName(folder: string): string | null {
|
|
27
|
+
try {
|
|
28
|
+
const json = JSON.parse(
|
|
29
|
+
fs.readFileSync(path.join(folder, PLUGIN_JSON), "utf8"),
|
|
30
|
+
) as { name?: unknown };
|
|
31
|
+
return typeof json.name === "string" && json.name.trim()
|
|
32
|
+
? json.name.trim()
|
|
33
|
+
: null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* This project's skills (`.claude/skills/<name>/`) and your USER skills
|
|
41
|
+
* (`<claude config dir>/skills/<name>/`), real folders only, as two kinds:
|
|
42
|
+
* - a plugin: the folder holds `.claude-plugin/plugin.json`, and no company
|
|
43
|
+
* marketplace has a plugin of its name (`companyPlugins`) — once added,
|
|
44
|
+
* the folder is the marketplace's plugin, not a local one;
|
|
45
|
+
* - a skill: the folder holds `SKILL.md`, and is none of a copy magus made of
|
|
46
|
+
* a company skill (it holds `.magus-origin`), a skill a company plugin
|
|
47
|
+
* holds under the same name (`companySkills`), or a skill folder this
|
|
48
|
+
* project's profile installs.
|
|
49
|
+
* Sorted by name, project before USER for the same name.
|
|
50
|
+
*/
|
|
51
|
+
export async function localItems(
|
|
52
|
+
cwd: string,
|
|
53
|
+
companySkills: ReadonlySet<string>,
|
|
54
|
+
companyPlugins: ReadonlySet<string> = new Set(),
|
|
55
|
+
): Promise<LocalItem[]> {
|
|
56
|
+
const roots = [
|
|
57
|
+
{ scope: "project" as const, dir: path.join(cwd, ".claude", "skills") },
|
|
58
|
+
{ scope: "user" as const, dir: getUserSkillsDir() },
|
|
59
|
+
];
|
|
60
|
+
const out: LocalItem[] = [];
|
|
61
|
+
const seen = new Set<string>();
|
|
62
|
+
for (const { scope, dir } of roots) {
|
|
63
|
+
let entries: fs.Dirent[];
|
|
64
|
+
try {
|
|
65
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
66
|
+
} catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
for (const d of entries) {
|
|
70
|
+
if (!d.isDirectory()) continue;
|
|
71
|
+
const folder = path.join(dir, d.name);
|
|
72
|
+
if (seen.has(folder)) continue;
|
|
73
|
+
seen.add(folder);
|
|
74
|
+
if (isFile(path.join(folder, PLUGIN_JSON))) {
|
|
75
|
+
const name = pluginJsonName(folder) ?? d.name;
|
|
76
|
+
if (!companyPlugins.has(name)) {
|
|
77
|
+
out.push({ kind: "plugin", name, scope, path: folder });
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!isFile(path.join(folder, "SKILL.md"))) continue;
|
|
82
|
+
if (fs.existsSync(path.join(folder, ORIGIN_FILE))) continue;
|
|
83
|
+
if (companySkills.has(d.name)) continue;
|
|
84
|
+
if (scope === "project" && (await profileManaged(cwd, d.name))) continue;
|
|
85
|
+
out.push({ kind: "skill", name: d.name, scope, path: folder });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out.sort((a, b) =>
|
|
89
|
+
a.name === b.name
|
|
90
|
+
? a.scope.localeCompare(b.scope)
|
|
91
|
+
: a.name.localeCompare(b.name),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* market-marker.ts — the company marker in a marketplace manifest.
|
|
3
|
+
*
|
|
4
|
+
* A company marketplace says so in its own `.claude-plugin/marketplace.json`:
|
|
5
|
+
* `metadata.magus = {"schema": 1, "company": true}`. Claude Code ignores the
|
|
6
|
+
* field at load time; plain `claude plugin validate` reports it as a warning
|
|
7
|
+
* and still exits 0. `--strict` would fail on it, so magus never runs that.
|
|
8
|
+
*
|
|
9
|
+
* The marker alone does not make a marketplace company on this machine: the
|
|
10
|
+
* marketplace must also be on this machine's trust list, so a repository
|
|
11
|
+
* cannot promote itself by committing the field.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const MARKER_SCHEMA = 1;
|
|
15
|
+
|
|
16
|
+
export interface MagusMarker {
|
|
17
|
+
schema: number;
|
|
18
|
+
company: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
22
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True when the parsed manifest carries `metadata.magus.company === true`. */
|
|
26
|
+
export function readMarker(manifest: unknown): boolean {
|
|
27
|
+
if (!isRecord(manifest)) return false;
|
|
28
|
+
const metadata = manifest.metadata;
|
|
29
|
+
if (!isRecord(metadata)) return false;
|
|
30
|
+
const magus = metadata.magus;
|
|
31
|
+
return isRecord(magus) && magus.company === true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The manifest with the marker set. Returns a copy; every other field, and
|
|
36
|
+
* every other key under `metadata`, is kept as it was.
|
|
37
|
+
*/
|
|
38
|
+
export function withMarker<T extends Record<string, unknown>>(
|
|
39
|
+
manifest: T,
|
|
40
|
+
): T & { metadata: Record<string, unknown> & { magus: MagusMarker } } {
|
|
41
|
+
const metadata = isRecord(manifest.metadata) ? manifest.metadata : {};
|
|
42
|
+
return {
|
|
43
|
+
...manifest,
|
|
44
|
+
metadata: {
|
|
45
|
+
...metadata,
|
|
46
|
+
magus: { schema: MARKER_SCHEMA, company: true },
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|