@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
package/dist/install.js
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, basename, extname, relative } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { parse, stringify } from "comment-json";
|
|
5
|
+
import { debugLog } from "./config.js";
|
|
6
|
+
const OPENCODE_SCHEMA = "https://opencode.ai/config.json";
|
|
7
|
+
const TUI_SCHEMA = "https://opencode.ai/tui.json";
|
|
8
|
+
const THEME_SCHEMA = "https://opencode.ai/theme.json";
|
|
9
|
+
export async function previewNativeInstall(items, scope, settings) {
|
|
10
|
+
const plans = await Promise.all(items.map((item) => planInstall(item, scope, settings)));
|
|
11
|
+
return {
|
|
12
|
+
supported: plans.some((plan) => plan.supported),
|
|
13
|
+
summary: plans.flatMap((plan) => plan.actions.length ? plan.actions : [`${plan.item.name}: no deterministic native action`]).join("\n"),
|
|
14
|
+
warnings: plans.flatMap((plan) => plan.warnings),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export async function nativeInstall(api, items, scope, callbacks = {}, settings) {
|
|
18
|
+
debugLog("Native install start", `${items.length} item(s) -> ${scopeLabel(scope)}`);
|
|
19
|
+
const installed = [];
|
|
20
|
+
const failed = [];
|
|
21
|
+
const planned = await Promise.all(items.map(async (item) => ({ item, plan: await planInstall(item, scope, settings).catch((error) => ({ item, supported: false, actions: [], warnings: [error instanceof Error ? error.message : String(error)] })) })));
|
|
22
|
+
const supported = planned.filter(({ plan }) => plan.supported && plan.execute);
|
|
23
|
+
let backupPath;
|
|
24
|
+
if (supported.length) {
|
|
25
|
+
backupPath = createInstallBackup(scope, supported.map(({ item }) => item));
|
|
26
|
+
installed.push(`Pre-install backup created at ${backupPath}`);
|
|
27
|
+
}
|
|
28
|
+
for (const { item, plan } of planned) {
|
|
29
|
+
try {
|
|
30
|
+
if (!plan.supported || !plan.execute) {
|
|
31
|
+
failed.push(`${item.name}: ${plan.warnings[0] ?? "no deterministic native installer"}`);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
installed.push(...await plan.execute(api, callbacks));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
failed.push(`${item.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
debugLog("Native install result", `installed=${installed.length}; failed=${failed.length}`);
|
|
41
|
+
return { installed, failed };
|
|
42
|
+
}
|
|
43
|
+
function createInstallBackup(scope, items) {
|
|
44
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
45
|
+
const root = join(globalConfigDir(), "souk-install-backups", `${stamp}-${scope.kind}`);
|
|
46
|
+
mkdirSync(root, { recursive: true });
|
|
47
|
+
const candidates = scope.kind === "global" ? globalBackupCandidates() : projectBackupCandidates(scope.path);
|
|
48
|
+
const copied = [];
|
|
49
|
+
for (const candidate of candidates) {
|
|
50
|
+
if (!existsSync(candidate.path))
|
|
51
|
+
continue;
|
|
52
|
+
const target = join(root, candidate.label);
|
|
53
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
54
|
+
cpSync(candidate.path, target, { recursive: true, force: false, errorOnExist: true });
|
|
55
|
+
copied.push(candidate.label);
|
|
56
|
+
}
|
|
57
|
+
writeTextAtomic(join(root, "manifest.json"), `${JSON.stringify({
|
|
58
|
+
createdAt: new Date().toISOString(),
|
|
59
|
+
scope,
|
|
60
|
+
items: items.map((item) => ({ id: item.id, name: item.name, kind: item.kind, source: item.source, repoUrl: item.repoUrl })),
|
|
61
|
+
copied,
|
|
62
|
+
}, null, 2)}\n`);
|
|
63
|
+
debugLog("Install backup created", `${root}; copied=${copied.length}`);
|
|
64
|
+
return root;
|
|
65
|
+
}
|
|
66
|
+
function globalBackupCandidates() {
|
|
67
|
+
const dir = globalConfigDir();
|
|
68
|
+
return [
|
|
69
|
+
"opencode.jsonc",
|
|
70
|
+
"opencode.json",
|
|
71
|
+
"config.json",
|
|
72
|
+
"tui.jsonc",
|
|
73
|
+
"tui.json",
|
|
74
|
+
"agents",
|
|
75
|
+
"agent",
|
|
76
|
+
"commands",
|
|
77
|
+
"command",
|
|
78
|
+
"skills",
|
|
79
|
+
"skill",
|
|
80
|
+
"themes",
|
|
81
|
+
"plugins",
|
|
82
|
+
"plugin",
|
|
83
|
+
].map((name) => ({ path: join(dir, name), label: name }));
|
|
84
|
+
}
|
|
85
|
+
function projectBackupCandidates(project) {
|
|
86
|
+
return [
|
|
87
|
+
"opencode.jsonc",
|
|
88
|
+
"opencode.json",
|
|
89
|
+
"tui.jsonc",
|
|
90
|
+
"tui.json",
|
|
91
|
+
".opencode",
|
|
92
|
+
].map((name) => ({ path: join(project, name), label: relative(project, join(project, name)) }));
|
|
93
|
+
}
|
|
94
|
+
async function planInstall(item, scope, settings) {
|
|
95
|
+
if (item.kind === "plugin")
|
|
96
|
+
return planPluginInstall(item, scope);
|
|
97
|
+
if (item.kind === "mcp")
|
|
98
|
+
return planMcpInstall(item, scope, settings);
|
|
99
|
+
if (item.kind === "agent")
|
|
100
|
+
return planAgentInstall(item, scope);
|
|
101
|
+
if (item.kind === "command")
|
|
102
|
+
return planCommandInstall(item, scope);
|
|
103
|
+
if (item.kind === "theme")
|
|
104
|
+
return planThemeInstall(item, scope);
|
|
105
|
+
if (item.kind === "skill")
|
|
106
|
+
return planSkillInstall(item, scope);
|
|
107
|
+
return unsupported(item, `Native ${item.kind} install is not deterministic yet. Use Forge with Kāf for this item.`);
|
|
108
|
+
}
|
|
109
|
+
async function planAgentInstall(item, scope) {
|
|
110
|
+
const agentConfig = extractAgentConfig(item);
|
|
111
|
+
if (agentConfig && Object.keys(agentConfig).length) {
|
|
112
|
+
const target = opencodeConfigPath(scope);
|
|
113
|
+
return {
|
|
114
|
+
item,
|
|
115
|
+
supported: true,
|
|
116
|
+
actions: Object.keys(agentConfig).map((name) => `AGENT ${name} -> ${target}`),
|
|
117
|
+
warnings: item.warnings,
|
|
118
|
+
execute: async () => {
|
|
119
|
+
patchJsonc(target, OPENCODE_SCHEMA, (doc) => {
|
|
120
|
+
const agent = ensureRecord(doc, "agent");
|
|
121
|
+
setConfigEntries(agent, agentConfig, "agent");
|
|
122
|
+
});
|
|
123
|
+
return [`${item.name}: installed ${Object.keys(agentConfig).length} agent config entrie(s) into ${target}`];
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return planMarkdownInstall(item, scope, "agent");
|
|
128
|
+
}
|
|
129
|
+
function planPluginInstall(item, scope) {
|
|
130
|
+
const spec = item.install?.spec ?? inferNpmSpec(item);
|
|
131
|
+
if (!spec)
|
|
132
|
+
return unsupported(item, "No plugin package/file spec found.");
|
|
133
|
+
return {
|
|
134
|
+
item,
|
|
135
|
+
supported: true,
|
|
136
|
+
actions: [`PLUGIN ${spec} -> ${scopeLabel(scope)} plugin config via OpenCode native installer`],
|
|
137
|
+
warnings: item.warnings,
|
|
138
|
+
execute: async (api) => {
|
|
139
|
+
const pluginApi = api.plugins;
|
|
140
|
+
if (pluginApi?.install) {
|
|
141
|
+
const out = await pluginApi.install(spec, { global: scope.kind === "global" });
|
|
142
|
+
if (!out.ok)
|
|
143
|
+
throw new Error(out.message);
|
|
144
|
+
const lines = [`${item.name}: installed plugin ${spec}`];
|
|
145
|
+
if (out.tui) {
|
|
146
|
+
const ok = await pluginApi.add(spec);
|
|
147
|
+
lines.push(ok ? `${item.name}: TUI plugin loaded in this session` : `${item.name}: installed; restart OpenCode if runtime TUI load did not activate`);
|
|
148
|
+
}
|
|
149
|
+
return lines;
|
|
150
|
+
}
|
|
151
|
+
const target = opencodeConfigPath(scope);
|
|
152
|
+
patchJsonc(target, OPENCODE_SCHEMA, (doc) => appendPluginSpec(doc, spec));
|
|
153
|
+
return [`${item.name}: added plugin ${spec} to ${target}. Restart OpenCode for config-time plugin loading.`];
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
async function planMcpInstall(item, scope, settings) {
|
|
158
|
+
const extracted = extractMcpConfig(item);
|
|
159
|
+
if (extracted.requiresConversionApproval && settings?.allow_claude_mcp_conversion === "never")
|
|
160
|
+
return unsupported(item, "Claude mcpServers conversion is disabled by Souk config.");
|
|
161
|
+
if (!extracted.config || Object.keys(extracted.config).length === 0)
|
|
162
|
+
return unsupported(item, "No valid OpenCode MCP config or convertible Claude mcpServers config found.");
|
|
163
|
+
const target = opencodeConfigPath(scope);
|
|
164
|
+
return {
|
|
165
|
+
item,
|
|
166
|
+
supported: true,
|
|
167
|
+
actions: Object.keys(extracted.config).map((name) => `MCP ${name} -> ${target}`),
|
|
168
|
+
warnings: [...item.warnings, ...extracted.warnings],
|
|
169
|
+
execute: async (api, callbacks) => {
|
|
170
|
+
if (extracted.requiresConversionApproval) {
|
|
171
|
+
const ok = await callbacks.confirm?.("Convert Claude MCP config?", `${item.name} contains Claude-style mcpServers config. Souk can convert it to OpenCode mcp config.\n\nThe souk can make mistakes. Review the converted server names and commands before approving.`);
|
|
172
|
+
if (!ok)
|
|
173
|
+
throw new Error("Claude mcpServers conversion was not approved.");
|
|
174
|
+
}
|
|
175
|
+
patchJsonc(target, OPENCODE_SCHEMA, (doc) => {
|
|
176
|
+
const mcp = ensureRecord(doc, "mcp");
|
|
177
|
+
setConfigEntries(mcp, extracted.config, "MCP server");
|
|
178
|
+
});
|
|
179
|
+
if (settings?.runtime_mcp_connect !== false)
|
|
180
|
+
await addRuntimeMcp(api, extracted.config, scope);
|
|
181
|
+
return [`${item.name}: installed ${Object.keys(extracted.config).length} MCP server(s) into ${target}`];
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
async function planMarkdownInstall(item, scope, kind) {
|
|
186
|
+
const files = await markdownFilesFor(item, kind);
|
|
187
|
+
if (!files.length)
|
|
188
|
+
return unsupported(item, `No ${kind} markdown file or config snippet found.`);
|
|
189
|
+
const dir = scopedDir(scope, kind);
|
|
190
|
+
const targets = files.map((file) => ({ source: file, target: uniqueTargetPath(dir, `${safeName(stripKnownPrefix(file.path, kind))}.md`) }));
|
|
191
|
+
return {
|
|
192
|
+
item,
|
|
193
|
+
supported: true,
|
|
194
|
+
actions: targets.map(({ source, target }) => `${kind.toUpperCase()} ${source.path} -> ${target}`),
|
|
195
|
+
warnings: item.warnings,
|
|
196
|
+
execute: async (_api, callbacks) => {
|
|
197
|
+
const lines = [];
|
|
198
|
+
for (const { source, target } of targets) {
|
|
199
|
+
writeNewFile(target, ensureMarkdownFrontmatter(source.content, item, kind));
|
|
200
|
+
lines.push(`${item.name}: installed ${kind} ${basename(target, ".md")} into ${target}`);
|
|
201
|
+
}
|
|
202
|
+
return lines;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async function planCommandInstall(item, scope) {
|
|
207
|
+
const commandConfig = extractCommandConfig(item);
|
|
208
|
+
if (commandConfig && Object.keys(commandConfig).length) {
|
|
209
|
+
const target = opencodeConfigPath(scope);
|
|
210
|
+
return {
|
|
211
|
+
item,
|
|
212
|
+
supported: true,
|
|
213
|
+
actions: Object.keys(commandConfig).map((name) => `COMMAND ${name} -> ${target}`),
|
|
214
|
+
warnings: item.warnings,
|
|
215
|
+
execute: async () => {
|
|
216
|
+
patchJsonc(target, OPENCODE_SCHEMA, (doc) => {
|
|
217
|
+
const command = ensureRecord(doc, "command");
|
|
218
|
+
setConfigEntries(command, commandConfig, "command");
|
|
219
|
+
});
|
|
220
|
+
return [`${item.name}: installed ${Object.keys(commandConfig).length} command(s) into ${target}`];
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return planMarkdownInstall(item, scope, "command");
|
|
225
|
+
}
|
|
226
|
+
function extractAgentConfig(item) {
|
|
227
|
+
for (const snippet of candidateObjects(item)) {
|
|
228
|
+
const agent = asRecord(snippet.agent);
|
|
229
|
+
if (agent)
|
|
230
|
+
return Object.fromEntries(Object.entries(agent).flatMap(([name, value]) => {
|
|
231
|
+
const config = asRecord(value);
|
|
232
|
+
if (!config)
|
|
233
|
+
return [];
|
|
234
|
+
return [[safeName(name), config]];
|
|
235
|
+
}));
|
|
236
|
+
}
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
async function planThemeInstall(item, scope) {
|
|
240
|
+
const themes = await themeFilesFor(item);
|
|
241
|
+
if (!themes.length)
|
|
242
|
+
return unsupported(item, "No valid OpenCode theme JSON found.");
|
|
243
|
+
const dir = scopedDir(scope, "theme");
|
|
244
|
+
const targets = themes.map((file) => ({ source: file, target: uniqueTargetPath(dir, `${safeName(basename(file.path, extname(file.path)) || item.name)}.json`) }));
|
|
245
|
+
return {
|
|
246
|
+
item,
|
|
247
|
+
supported: true,
|
|
248
|
+
actions: targets.map(({ source, target }) => `THEME ${source.path} -> ${target}`),
|
|
249
|
+
warnings: [...item.warnings, "Theme is installed but not activated automatically; set tui.json theme if desired."],
|
|
250
|
+
execute: async (_api, callbacks) => {
|
|
251
|
+
const lines = [];
|
|
252
|
+
for (const { source, target } of targets) {
|
|
253
|
+
writeNewFile(target, normalizeThemeJson(source.content));
|
|
254
|
+
lines.push(`${item.name}: installed theme ${basename(target, ".json")} into ${target}`);
|
|
255
|
+
}
|
|
256
|
+
if (targets.length === 1) {
|
|
257
|
+
const themeName = basename(targets[0].target, ".json");
|
|
258
|
+
const ok = await callbacks.confirm?.("Activate theme?", `Activate theme "${themeName}" in ${scopeLabel(scope)} tui config?\n\nThis writes the theme field to tui.jsonc and requires restarting or reopening OpenCode TUI if the running UI does not refresh it.`);
|
|
259
|
+
if (ok) {
|
|
260
|
+
const tuiPath = tuiConfigPath(scope);
|
|
261
|
+
patchJsonc(tuiPath, TUI_SCHEMA, (doc) => { doc.theme = themeName; });
|
|
262
|
+
lines.push(`${item.name}: activated theme ${themeName} in ${tuiPath}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return lines;
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
async function planSkillInstall(item, scope) {
|
|
270
|
+
const skills = await skillFilesFor(item);
|
|
271
|
+
if (!skills.length)
|
|
272
|
+
return unsupported(item, "No SKILL.md file found.");
|
|
273
|
+
const dir = scopedDir(scope, "skill");
|
|
274
|
+
const targets = skills.map((skill) => {
|
|
275
|
+
const folder = safeName(skill.name || item.name);
|
|
276
|
+
const files = skill.files.map((file) => ({ source: file, target: join(dir, folder, relativeWithinSkill(file.path, skill.root)) }));
|
|
277
|
+
return { name: folder, files };
|
|
278
|
+
});
|
|
279
|
+
return {
|
|
280
|
+
item,
|
|
281
|
+
supported: true,
|
|
282
|
+
actions: targets.flatMap((skill) => skill.files.map(({ source, target }) => `SKILL ${skill.name}/${source.path} -> ${target}`)),
|
|
283
|
+
warnings: item.warnings,
|
|
284
|
+
execute: async () => {
|
|
285
|
+
const lines = [];
|
|
286
|
+
for (const skill of targets) {
|
|
287
|
+
for (const { source, target } of skill.files)
|
|
288
|
+
writeNewFile(target, source.content);
|
|
289
|
+
lines.push(`${item.name}: installed skill ${skill.name} with ${skill.files.length} file(s)`);
|
|
290
|
+
}
|
|
291
|
+
return lines;
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function extractMcpConfig(item) {
|
|
296
|
+
const warnings = [];
|
|
297
|
+
const snippets = candidateObjects(item);
|
|
298
|
+
for (const snippet of snippets) {
|
|
299
|
+
const mcp = asRecord(snippet.mcp);
|
|
300
|
+
if (mcp)
|
|
301
|
+
return { config: normalizeMcpMap(mcp, warnings), warnings, requiresConversionApproval: false };
|
|
302
|
+
const direct = normalizeMcpEntry(snippet, warnings);
|
|
303
|
+
if (direct)
|
|
304
|
+
return { config: { [safeName(item.install?.name ?? item.name)]: direct }, warnings, requiresConversionApproval: false };
|
|
305
|
+
const claude = asRecord(snippet.mcpServers);
|
|
306
|
+
if (claude)
|
|
307
|
+
return { config: convertClaudeMcp(claude, warnings), warnings, requiresConversionApproval: true };
|
|
308
|
+
}
|
|
309
|
+
if (item.install?.config && item.install.type === "mcp")
|
|
310
|
+
return { config: normalizeMcpMap(item.install.config, warnings), warnings, requiresConversionApproval: false };
|
|
311
|
+
return { config: {}, warnings, requiresConversionApproval: false };
|
|
312
|
+
}
|
|
313
|
+
function extractCommandConfig(item) {
|
|
314
|
+
for (const snippet of candidateObjects(item)) {
|
|
315
|
+
const command = asRecord(snippet.command);
|
|
316
|
+
if (command)
|
|
317
|
+
return Object.fromEntries(Object.entries(command).flatMap(([name, value]) => {
|
|
318
|
+
const config = asRecord(value);
|
|
319
|
+
if (!config || typeof config.template !== "string")
|
|
320
|
+
return [];
|
|
321
|
+
return [[safeName(name), config]];
|
|
322
|
+
}));
|
|
323
|
+
}
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
async function markdownFilesFor(item, kind) {
|
|
327
|
+
const fromBlocks = markdownBlocks(item.installationMarkdown).filter((block) => looksLikeMarkdownEntry(block, kind));
|
|
328
|
+
if (fromBlocks.length)
|
|
329
|
+
return fromBlocks.map((content, index) => ({ path: `${safeName(item.name)}${index ? `-${index + 1}` : ""}.md`, content }));
|
|
330
|
+
const repo = await githubFiles(item.repoUrl, kind);
|
|
331
|
+
return repo.filter((file) => looksLikeKnownPath(file.path, kind));
|
|
332
|
+
}
|
|
333
|
+
async function themeFilesFor(item) {
|
|
334
|
+
const fromBlocks = codeBlocks(item.installationMarkdown).filter((block) => isThemeJsonText(block));
|
|
335
|
+
if (fromBlocks.length)
|
|
336
|
+
return fromBlocks.map((content, index) => ({ path: `${safeName(item.name)}${index ? `-${index + 1}` : ""}.json`, content }));
|
|
337
|
+
return (await githubFiles(item.repoUrl, "theme")).filter((file) => isThemeJsonText(file.content));
|
|
338
|
+
}
|
|
339
|
+
async function skillFilesFor(item) {
|
|
340
|
+
const fromBlocks = markdownBlocks(item.installationMarkdown).filter((block) => /---[\s\S]*name\s*:/m.test(block) && /---[\s\S]*description\s*:/m.test(block));
|
|
341
|
+
if (fromBlocks.length)
|
|
342
|
+
return fromBlocks.map((content) => ({ name: skillNameFromText(content) ?? safeName(item.name), root: "", files: [{ path: "SKILL.md", content }] }));
|
|
343
|
+
const repo = await githubFiles(item.repoUrl, "skill");
|
|
344
|
+
const skillRoots = repo.filter((file) => basename(file.path) === "SKILL.md").map((file) => dirname(file.path));
|
|
345
|
+
return skillRoots.map((root) => {
|
|
346
|
+
const files = repo.filter((file) => file.path === join(root, "SKILL.md").replace(/\\/g, "/") || file.path.startsWith(`${root}/`));
|
|
347
|
+
const skillMd = files.find((file) => basename(file.path) === "SKILL.md");
|
|
348
|
+
return { name: skillNameFromText(skillMd?.content ?? "") ?? safeName(basename(root) || item.name), root, files };
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
async function githubFiles(repoUrl, kind) {
|
|
352
|
+
const parsed = parseGitHubRepo(repoUrl);
|
|
353
|
+
if (!parsed)
|
|
354
|
+
return [];
|
|
355
|
+
const repoInfo = await fetchJson(`https://api.github.com/repos/${parsed.owner}/${parsed.repo}`);
|
|
356
|
+
const defaultBranch = typeof repoInfo.default_branch === "string" ? repoInfo.default_branch : "main";
|
|
357
|
+
const tree = await fetchJson(`https://api.github.com/repos/${parsed.owner}/${parsed.repo}/git/trees/${encodeURIComponent(parsed.branch ?? defaultBranch)}?recursive=1`);
|
|
358
|
+
const entries = Array.isArray(tree.tree) ? tree.tree : [];
|
|
359
|
+
const paths = entries.flatMap((entry) => {
|
|
360
|
+
const rec = asRecord(entry);
|
|
361
|
+
if (!rec || rec.type !== "blob" || typeof rec.path !== "string")
|
|
362
|
+
return [];
|
|
363
|
+
return [rec.path];
|
|
364
|
+
}).filter((path) => candidatePath(path, kind));
|
|
365
|
+
const selected = narrowCandidates(paths, parsed.path, kind);
|
|
366
|
+
return Promise.all(selected.map(async (path) => ({ path, content: await fetchText(`https://raw.githubusercontent.com/${parsed.owner}/${parsed.repo}/${encodeURIComponent(parsed.branch ?? defaultBranch).replace(/%2F/g, "/")}/${path}`) })));
|
|
367
|
+
}
|
|
368
|
+
function candidatePath(path, kind) {
|
|
369
|
+
const lower = path.toLowerCase();
|
|
370
|
+
if (kind === "agent")
|
|
371
|
+
return /(^|\/)(agent|agents)\/.*\.md$/.test(lower);
|
|
372
|
+
if (kind === "command")
|
|
373
|
+
return /(^|\/)(command|commands)\/.*\.md$/.test(lower);
|
|
374
|
+
if (kind === "skill")
|
|
375
|
+
return /(^|\/)(skill|skills)\/.*$/.test(lower) || /(^|\/)skill\.md$/.test(lower);
|
|
376
|
+
if (kind === "theme")
|
|
377
|
+
return /(^|\/)themes\/.*\.json$/.test(lower) || /theme.*\.json$/.test(lower);
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
function narrowCandidates(paths, requestedPath, kind) {
|
|
381
|
+
const scoped = requestedPath ? paths.filter((path) => path === requestedPath || path.startsWith(`${requestedPath.replace(/\/$/, "")}/`)) : paths;
|
|
382
|
+
const source = scoped.length ? scoped : paths;
|
|
383
|
+
if (kind === "skill")
|
|
384
|
+
return source.slice(0, 100);
|
|
385
|
+
return source.slice(0, 25);
|
|
386
|
+
}
|
|
387
|
+
function candidateObjects(item) {
|
|
388
|
+
const objects = [];
|
|
389
|
+
if (item.install?.config)
|
|
390
|
+
objects.push(item.install.config);
|
|
391
|
+
for (const block of codeBlocks(item.installationMarkdown)) {
|
|
392
|
+
const parsed = parseLooseObject(block);
|
|
393
|
+
if (parsed)
|
|
394
|
+
objects.push(parsed);
|
|
395
|
+
}
|
|
396
|
+
const whole = parseLooseObject(item.installationMarkdown);
|
|
397
|
+
if (whole)
|
|
398
|
+
objects.push(whole);
|
|
399
|
+
return objects;
|
|
400
|
+
}
|
|
401
|
+
function codeBlocks(text) {
|
|
402
|
+
if (!text)
|
|
403
|
+
return [];
|
|
404
|
+
const blocks = [];
|
|
405
|
+
for (const match of text.matchAll(/```(?:jsonc?|ya?ml|toml|md|markdown)?\s*([\s\S]*?)```/gi)) {
|
|
406
|
+
if (match[1]?.trim())
|
|
407
|
+
blocks.push(match[1].trim());
|
|
408
|
+
}
|
|
409
|
+
return blocks.length ? blocks : [text.trim()].filter(Boolean);
|
|
410
|
+
}
|
|
411
|
+
function markdownBlocks(text) {
|
|
412
|
+
return codeBlocks(text).filter((block) => block.includes("---") || block.split(/\r?\n/).length > 3);
|
|
413
|
+
}
|
|
414
|
+
function parseLooseObject(text) {
|
|
415
|
+
if (!text)
|
|
416
|
+
return undefined;
|
|
417
|
+
const candidates = [text, stripJsonAssignment(text), extractBraced(text)].filter((candidate) => !!candidate);
|
|
418
|
+
for (const candidate of candidates) {
|
|
419
|
+
try {
|
|
420
|
+
const parsed = parse(candidate);
|
|
421
|
+
if (isRecord(parsed))
|
|
422
|
+
return parsed;
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
// keep trying candidates
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
function stripJsonAssignment(text) {
|
|
431
|
+
return text.replace(/^\s*(?:const|let|var|export\s+default)?\s*[\w-]+\s*=\s*/m, "").trim();
|
|
432
|
+
}
|
|
433
|
+
function extractBraced(text) {
|
|
434
|
+
const start = text.indexOf("{");
|
|
435
|
+
const end = text.lastIndexOf("}");
|
|
436
|
+
return start >= 0 && end > start ? text.slice(start, end + 1) : undefined;
|
|
437
|
+
}
|
|
438
|
+
function normalizeMcpMap(map, warnings) {
|
|
439
|
+
return Object.fromEntries(Object.entries(map).flatMap(([name, value]) => {
|
|
440
|
+
const entry = normalizeMcpEntry(value, warnings);
|
|
441
|
+
if (!entry) {
|
|
442
|
+
warnings.push(`${name}: skipped invalid MCP config.`);
|
|
443
|
+
return [];
|
|
444
|
+
}
|
|
445
|
+
return [[safeName(name), entry]];
|
|
446
|
+
}));
|
|
447
|
+
}
|
|
448
|
+
function normalizeMcpEntry(value, warnings) {
|
|
449
|
+
const record = asRecord(value);
|
|
450
|
+
if (!record)
|
|
451
|
+
return undefined;
|
|
452
|
+
if (record.type === "local") {
|
|
453
|
+
const command = normalizeCommandArray(record.command, asArray(record.args));
|
|
454
|
+
if (!command.length)
|
|
455
|
+
return undefined;
|
|
456
|
+
return cleanObject({ type: "local", command, environment: stringMap(record.environment ?? record.env), enabled: record.enabled ?? true, timeout: positiveInt(record.timeout) });
|
|
457
|
+
}
|
|
458
|
+
if (record.type === "remote") {
|
|
459
|
+
const url = typeof record.url === "string" ? record.url : undefined;
|
|
460
|
+
if (!url)
|
|
461
|
+
return undefined;
|
|
462
|
+
return cleanObject({ type: "remote", url, headers: stringMap(record.headers), oauth: record.oauth, enabled: record.enabled ?? true, timeout: positiveInt(record.timeout) });
|
|
463
|
+
}
|
|
464
|
+
if (typeof record.url === "string")
|
|
465
|
+
return cleanObject({ type: "remote", url: record.url, headers: stringMap(record.headers), oauth: record.oauth, enabled: record.enabled ?? true, timeout: positiveInt(record.timeout) });
|
|
466
|
+
const command = normalizeCommandArray(record.command, asArray(record.args));
|
|
467
|
+
if (command.length) {
|
|
468
|
+
warnings.push("Converted command/args MCP entry to OpenCode local MCP config.");
|
|
469
|
+
return cleanObject({ type: "local", command, environment: stringMap(record.environment ?? record.env), enabled: record.enabled ?? true, timeout: positiveInt(record.timeout) });
|
|
470
|
+
}
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
function convertClaudeMcp(map, warnings) {
|
|
474
|
+
warnings.push("Claude mcpServers config requires explicit conversion approval.");
|
|
475
|
+
return Object.fromEntries(Object.entries(map).flatMap(([name, value]) => {
|
|
476
|
+
const entry = normalizeMcpEntry(value, warnings);
|
|
477
|
+
return entry ? [[safeName(name), entry]] : [];
|
|
478
|
+
}));
|
|
479
|
+
}
|
|
480
|
+
function patchJsonc(path, schema, mutate) {
|
|
481
|
+
const doc = readJsonc(path, schema);
|
|
482
|
+
mutate(doc);
|
|
483
|
+
writeTextAtomic(path, `${stringify(doc, null, 2)}\n`);
|
|
484
|
+
}
|
|
485
|
+
function setConfigEntries(target, entries, label) {
|
|
486
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
487
|
+
const existing = target[name];
|
|
488
|
+
if (existing !== undefined && stableJson(existing) !== stableJson(value)) {
|
|
489
|
+
throw new Error(`${label} "${name}" already exists with different config. Refusing to overwrite.`);
|
|
490
|
+
}
|
|
491
|
+
target[name] = value;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
function appendPluginSpec(doc, spec) {
|
|
495
|
+
const plugin = doc.plugin;
|
|
496
|
+
if (plugin !== undefined && !Array.isArray(plugin))
|
|
497
|
+
throw new Error("opencode config plugin field exists but is not an array. Refusing to overwrite.");
|
|
498
|
+
const list = Array.isArray(plugin) ? plugin : [];
|
|
499
|
+
const exists = list.some((entry) => Array.isArray(entry) ? entry[0] === spec : entry === spec);
|
|
500
|
+
if (!exists)
|
|
501
|
+
list.push(spec);
|
|
502
|
+
doc.plugin = list;
|
|
503
|
+
}
|
|
504
|
+
async function addRuntimeMcp(api, configs, scope) {
|
|
505
|
+
const client = api.client;
|
|
506
|
+
if (!client.mcp?.add)
|
|
507
|
+
return;
|
|
508
|
+
for (const [name, config] of Object.entries(configs)) {
|
|
509
|
+
try {
|
|
510
|
+
await client.mcp.add({ name, config, ...(scope.kind === "project" ? { directory: scope.path } : {}) });
|
|
511
|
+
if (client.mcp.connect)
|
|
512
|
+
await client.mcp.connect({ name, ...(scope.kind === "project" ? { directory: scope.path } : {}) }).catch(() => undefined);
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
// Persistent config is the source of truth. Runtime MCP add is best-effort because
|
|
516
|
+
// some servers require restart, auth, env, or credentials before connecting.
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function readJsonc(path, schema) {
|
|
521
|
+
if (!existsSync(path))
|
|
522
|
+
return { $schema: schema };
|
|
523
|
+
const parsed = parse(readFileSync(path, "utf8"));
|
|
524
|
+
if (!isRecord(parsed))
|
|
525
|
+
throw new Error(`${path} does not contain a JSON object.`);
|
|
526
|
+
if (!parsed.$schema)
|
|
527
|
+
parsed.$schema = schema;
|
|
528
|
+
return parsed;
|
|
529
|
+
}
|
|
530
|
+
function ensureRecord(doc, key) {
|
|
531
|
+
const existing = doc[key];
|
|
532
|
+
if (isRecord(existing))
|
|
533
|
+
return existing;
|
|
534
|
+
doc[key] = {};
|
|
535
|
+
return doc[key];
|
|
536
|
+
}
|
|
537
|
+
function writeNewFile(path, content) {
|
|
538
|
+
if (existsSync(path)) {
|
|
539
|
+
const current = readFileSync(path, "utf8");
|
|
540
|
+
if (normalizeNewlines(current) === normalizeNewlines(content))
|
|
541
|
+
return;
|
|
542
|
+
throw new Error(`${path} already exists with different content. Refusing to overwrite.`);
|
|
543
|
+
}
|
|
544
|
+
writeTextAtomic(path, content.endsWith("\n") ? content : `${content}\n`);
|
|
545
|
+
}
|
|
546
|
+
function writeTextAtomic(path, content) {
|
|
547
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
548
|
+
const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
549
|
+
writeFileSync(temp, content, "utf8");
|
|
550
|
+
renameSync(temp, path);
|
|
551
|
+
}
|
|
552
|
+
function opencodeConfigPath(scope) {
|
|
553
|
+
return scope.kind === "global" ? join(globalConfigDir(), "opencode.jsonc") : join(scope.path, ".opencode", "opencode.jsonc");
|
|
554
|
+
}
|
|
555
|
+
function tuiConfigPath(scope) {
|
|
556
|
+
return scope.kind === "global" ? join(globalConfigDir(), "tui.jsonc") : join(scope.path, ".opencode", "tui.jsonc");
|
|
557
|
+
}
|
|
558
|
+
function scopedDir(scope, kind) {
|
|
559
|
+
if (scope.kind === "global") {
|
|
560
|
+
if (kind === "theme")
|
|
561
|
+
return join(globalConfigDir(), "themes");
|
|
562
|
+
if (kind === "skill")
|
|
563
|
+
return join(globalConfigDir(), "skills");
|
|
564
|
+
return join(globalConfigDir(), `${kind}s`);
|
|
565
|
+
}
|
|
566
|
+
if (kind === "theme")
|
|
567
|
+
return join(scope.path, ".opencode", "themes");
|
|
568
|
+
if (kind === "skill")
|
|
569
|
+
return join(scope.path, ".opencode", "skills");
|
|
570
|
+
return join(scope.path, ".opencode", `${kind}s`);
|
|
571
|
+
}
|
|
572
|
+
function globalConfigDir() {
|
|
573
|
+
if (process.env.OPENCODE_CONFIG_DIR)
|
|
574
|
+
return process.env.OPENCODE_CONFIG_DIR;
|
|
575
|
+
if (process.env.XDG_CONFIG_HOME)
|
|
576
|
+
return join(process.env.XDG_CONFIG_HOME, "opencode");
|
|
577
|
+
if (process.platform === "win32" && process.env.APPDATA)
|
|
578
|
+
return join(process.env.APPDATA, "opencode");
|
|
579
|
+
return join(homedir(), ".config", "opencode");
|
|
580
|
+
}
|
|
581
|
+
function looksLikeMarkdownEntry(content, kind) {
|
|
582
|
+
if (kind === "agent")
|
|
583
|
+
return /---[\s\S]*description\s*:/m.test(content) || /---[\s\S]*mode\s*:/m.test(content);
|
|
584
|
+
return /\$ARGUMENTS|---[\s\S]*(description|agent|model|subtask)\s*:/m.test(content);
|
|
585
|
+
}
|
|
586
|
+
function looksLikeKnownPath(path, kind) {
|
|
587
|
+
const lower = path.toLowerCase();
|
|
588
|
+
return kind === "agent" ? /(^|\/)(agent|agents)\/.*\.md$/.test(lower) : /(^|\/)(command|commands)\/.*\.md$/.test(lower);
|
|
589
|
+
}
|
|
590
|
+
function ensureMarkdownFrontmatter(content, item, kind) {
|
|
591
|
+
if (/^---\s*\r?\n/.test(content))
|
|
592
|
+
return content;
|
|
593
|
+
const desc = item.description.replace(/\r?\n/g, " ").replace(/"/g, "'");
|
|
594
|
+
if (kind === "agent")
|
|
595
|
+
return `---\ndescription: "${desc || `Installed by OpenCode Souk from ${item.source}`}"\nmode: subagent\n---\n\n${content}`;
|
|
596
|
+
return `---\ndescription: "${desc || `Installed by OpenCode Souk from ${item.source}`}"\n---\n\n${content}`;
|
|
597
|
+
}
|
|
598
|
+
function normalizeThemeJson(content) {
|
|
599
|
+
const parsed = parse(content);
|
|
600
|
+
if (!isRecord(parsed) || !isRecord(parsed.theme))
|
|
601
|
+
throw new Error("Invalid theme JSON; expected object with theme field.");
|
|
602
|
+
if (!parsed.$schema)
|
|
603
|
+
parsed.$schema = THEME_SCHEMA;
|
|
604
|
+
return `${stringify(parsed, null, 2)}\n`;
|
|
605
|
+
}
|
|
606
|
+
function isThemeJsonText(content) {
|
|
607
|
+
try {
|
|
608
|
+
const parsed = parse(content);
|
|
609
|
+
return isRecord(parsed) && isRecord(parsed.theme);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
function skillNameFromText(content) {
|
|
616
|
+
return /^---[\s\S]*?\nname:\s*["']?([^"'\n]+)["']?/m.exec(content)?.[1]?.trim();
|
|
617
|
+
}
|
|
618
|
+
function relativeWithinSkill(path, root) {
|
|
619
|
+
if (!root)
|
|
620
|
+
return basename(path) || "SKILL.md";
|
|
621
|
+
const rel = path.startsWith(`${root}/`) ? path.slice(root.length + 1) : basename(path);
|
|
622
|
+
return rel || "SKILL.md";
|
|
623
|
+
}
|
|
624
|
+
function parseGitHubRepo(url) {
|
|
625
|
+
if (!url)
|
|
626
|
+
return undefined;
|
|
627
|
+
try {
|
|
628
|
+
const parsed = new URL(url);
|
|
629
|
+
if (parsed.hostname !== "github.com")
|
|
630
|
+
return undefined;
|
|
631
|
+
const parts = parsed.pathname.split("/").filter(Boolean);
|
|
632
|
+
const owner = parts[0];
|
|
633
|
+
const repo = parts[1]?.replace(/\.git$/, "");
|
|
634
|
+
if (!owner || !repo)
|
|
635
|
+
return undefined;
|
|
636
|
+
const treeIndex = parts.indexOf("tree");
|
|
637
|
+
const blobIndex = parts.indexOf("blob");
|
|
638
|
+
if (treeIndex >= 0)
|
|
639
|
+
return { owner, repo, branch: parts[treeIndex + 1], path: parts.slice(treeIndex + 2).join("/") || undefined };
|
|
640
|
+
if (blobIndex >= 0)
|
|
641
|
+
return { owner, repo, branch: parts[blobIndex + 1], path: parts.slice(blobIndex + 2).join("/") || undefined };
|
|
642
|
+
return { owner, repo, branch: undefined, path: undefined };
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
return undefined;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
async function fetchJson(url) {
|
|
649
|
+
const response = await fetch(url, { headers: { accept: "application/vnd.github+json" } });
|
|
650
|
+
if (!response.ok)
|
|
651
|
+
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
|
652
|
+
return await response.json();
|
|
653
|
+
}
|
|
654
|
+
async function fetchText(url) {
|
|
655
|
+
const response = await fetch(url);
|
|
656
|
+
if (!response.ok)
|
|
657
|
+
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
|
658
|
+
return await response.text();
|
|
659
|
+
}
|
|
660
|
+
function unsupported(item, reason) {
|
|
661
|
+
return { item, supported: false, actions: [], warnings: [`${item.name}: ${reason}`] };
|
|
662
|
+
}
|
|
663
|
+
function scopeLabel(scope) {
|
|
664
|
+
return scope.kind === "global" ? "global" : `project (${scope.path})`;
|
|
665
|
+
}
|
|
666
|
+
export { scopeLabel };
|
|
667
|
+
function inferNpmSpec(item) {
|
|
668
|
+
if (item.install?.type === "plugin" && item.install.spec)
|
|
669
|
+
return item.install.spec;
|
|
670
|
+
return undefined;
|
|
671
|
+
}
|
|
672
|
+
function stripKnownPrefix(path, kind) {
|
|
673
|
+
const normalized = path.replace(/\\/g, "/");
|
|
674
|
+
return normalized.replace(new RegExp(`^.*(?:${kind}|${kind}s)/`, "i"), "").replace(/\.md$/i, "");
|
|
675
|
+
}
|
|
676
|
+
function uniqueTargetPath(dir, file) {
|
|
677
|
+
return join(dir, safeName(basename(file, extname(file))) + extname(file));
|
|
678
|
+
}
|
|
679
|
+
function safeName(value) {
|
|
680
|
+
return value.toLowerCase().trim().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "item";
|
|
681
|
+
}
|
|
682
|
+
function normalizeCommandArray(command, args) {
|
|
683
|
+
if (Array.isArray(command))
|
|
684
|
+
return command.filter((part) => typeof part === "string" && part.length > 0);
|
|
685
|
+
if (typeof command === "string" && command.length > 0)
|
|
686
|
+
return [command, ...args.filter((part) => typeof part === "string")];
|
|
687
|
+
return [];
|
|
688
|
+
}
|
|
689
|
+
function asArray(value) {
|
|
690
|
+
return Array.isArray(value) ? value : [];
|
|
691
|
+
}
|
|
692
|
+
function stringMap(value) {
|
|
693
|
+
const record = asRecord(value);
|
|
694
|
+
if (!record)
|
|
695
|
+
return undefined;
|
|
696
|
+
return Object.fromEntries(Object.entries(record).flatMap(([key, raw]) => typeof raw === "string" ? [[key, raw]] : []));
|
|
697
|
+
}
|
|
698
|
+
function positiveInt(value) {
|
|
699
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
|
700
|
+
}
|
|
701
|
+
function cleanObject(record) {
|
|
702
|
+
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined));
|
|
703
|
+
}
|
|
704
|
+
function asRecord(value) {
|
|
705
|
+
return isRecord(value) ? value : undefined;
|
|
706
|
+
}
|
|
707
|
+
function isRecord(value) {
|
|
708
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
709
|
+
}
|
|
710
|
+
function normalizeNewlines(value) {
|
|
711
|
+
return value.replace(/\r\n/g, "\n");
|
|
712
|
+
}
|
|
713
|
+
function stableJson(value) {
|
|
714
|
+
if (Array.isArray(value))
|
|
715
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
716
|
+
if (isRecord(value))
|
|
717
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
718
|
+
return JSON.stringify(value);
|
|
719
|
+
}
|