@mirrowel/opencode-souk 0.1.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/IMPLEMENTATION_PLAN.md +176 -0
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/config.d.ts +1093 -0
- package/dist/config.js +496 -0
- package/dist/forge.d.ts +3 -0
- package/dist/forge.js +78 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +303 -0
- package/dist/install.d.ts +18 -0
- package/dist/install.js +719 -0
- package/dist/registry.d.ts +67 -0
- package/dist/registry.js +447 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +2 -0
- package/dist/tui.d.ts +6 -0
- package/dist/tui.js +1686 -0
- package/docs/CONFIG.md +67 -0
- package/package.json +86 -0
- package/souk.example.jsonc +68 -0
- package/src/skill/souk-installer/SKILL.md +313 -0
- package/src/tui.tsx +1892 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { CacheFile, ItemKind, RegistryItem, SidecarConfig } from "./config.js";
|
|
2
|
+
export declare function loadRegistry(config: SidecarConfig, options?: {
|
|
3
|
+
force?: boolean;
|
|
4
|
+
}): Promise<CacheFile>;
|
|
5
|
+
declare function normalizeCafe(raw: unknown): RegistryItem | undefined;
|
|
6
|
+
declare function normalizeAwesome(raw: unknown): RegistryItem | undefined;
|
|
7
|
+
declare function parseDocs(markdown: string): RegistryItem[];
|
|
8
|
+
export declare function normalizeKind(type: string | undefined, raw?: Record<string, unknown>): ItemKind;
|
|
9
|
+
declare function dedupe(items: RegistryItem[]): {
|
|
10
|
+
id: string;
|
|
11
|
+
source: "opencode-cafe" | "awesome-opencode" | "opencode-docs" | "installed";
|
|
12
|
+
kind: "plugin" | "mcp" | "agent" | "command" | "theme" | "skill" | "tool" | "app" | "project" | "resource" | "unknown";
|
|
13
|
+
confidence: "verified" | "partial" | "unmapped" | "unvetted" | "blocked";
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
tags: string[];
|
|
17
|
+
aliases: string[];
|
|
18
|
+
alternateKinds: ("plugin" | "mcp" | "agent" | "command" | "theme" | "skill" | "tool" | "app" | "project" | "resource" | "unknown")[];
|
|
19
|
+
sources: {
|
|
20
|
+
source: "opencode-cafe" | "awesome-opencode" | "opencode-docs" | "installed";
|
|
21
|
+
id?: string | undefined;
|
|
22
|
+
sourceType?: string | undefined;
|
|
23
|
+
name?: string | undefined;
|
|
24
|
+
description?: string | undefined;
|
|
25
|
+
repoUrl?: string | undefined;
|
|
26
|
+
homepageUrl?: string | undefined;
|
|
27
|
+
kind?: "plugin" | "mcp" | "agent" | "command" | "theme" | "skill" | "tool" | "app" | "project" | "resource" | "unknown" | undefined;
|
|
28
|
+
confidence?: "verified" | "partial" | "unmapped" | "unvetted" | "blocked" | undefined;
|
|
29
|
+
install?: {
|
|
30
|
+
type?: "plugin" | "mcp" | "agent" | "command" | "theme" | "skill" | undefined;
|
|
31
|
+
spec?: string | undefined;
|
|
32
|
+
specs?: string[] | undefined;
|
|
33
|
+
name?: string | undefined;
|
|
34
|
+
config?: Record<string, unknown> | undefined;
|
|
35
|
+
reason?: string | undefined;
|
|
36
|
+
verified?: boolean | undefined;
|
|
37
|
+
} | undefined;
|
|
38
|
+
installationMarkdown?: string | undefined;
|
|
39
|
+
updatedAt?: string | undefined;
|
|
40
|
+
}[];
|
|
41
|
+
warnings: string[];
|
|
42
|
+
sourceType?: string | undefined;
|
|
43
|
+
repoUrl?: string | undefined;
|
|
44
|
+
homepageUrl?: string | undefined;
|
|
45
|
+
primarySource?: "opencode-cafe" | "awesome-opencode" | "opencode-docs" | "installed" | undefined;
|
|
46
|
+
installationMarkdown?: string | undefined;
|
|
47
|
+
install?: {
|
|
48
|
+
type?: "plugin" | "mcp" | "agent" | "command" | "theme" | "skill" | undefined;
|
|
49
|
+
spec?: string | undefined;
|
|
50
|
+
specs?: string[] | undefined;
|
|
51
|
+
name?: string | undefined;
|
|
52
|
+
config?: Record<string, unknown> | undefined;
|
|
53
|
+
reason?: string | undefined;
|
|
54
|
+
verified?: boolean | undefined;
|
|
55
|
+
} | undefined;
|
|
56
|
+
updatedAt?: string | undefined;
|
|
57
|
+
}[];
|
|
58
|
+
declare function canonicalRepoKey(url: string | undefined): string | undefined;
|
|
59
|
+
export declare const registryTestHooks: {
|
|
60
|
+
normalizeCafe: typeof normalizeCafe;
|
|
61
|
+
normalizeAwesome: typeof normalizeAwesome;
|
|
62
|
+
parseDocs: typeof parseDocs;
|
|
63
|
+
normalizeKind: typeof normalizeKind;
|
|
64
|
+
dedupe: typeof dedupe;
|
|
65
|
+
canonicalRepoKey: typeof canonicalRepoKey;
|
|
66
|
+
};
|
|
67
|
+
export {};
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { CacheFile as CacheSchema, debugLog, loadCache, saveCache } from "./config.js";
|
|
2
|
+
const SOURCE_TRUST = {
|
|
3
|
+
"opencode-cafe": 30,
|
|
4
|
+
"opencode-docs": 20,
|
|
5
|
+
"awesome-opencode": 15,
|
|
6
|
+
};
|
|
7
|
+
const KIND_PRIORITY = ["plugin", "mcp", "agent", "command", "theme", "skill", "tool", "app", "project", "resource", "unknown"];
|
|
8
|
+
export async function loadRegistry(config, options = {}) {
|
|
9
|
+
const cached = loadCache();
|
|
10
|
+
if (!options.force && cached.items.length > 0) {
|
|
11
|
+
debugLog("Registry cache hit", `${cached.items.length} item(s); fetched=${cached.fetchedAt ?? "never"}`);
|
|
12
|
+
return cached;
|
|
13
|
+
}
|
|
14
|
+
const started = Date.now();
|
|
15
|
+
const fetches = [
|
|
16
|
+
config.sources.opencode_cafe.enabled ? fetchCafe(config.sources.opencode_cafe.url) : skipped("opencode-cafe"),
|
|
17
|
+
config.sources.awesome_opencode.enabled ? fetchAwesome(config.sources.awesome_opencode.url) : skipped("awesome-opencode"),
|
|
18
|
+
config.sources.opencode_docs.enabled ? fetchDocs(config.sources.opencode_docs.url) : skipped("opencode-docs"),
|
|
19
|
+
];
|
|
20
|
+
const settled = await Promise.allSettled(fetches);
|
|
21
|
+
const items = [];
|
|
22
|
+
const errors = {};
|
|
23
|
+
const sourceStatus = {};
|
|
24
|
+
for (const result of settled) {
|
|
25
|
+
if (result.status === "rejected") {
|
|
26
|
+
errors.unknown = String(result.reason);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const status = result.value;
|
|
30
|
+
items.push(...status.items);
|
|
31
|
+
if (status.error)
|
|
32
|
+
errors[status.source] = status.error;
|
|
33
|
+
sourceStatus[status.source] = {
|
|
34
|
+
enabled: status.enabled,
|
|
35
|
+
status: status.enabled ? status.error ? "failed" : "fetched" : "skipped",
|
|
36
|
+
rawCount: status.rawCount,
|
|
37
|
+
normalizedCount: status.items.length,
|
|
38
|
+
warnings: status.warnings,
|
|
39
|
+
error: status.error,
|
|
40
|
+
};
|
|
41
|
+
if (status.enabled && status.rawCount > 0 && status.items.length === 0 && !status.error) {
|
|
42
|
+
errors[status.source] = `Source returned ${status.rawCount} raw item(s), but Souk normalized 0.`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const next = CacheSchema.parse({
|
|
46
|
+
version: 1,
|
|
47
|
+
fetchedAt: new Date().toISOString(),
|
|
48
|
+
items: dedupe(items),
|
|
49
|
+
errors,
|
|
50
|
+
sourceStatus,
|
|
51
|
+
});
|
|
52
|
+
debugLog("Registry refresh", `${next.items.length} item(s), raw=${items.length}, ${Object.keys(errors).length} error(s), ${Date.now() - started}ms`);
|
|
53
|
+
if (next.items.length > 0 || Object.keys(errors).length > 0)
|
|
54
|
+
saveCache(next);
|
|
55
|
+
return next.items.length > 0 ? next : cached;
|
|
56
|
+
}
|
|
57
|
+
function skipped(source) {
|
|
58
|
+
return Promise.resolve({ source, enabled: false, rawCount: 0, items: [], warnings: [] });
|
|
59
|
+
}
|
|
60
|
+
async function fetchCafe(url) {
|
|
61
|
+
const source = "opencode-cafe";
|
|
62
|
+
if (!url)
|
|
63
|
+
return { source, enabled: true, rawCount: 0, items: [], warnings: [], error: "No URL configured" };
|
|
64
|
+
try {
|
|
65
|
+
const response = await fetch(url, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "content-type": "application/json" },
|
|
68
|
+
body: JSON.stringify({ path: "extensions:listAllApproved", args: {}, format: "json" }),
|
|
69
|
+
});
|
|
70
|
+
if (!response.ok)
|
|
71
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
72
|
+
const json = await response.json();
|
|
73
|
+
const raw = Array.isArray(json) ? json : Array.isArray(json?.value) ? json.value : [];
|
|
74
|
+
const items = raw.map(normalizeCafe).filter(Boolean);
|
|
75
|
+
return { source, enabled: true, rawCount: raw.length, items, warnings: raw.length && !items.length ? ["No cafe entries normalized."] : [] };
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
return { source, enabled: true, rawCount: 0, items: [], warnings: [], error: error instanceof Error ? error.message : String(error) };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function fetchAwesome(url) {
|
|
82
|
+
const source = "awesome-opencode";
|
|
83
|
+
if (!url)
|
|
84
|
+
return { source, enabled: true, rawCount: 0, items: [], warnings: [], error: "No URL configured" };
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch(url);
|
|
87
|
+
if (!response.ok)
|
|
88
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
89
|
+
const json = await response.json();
|
|
90
|
+
const list = Array.isArray(json) ? json : Array.isArray(json?.items) ? json.items : flattenRegistry(json);
|
|
91
|
+
const items = list.map(normalizeAwesome).filter(Boolean);
|
|
92
|
+
return { source, enabled: true, rawCount: list.length, items, warnings: list.length && !items.length ? ["No awesome-opencode entries normalized. Check registry field names."] : [] };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
return { source, enabled: true, rawCount: 0, items: [], warnings: [], error: error instanceof Error ? error.message : String(error) };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function fetchDocs(url) {
|
|
99
|
+
const source = "opencode-docs";
|
|
100
|
+
if (!url)
|
|
101
|
+
return { source, enabled: true, rawCount: 0, items: [], warnings: [], error: "No URL configured" };
|
|
102
|
+
try {
|
|
103
|
+
const response = await fetch(url);
|
|
104
|
+
if (!response.ok)
|
|
105
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
106
|
+
const markdown = await response.text();
|
|
107
|
+
const rows = docsRows(markdown);
|
|
108
|
+
const items = parseDocs(markdown);
|
|
109
|
+
return { source, enabled: true, rawCount: rows.length, items, warnings: rows.length && !items.length ? ["No docs ecosystem rows normalized."] : [] };
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
return { source, enabled: true, rawCount: 0, items: [], warnings: [], error: error instanceof Error ? error.message : String(error) };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function normalizeCafe(raw) {
|
|
116
|
+
if (!isRecord(raw))
|
|
117
|
+
return;
|
|
118
|
+
const sourceID = stringValue(raw.productId) ?? stringValue(raw._id);
|
|
119
|
+
const name = stringValue(raw.displayName) ?? stringValue(raw.name) ?? sourceID;
|
|
120
|
+
if (!name)
|
|
121
|
+
return;
|
|
122
|
+
const sourceType = stringValue(raw.type);
|
|
123
|
+
const kind = normalizeKind(sourceType, raw);
|
|
124
|
+
const installation = stringValue(raw.installation);
|
|
125
|
+
const repoUrl = stringValue(raw.repoUrl);
|
|
126
|
+
const homepageUrl = stringValue(raw.homepageUrl);
|
|
127
|
+
return withSource({
|
|
128
|
+
id: `opencode-cafe:${slug(sourceID ?? name)}`,
|
|
129
|
+
source: "opencode-cafe",
|
|
130
|
+
sourceType,
|
|
131
|
+
kind,
|
|
132
|
+
confidence: confidenceFor(kind, installation, repoUrl),
|
|
133
|
+
name,
|
|
134
|
+
description: stringValue(raw.description) ?? "",
|
|
135
|
+
repoUrl,
|
|
136
|
+
homepageUrl,
|
|
137
|
+
tags: stringArray(raw.tags),
|
|
138
|
+
aliases: [sourceID].filter((item) => !!item && item !== name),
|
|
139
|
+
installationMarkdown: installation,
|
|
140
|
+
install: extractInstallHint(installation, kind),
|
|
141
|
+
warnings: warningsFor(kind, installation),
|
|
142
|
+
updatedAt: numberDate(raw.updatedAt) ?? numberDate(raw.reviewedAt) ?? numberDate(raw.createdAt) ?? stringValue(raw.updatedAt),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function normalizeAwesome(raw) {
|
|
146
|
+
if (!isRecord(raw))
|
|
147
|
+
return;
|
|
148
|
+
const productID = stringValue(raw.productId);
|
|
149
|
+
const name = stringValue(raw.displayName) ?? stringValue(raw.name) ?? productID;
|
|
150
|
+
if (!name)
|
|
151
|
+
return;
|
|
152
|
+
const sourceType = stringValue(raw.type) ?? stringValue(raw.category);
|
|
153
|
+
const kind = normalizeKind(sourceType, raw);
|
|
154
|
+
const repoUrl = stringValue(raw.repoUrl) ?? stringValue(raw.repo);
|
|
155
|
+
const homepageUrl = stringValue(raw.homepageUrl) ?? stringValue(raw.homepage);
|
|
156
|
+
const installation = stringValue(raw.installation);
|
|
157
|
+
return withSource({
|
|
158
|
+
id: `awesome-opencode:${slug(productID ?? name)}`,
|
|
159
|
+
source: "awesome-opencode",
|
|
160
|
+
sourceType,
|
|
161
|
+
kind,
|
|
162
|
+
confidence: confidenceFor(kind, installation, repoUrl),
|
|
163
|
+
name,
|
|
164
|
+
description: stringValue(raw.description) ?? stringValue(raw.tagline) ?? "",
|
|
165
|
+
repoUrl,
|
|
166
|
+
homepageUrl,
|
|
167
|
+
tags: [...stringArray(raw.tags), ...stringArray(raw.scope).map((scope) => `scope:${scope}`)],
|
|
168
|
+
aliases: [productID].filter((item) => !!item && item !== name),
|
|
169
|
+
installationMarkdown: installation,
|
|
170
|
+
install: extractInstallHint(installation, kind),
|
|
171
|
+
warnings: warningsFor(kind, installation),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function parseDocs(markdown) {
|
|
175
|
+
const lines = markdown.split(/\r?\n/);
|
|
176
|
+
let section = "unknown";
|
|
177
|
+
const items = [];
|
|
178
|
+
for (const line of lines) {
|
|
179
|
+
const header = /^##\s+(.+)$/.exec(line);
|
|
180
|
+
if (header) {
|
|
181
|
+
section = header[1]?.trim() ?? "unknown";
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const row = /^\|\s*\[([^\]]+)\]\(([^)]+)\)\s*\|\s*([^|]+)\|/.exec(line);
|
|
185
|
+
if (!row)
|
|
186
|
+
continue;
|
|
187
|
+
const name = row[1]?.trim();
|
|
188
|
+
const repo = row[2]?.trim();
|
|
189
|
+
if (!name)
|
|
190
|
+
continue;
|
|
191
|
+
const kind = normalizeKind(section, { name, description: row[3] });
|
|
192
|
+
items.push(withSource({
|
|
193
|
+
id: `opencode-docs:${slug(section)}:${slug(name)}`,
|
|
194
|
+
source: "opencode-docs",
|
|
195
|
+
sourceType: section,
|
|
196
|
+
kind,
|
|
197
|
+
confidence: confidenceFor(kind, undefined, repo),
|
|
198
|
+
name,
|
|
199
|
+
description: row[3]?.trim() ?? "",
|
|
200
|
+
repoUrl: repo,
|
|
201
|
+
tags: [],
|
|
202
|
+
warnings: kind === "plugin" ? ["Docs entry does not include a package spec; inspect before installing."] : ["Docs ecosystem entry may need Forge or manual installation."],
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
return items;
|
|
206
|
+
}
|
|
207
|
+
function docsRows(markdown) {
|
|
208
|
+
return markdown.split(/\r?\n/).filter((line) => /^\|\s*\[[^\]]+\]\([^)]+\)\s*\|\s*[^|]+\|/.test(line));
|
|
209
|
+
}
|
|
210
|
+
export function normalizeKind(type, raw = {}) {
|
|
211
|
+
const normalized = (type ?? "").toLowerCase().replace(/_/g, "-").trim();
|
|
212
|
+
if (["plugin", "plugins", "tui-plugin", "server-plugin"].includes(normalized))
|
|
213
|
+
return "plugin";
|
|
214
|
+
if (["mcp", "mcps", "mcp-server", "mcp-servers"].includes(normalized))
|
|
215
|
+
return "mcp";
|
|
216
|
+
if (["slash-command", "slash-commands", "command", "commands"].includes(normalized))
|
|
217
|
+
return "command";
|
|
218
|
+
if (["agent", "agents"].includes(normalized))
|
|
219
|
+
return "agent";
|
|
220
|
+
if (["theme", "themes"].includes(normalized))
|
|
221
|
+
return "theme";
|
|
222
|
+
if (["skill", "skills"].includes(normalized))
|
|
223
|
+
return "skill";
|
|
224
|
+
if (["tool", "tools"].includes(normalized))
|
|
225
|
+
return "tool";
|
|
226
|
+
if (["web-view", "webview", "app", "apps"].includes(normalized))
|
|
227
|
+
return "app";
|
|
228
|
+
if (["fork", "project", "projects"].includes(normalized))
|
|
229
|
+
return "project";
|
|
230
|
+
if (["resource", "resources", "discussion", "docs"].includes(normalized))
|
|
231
|
+
return "resource";
|
|
232
|
+
const haystack = `${stringValue(raw.name) ?? ""} ${stringValue(raw.displayName) ?? ""} ${stringValue(raw.productId) ?? ""} ${stringValue(raw.description) ?? ""} ${stringValue(raw.tagline) ?? ""} ${stringArray(raw.tags).join(" ")}`.toLowerCase();
|
|
233
|
+
if (/\bmcp\b/.test(haystack))
|
|
234
|
+
return "mcp";
|
|
235
|
+
if (/\bskill\b|skill\.md/.test(haystack))
|
|
236
|
+
return "skill";
|
|
237
|
+
if (/\bagent\b/.test(haystack))
|
|
238
|
+
return "agent";
|
|
239
|
+
if (/\bcommand\b|slash command/.test(haystack))
|
|
240
|
+
return "command";
|
|
241
|
+
if (/\btheme\b/.test(haystack))
|
|
242
|
+
return "theme";
|
|
243
|
+
if (/\bplugin\b|opencode-/.test(haystack))
|
|
244
|
+
return "plugin";
|
|
245
|
+
return "unknown";
|
|
246
|
+
}
|
|
247
|
+
function extractInstallHint(text, kind) {
|
|
248
|
+
if (!text)
|
|
249
|
+
return undefined;
|
|
250
|
+
const specs = Array.from(text.matchAll(/["']plugin["']\s*:\s*\[\s*((?:["'][^"']+["']\s*,?\s*)+)/gm))
|
|
251
|
+
.flatMap((match) => Array.from(match[1]?.matchAll(/["']([^"']+)["']/g) ?? []).map((entry) => entry[1]).filter((entry) => !!entry));
|
|
252
|
+
if (specs.length)
|
|
253
|
+
return { type: "plugin", spec: specs[0], specs: Array.from(new Set(specs)), reason: "Found OpenCode plugin config snippet", verified: true };
|
|
254
|
+
const barePlugin = /opencode\s+(?:plugin|plug)\s+([^\s`]+)/im.exec(text);
|
|
255
|
+
if (barePlugin?.[1])
|
|
256
|
+
return { type: "plugin", spec: barePlugin[1], specs: [barePlugin[1]], reason: "Found OpenCode plugin install command", verified: true };
|
|
257
|
+
if (/["']mcp["']\s*:\s*\{/m.test(text))
|
|
258
|
+
return { type: "mcp", reason: "Found OpenCode MCP config snippet", verified: true };
|
|
259
|
+
if (/\bmcpServers\b/m.test(text))
|
|
260
|
+
return { type: "mcp", reason: "Found Claude mcpServers config; conversion requires approval", verified: false };
|
|
261
|
+
if (kind === "plugin" && /^(@?[\w.-]+\/[\w.-]+|@?[\w.-]+)$/.test(text.trim()))
|
|
262
|
+
return { type: "plugin", spec: text.trim(), specs: [text.trim()], verified: true };
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
function confidenceFor(kind, installation, repoUrl) {
|
|
266
|
+
const hint = extractInstallHint(installation, kind);
|
|
267
|
+
if (hint?.verified && (hint.spec || hint.type === "mcp"))
|
|
268
|
+
return "verified";
|
|
269
|
+
if (["plugin", "mcp", "agent", "command", "theme", "skill"].includes(kind) && (installation || repoUrl))
|
|
270
|
+
return "partial";
|
|
271
|
+
if (["tool", "app", "project", "resource"].includes(kind))
|
|
272
|
+
return "unmapped";
|
|
273
|
+
return "unvetted";
|
|
274
|
+
}
|
|
275
|
+
function warningsFor(kind, installation) {
|
|
276
|
+
const warnings = [];
|
|
277
|
+
if (!installation)
|
|
278
|
+
warnings.push("No installation instructions were found in the source.");
|
|
279
|
+
if (installation && /curl\s+[^|]+\|\s*(?:sh|bash)|rm\s+-rf|sudo\s+/i.test(installation))
|
|
280
|
+
warnings.push("Installation text contains shell commands that need security review.");
|
|
281
|
+
if (installation && /\bmcpServers\b/m.test(installation))
|
|
282
|
+
warnings.push("Claude mcpServers config requires explicit conversion approval before native OpenCode install.");
|
|
283
|
+
if (["tool", "app", "project", "resource", "unknown"].includes(kind))
|
|
284
|
+
warnings.push("This item is not a deterministic native install target yet; use Forge if needed.");
|
|
285
|
+
if (installation && !extractInstallHint(installation, kind) && ["plugin", "mcp", "agent", "command", "theme", "skill"].includes(kind))
|
|
286
|
+
warnings.push("Install instructions exist but Souk could not prove a deterministic native install hint.");
|
|
287
|
+
return warnings;
|
|
288
|
+
}
|
|
289
|
+
function dedupe(items) {
|
|
290
|
+
const groups = new Map();
|
|
291
|
+
for (const item of items) {
|
|
292
|
+
const key = dedupeKey(item);
|
|
293
|
+
groups.set(key, [...(groups.get(key) ?? []), item]);
|
|
294
|
+
}
|
|
295
|
+
return Array.from(groups.entries()).map(([key, group]) => mergeGroup(key, group)).sort((a, b) => confidenceRank(a.confidence) - confidenceRank(b.confidence) || a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));
|
|
296
|
+
}
|
|
297
|
+
function mergeGroup(key, group) {
|
|
298
|
+
const primary = [...group].sort((a, b) => sourceScore(b) - sourceScore(a))[0] ?? group[0];
|
|
299
|
+
const allSources = uniqueSources(group.flatMap((item) => item.sources.length ? item.sources : [sourceRef(item)]));
|
|
300
|
+
const kinds = Array.from(new Set(group.map((item) => item.kind))).sort((a, b) => KIND_PRIORITY.indexOf(a) - KIND_PRIORITY.indexOf(b));
|
|
301
|
+
const kind = kinds[0] ?? primary.kind;
|
|
302
|
+
const install = bestInstall(group);
|
|
303
|
+
const installationMarkdown = group.find((item) => item.installationMarkdown)?.installationMarkdown;
|
|
304
|
+
const aliases = Array.from(new Set(group.flatMap((item) => [item.name, ...item.aliases]).filter(Boolean))).filter((alias) => alias !== primary.name);
|
|
305
|
+
const descriptions = group.map((item) => item.description).filter(Boolean).sort((a, b) => b.length - a.length);
|
|
306
|
+
return {
|
|
307
|
+
...primary,
|
|
308
|
+
id: group.length > 1 ? `combo:${slug(key)}` : primary.id,
|
|
309
|
+
source: primary.source,
|
|
310
|
+
primarySource: primary.source,
|
|
311
|
+
sourceType: primary.sourceType,
|
|
312
|
+
kind,
|
|
313
|
+
alternateKinds: kinds.filter((item) => item !== kind),
|
|
314
|
+
confidence: bestConfidence(group),
|
|
315
|
+
description: descriptions[0] ?? primary.description,
|
|
316
|
+
repoUrl: bestRepoUrl(group) ?? primary.repoUrl,
|
|
317
|
+
homepageUrl: group.find((item) => item.homepageUrl)?.homepageUrl,
|
|
318
|
+
tags: Array.from(new Set(group.flatMap((item) => item.tags))).sort(),
|
|
319
|
+
aliases,
|
|
320
|
+
sources: allSources,
|
|
321
|
+
installationMarkdown,
|
|
322
|
+
install,
|
|
323
|
+
warnings: Array.from(new Set(group.flatMap((item) => item.warnings))),
|
|
324
|
+
updatedAt: latestDate(group.map((item) => item.updatedAt)),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function dedupeKey(item) {
|
|
328
|
+
const repo = canonicalRepoKey(item.repoUrl);
|
|
329
|
+
if (repo)
|
|
330
|
+
return `repo:${repo}`;
|
|
331
|
+
const spec = item.install?.spec ?? item.install?.specs?.[0];
|
|
332
|
+
if (spec)
|
|
333
|
+
return `spec:${spec.toLowerCase()}`;
|
|
334
|
+
const homepage = canonicalUrl(item.homepageUrl);
|
|
335
|
+
if (homepage)
|
|
336
|
+
return `home:${homepage}`;
|
|
337
|
+
return `name:${slug(item.name)}:${item.kind}`;
|
|
338
|
+
}
|
|
339
|
+
function sourceScore(item) {
|
|
340
|
+
const installScore = item.install?.verified ? 60 : item.install?.spec || item.install?.type ? 30 : 0;
|
|
341
|
+
const confidenceScore = Math.max(0, 40 - confidenceRank(item.confidence) * 10);
|
|
342
|
+
const sourceScore = item.source === "installed" ? 0 : SOURCE_TRUST[item.source];
|
|
343
|
+
const descScore = Math.min(10, Math.floor((item.description?.length ?? 0) / 40));
|
|
344
|
+
const repoScore = item.repoUrl ? 8 : 0;
|
|
345
|
+
return installScore + confidenceScore + sourceScore + descScore + repoScore;
|
|
346
|
+
}
|
|
347
|
+
function bestInstall(group) {
|
|
348
|
+
return [...group].filter((item) => item.install).sort((a, b) => sourceScore(b) - sourceScore(a))[0]?.install;
|
|
349
|
+
}
|
|
350
|
+
function bestConfidence(group) {
|
|
351
|
+
return [...group].sort((a, b) => confidenceRank(a.confidence) - confidenceRank(b.confidence))[0]?.confidence ?? "unvetted";
|
|
352
|
+
}
|
|
353
|
+
function bestRepoUrl(group) {
|
|
354
|
+
return [...group].sort((a, b) => sourceScore(b) - sourceScore(a)).find((item) => item.repoUrl)?.repoUrl;
|
|
355
|
+
}
|
|
356
|
+
function sourceRef(item) {
|
|
357
|
+
return {
|
|
358
|
+
source: item.source,
|
|
359
|
+
id: item.id,
|
|
360
|
+
sourceType: item.sourceType,
|
|
361
|
+
name: item.name,
|
|
362
|
+
description: item.description,
|
|
363
|
+
repoUrl: item.repoUrl,
|
|
364
|
+
homepageUrl: item.homepageUrl,
|
|
365
|
+
kind: item.kind,
|
|
366
|
+
confidence: item.confidence,
|
|
367
|
+
install: item.install,
|
|
368
|
+
installationMarkdown: item.installationMarkdown,
|
|
369
|
+
updatedAt: item.updatedAt,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
function withSource(item) {
|
|
373
|
+
const full = { ...item, aliases: item.aliases ?? [], alternateKinds: item.alternateKinds ?? [], sources: item.sources ?? [] };
|
|
374
|
+
return { ...full, primarySource: full.source, sources: [sourceRef(full)] };
|
|
375
|
+
}
|
|
376
|
+
function uniqueSources(sources) {
|
|
377
|
+
const map = new Map();
|
|
378
|
+
for (const source of sources)
|
|
379
|
+
map.set(`${source.source}:${source.id ?? source.name ?? source.repoUrl ?? "unknown"}`, source);
|
|
380
|
+
return Array.from(map.values()).sort((a, b) => a.source.localeCompare(b.source));
|
|
381
|
+
}
|
|
382
|
+
function canonicalRepoKey(url) {
|
|
383
|
+
const canonical = canonicalUrl(url);
|
|
384
|
+
if (!canonical)
|
|
385
|
+
return undefined;
|
|
386
|
+
try {
|
|
387
|
+
const parsed = new URL(canonical);
|
|
388
|
+
if (parsed.hostname !== "github.com")
|
|
389
|
+
return canonical;
|
|
390
|
+
const [owner, repo] = parsed.pathname.split("/").filter(Boolean);
|
|
391
|
+
if (!owner || !repo)
|
|
392
|
+
return canonical;
|
|
393
|
+
return `github.com/${owner.toLowerCase()}/${repo.toLowerCase().replace(/\.git$/, "")}`;
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return canonical;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
function canonicalUrl(url) {
|
|
400
|
+
if (!url)
|
|
401
|
+
return undefined;
|
|
402
|
+
try {
|
|
403
|
+
const parsed = new URL(url);
|
|
404
|
+
parsed.hash = "";
|
|
405
|
+
parsed.search = "";
|
|
406
|
+
parsed.pathname = parsed.pathname.replace(/\.git$/i, "").replace(/\/+$/g, "");
|
|
407
|
+
return parsed.toString().replace(/\/$/, "").toLowerCase();
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return url.trim().toLowerCase().replace(/\.git$/i, "").replace(/\/+$/g, "") || undefined;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function latestDate(values) {
|
|
414
|
+
const dates = values.filter(Boolean).sort((a, b) => Date.parse(b) - Date.parse(a));
|
|
415
|
+
return dates[0];
|
|
416
|
+
}
|
|
417
|
+
function flattenRegistry(raw) {
|
|
418
|
+
if (!isRecord(raw))
|
|
419
|
+
return [];
|
|
420
|
+
return Object.entries(raw).flatMap(([type, value]) => Array.isArray(value) ? value.map((entry) => isRecord(entry) ? { ...entry, type } : entry) : []);
|
|
421
|
+
}
|
|
422
|
+
function confidenceRank(confidence) {
|
|
423
|
+
return ["verified", "partial", "unmapped", "unvetted", "blocked"].indexOf(confidence);
|
|
424
|
+
}
|
|
425
|
+
function isRecord(value) {
|
|
426
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
427
|
+
}
|
|
428
|
+
function stringValue(value) {
|
|
429
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
430
|
+
}
|
|
431
|
+
function stringArray(value) {
|
|
432
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.trim().length > 0).map((item) => item.trim()) : [];
|
|
433
|
+
}
|
|
434
|
+
function numberDate(value) {
|
|
435
|
+
return typeof value === "number" ? new Date(value).toISOString() : undefined;
|
|
436
|
+
}
|
|
437
|
+
function slug(value) {
|
|
438
|
+
return value.toLowerCase().replace(/[^a-z0-9@._/-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
439
|
+
}
|
|
440
|
+
export const registryTestHooks = {
|
|
441
|
+
normalizeCafe,
|
|
442
|
+
normalizeAwesome,
|
|
443
|
+
parseDocs,
|
|
444
|
+
normalizeKind,
|
|
445
|
+
dedupe,
|
|
446
|
+
canonicalRepoKey,
|
|
447
|
+
};
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED