Resolve ProvidersPage and lifecycle conflicts with custom providers and AppImage ARGV0 stripping. Address review follow-ups: OAuth index helper + tests, single auth-methods load trigger, shared Google env-alias module with VS Code parity coverage, and deferred restart for custom provider upsert. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
798 lines
30 KiB
TypeScript
798 lines
30 KiB
TypeScript
import * as vscode from 'vscode';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import {
|
|
createAgent,
|
|
createCommand,
|
|
createSnippet,
|
|
deleteAgent,
|
|
deleteCommand,
|
|
deleteSnippet,
|
|
getAgentSources,
|
|
getCommandSources,
|
|
getSnippet,
|
|
updateAgent,
|
|
updateCommand,
|
|
updateSnippet,
|
|
type AgentScope,
|
|
type CommandScope,
|
|
AGENT_SCOPE,
|
|
COMMAND_SCOPE,
|
|
discoverSkills,
|
|
mergeDiscoveredSkills,
|
|
getSkillSources,
|
|
createSkill,
|
|
updateSkill,
|
|
deleteSkill,
|
|
renameSkill,
|
|
isManagedSkillPath,
|
|
readSkillSupportingFile,
|
|
writeSkillSupportingFile,
|
|
deleteSkillSupportingFile,
|
|
type SkillScope,
|
|
type DiscoveredSkill,
|
|
SKILL_SCOPE,
|
|
listMcpConfigs,
|
|
listPluginDirFiles,
|
|
listPluginEntries,
|
|
getPluginEntry,
|
|
createPluginEntry,
|
|
updatePluginEntry,
|
|
deletePluginEntry,
|
|
readPluginDirFile,
|
|
writePluginDirFile,
|
|
deletePluginDirFile,
|
|
queryPluginRegistry,
|
|
listSnippets,
|
|
getMcpConfig,
|
|
createMcpConfig,
|
|
updateMcpConfig,
|
|
deleteMcpConfig,
|
|
expandSnippets,
|
|
type SnippetScope,
|
|
} from './opencodeConfig';
|
|
import {
|
|
getSkillsCatalog,
|
|
scanSkillsRepository as scanSkillsRepositoryFromGit,
|
|
installSkillsFromRepository as installSkillsFromGit,
|
|
type SkillsCatalogSourceConfig,
|
|
} from './skillsCatalog';
|
|
import { buildDeferredRestartResponse } from './config-mutation-response';
|
|
import type { BridgeContext, BridgeResponse } from './bridge';
|
|
|
|
type BridgeMessageInput = {
|
|
id: string;
|
|
type: string;
|
|
payload?: unknown;
|
|
};
|
|
|
|
type ConfigRuntimeDeps = {
|
|
readSettings: (ctx?: BridgeContext) => Record<string, unknown>;
|
|
persistSettings: (changes: Record<string, unknown>, ctx?: BridgeContext) => Promise<Record<string, unknown>>;
|
|
readMagicPromptOverrides: () => { version: number; overrides: Record<string, string> };
|
|
saveMagicPromptOverride: (id: string, text: string) => Promise<{ version: number; overrides: Record<string, string> }>;
|
|
resetMagicPromptOverride: (id: string) => Promise<{ version: number; overrides: Record<string, string> }>;
|
|
resetAllMagicPromptOverrides: () => Promise<{ version: number; overrides: Record<string, string> }>;
|
|
fetchOpenCodeSkillsFromApi: (ctx: BridgeContext | undefined, workingDirectory?: string) => Promise<DiscoveredSkill[] | null>;
|
|
clientReloadDelayMs: number;
|
|
};
|
|
|
|
const AGENTS_MD_PATH = path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md');
|
|
const MAX_BEHAVIOR_PROMPT_SIZE = 1024 * 1024;
|
|
|
|
const resolveWorkingDirectory = (ctx: BridgeContext | undefined, directory?: string): string | undefined => (
|
|
(typeof directory === 'string' && directory.trim())
|
|
? directory.trim()
|
|
: (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath)
|
|
);
|
|
|
|
const pluginMutationPayload = (label: string) => buildDeferredRestartResponse(
|
|
`${label}. Restart OpenCode to apply.`,
|
|
);
|
|
|
|
const parseSkillsCatalogSources = (settings: Record<string, unknown>): SkillsCatalogSourceConfig[] => {
|
|
const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs;
|
|
if (!Array.isArray(rawCatalogs)) {
|
|
return [];
|
|
}
|
|
|
|
return rawCatalogs
|
|
.map((entry) => {
|
|
if (!entry || typeof entry !== 'object') return null;
|
|
const candidate = entry as Record<string, unknown>;
|
|
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
|
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
|
|
const source = typeof candidate.source === 'string' ? candidate.source.trim() : '';
|
|
const subpath = typeof candidate.subpath === 'string' ? candidate.subpath.trim() : '';
|
|
if (!id || !label || !source) return null;
|
|
const normalized: SkillsCatalogSourceConfig = {
|
|
id,
|
|
label,
|
|
description: source,
|
|
source,
|
|
...(subpath ? { defaultSubpath: subpath } : {}),
|
|
};
|
|
return normalized;
|
|
})
|
|
.filter((value): value is SkillsCatalogSourceConfig => value !== null);
|
|
};
|
|
|
|
const resolveDiscoveredSkills = async (
|
|
deps: ConfigRuntimeDeps,
|
|
ctx: BridgeContext | undefined,
|
|
workingDirectory?: string,
|
|
): Promise<DiscoveredSkill[]> => mergeDiscoveredSkills(
|
|
(await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || [],
|
|
discoverSkills(workingDirectory),
|
|
);
|
|
|
|
export async function handleConfigBridgeMessage(
|
|
message: BridgeMessageInput,
|
|
ctx: BridgeContext | undefined,
|
|
deps: ConfigRuntimeDeps,
|
|
): Promise<BridgeResponse | null> {
|
|
const { id, type, payload } = message;
|
|
|
|
switch (type) {
|
|
case 'api:config/opencode-resolution:get': {
|
|
const debugInfo = ctx?.manager?.getDebugInfo();
|
|
const configuredFromWorkspace = vscode.workspace.getConfiguration('openchamber').get<string>('opencodeBinary');
|
|
const configured = typeof configuredFromWorkspace === 'string' && configuredFromWorkspace.trim().length > 0
|
|
? configuredFromWorkspace.trim()
|
|
: null;
|
|
const resolved = debugInfo?.cliPath ?? null;
|
|
const source = (() => {
|
|
if (!resolved) return null;
|
|
if (configured && configured === resolved) return 'settings';
|
|
const envBinary = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : '';
|
|
if (envBinary && envBinary === resolved) return 'env';
|
|
return 'path';
|
|
})();
|
|
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: {
|
|
configured,
|
|
resolved,
|
|
resolvedDir: resolved ? path.dirname(resolved) : null,
|
|
source,
|
|
detectedNow: resolved,
|
|
detectedSourceNow: source,
|
|
shim: null,
|
|
viaWsl: false,
|
|
wslBinary: null,
|
|
wslPath: null,
|
|
wslDistro: null,
|
|
node: process.execPath || null,
|
|
bun: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
case 'api:config/settings:get': {
|
|
const settings = deps.readSettings(ctx);
|
|
return { id, type, success: true, data: settings };
|
|
}
|
|
|
|
case 'api:config/settings:save': {
|
|
const changes = (payload as Record<string, unknown>) || {};
|
|
const updated = await deps.persistSettings(changes, ctx);
|
|
return { id, type, success: true, data: updated };
|
|
}
|
|
|
|
case 'api:behavior/agents-md:get': {
|
|
try {
|
|
const content = await fs.promises.readFile(AGENTS_MD_PATH, 'utf8');
|
|
return { id, type, success: true, data: { content, exists: true } };
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
|
return { id, type, success: true, data: { content: '', exists: false } };
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
case 'api:behavior/agents-md:save': {
|
|
const request = (payload || {}) as { content?: unknown };
|
|
const content = typeof request.content === 'string' ? request.content : '';
|
|
if (content.length > MAX_BEHAVIOR_PROMPT_SIZE) {
|
|
return { id, type, success: false, error: `Content exceeds maximum size of ${MAX_BEHAVIOR_PROMPT_SIZE} bytes` };
|
|
}
|
|
await fs.promises.mkdir(path.dirname(AGENTS_MD_PATH), { recursive: true });
|
|
await fs.promises.writeFile(AGENTS_MD_PATH, content, 'utf8');
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: buildDeferredRestartResponse('AGENTS.md saved. Restart OpenCode to apply.'),
|
|
};
|
|
}
|
|
|
|
case 'api:magic-prompts:get': {
|
|
return { id, type, success: true, data: deps.readMagicPromptOverrides() };
|
|
}
|
|
|
|
case 'api:magic-prompts:save': {
|
|
const request = (payload || {}) as { id?: string; text?: string };
|
|
const promptId = typeof request.id === 'string' ? request.id : '';
|
|
if (!promptId) {
|
|
return { id, type, success: false, error: 'Prompt id is required' };
|
|
}
|
|
if (typeof request.text !== 'string') {
|
|
return { id, type, success: false, error: 'Prompt text is required' };
|
|
}
|
|
const data = await deps.saveMagicPromptOverride(promptId, request.text);
|
|
return { id, type, success: true, data };
|
|
}
|
|
|
|
case 'api:magic-prompts:reset': {
|
|
const request = (payload || {}) as { id?: string };
|
|
const promptId = typeof request.id === 'string' ? request.id : '';
|
|
if (!promptId) {
|
|
return { id, type, success: false, error: 'Prompt id is required' };
|
|
}
|
|
const data = await deps.resetMagicPromptOverride(promptId);
|
|
return { id, type, success: true, data };
|
|
}
|
|
|
|
case 'api:magic-prompts:reset-all': {
|
|
const data = await deps.resetAllMagicPromptOverrides();
|
|
return { id, type, success: true, data };
|
|
}
|
|
|
|
case 'api:config/reload': {
|
|
await ctx?.manager?.restart();
|
|
return { id, type, success: true, data: { restarted: true } };
|
|
}
|
|
|
|
case 'api:config/agents': {
|
|
const { method, name, body, directory } = (payload || {}) as {
|
|
method?: string;
|
|
name?: string;
|
|
body?: Record<string, unknown>;
|
|
directory?: string;
|
|
};
|
|
const agentName = typeof name === 'string' ? name.trim() : '';
|
|
if (!agentName) {
|
|
return { id, type, success: false, error: 'Agent name is required' };
|
|
}
|
|
|
|
const workingDirectory = resolveWorkingDirectory(ctx, directory);
|
|
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
|
|
|
if (normalizedMethod === 'GET') {
|
|
const sources = getAgentSources(agentName, workingDirectory);
|
|
const scope = sources.md.exists
|
|
? sources.md.scope
|
|
: (sources.json.exists ? sources.json.scope : null);
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: { name: agentName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists },
|
|
};
|
|
}
|
|
|
|
if (normalizedMethod === 'POST') {
|
|
const scopeValue = body?.scope as string | undefined;
|
|
const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined;
|
|
createAgent(agentName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: buildDeferredRestartResponse(`Agent ${agentName} created successfully. Restart OpenCode to apply.`),
|
|
};
|
|
}
|
|
|
|
if (normalizedMethod === 'PATCH') {
|
|
updateAgent(agentName, (body || {}) as Record<string, unknown>, workingDirectory);
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: buildDeferredRestartResponse(`Agent ${agentName} updated successfully. Restart OpenCode to apply.`),
|
|
};
|
|
}
|
|
|
|
if (normalizedMethod === 'DELETE') {
|
|
const scopeValue = body?.scope as string | undefined;
|
|
const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined;
|
|
deleteAgent(agentName, workingDirectory, scope);
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: buildDeferredRestartResponse(`Agent ${agentName} deleted successfully. Restart OpenCode to apply.`),
|
|
};
|
|
}
|
|
|
|
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
|
}
|
|
|
|
case 'api:config/commands': {
|
|
const { method, name, body, directory } = (payload || {}) as {
|
|
method?: string;
|
|
name?: string;
|
|
body?: Record<string, unknown>;
|
|
directory?: string;
|
|
};
|
|
const commandName = typeof name === 'string' ? name.trim() : '';
|
|
if (!commandName) {
|
|
return { id, type, success: false, error: 'Command name is required' };
|
|
}
|
|
|
|
const workingDirectory = resolveWorkingDirectory(ctx, directory);
|
|
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
|
|
|
if (normalizedMethod === 'GET') {
|
|
const sources = getCommandSources(commandName, workingDirectory);
|
|
const scope = sources.md.exists
|
|
? sources.md.scope
|
|
: (sources.json.exists ? sources.json.scope : null);
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: { name: commandName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists },
|
|
};
|
|
}
|
|
|
|
if (normalizedMethod === 'POST') {
|
|
const scopeValue = body?.scope as string | undefined;
|
|
const scope: CommandScope | undefined = scopeValue === 'project' ? COMMAND_SCOPE.PROJECT : scopeValue === 'user' ? COMMAND_SCOPE.USER : undefined;
|
|
createCommand(commandName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: buildDeferredRestartResponse(`Command ${commandName} created successfully. Restart OpenCode to apply.`),
|
|
};
|
|
}
|
|
|
|
if (normalizedMethod === 'PATCH') {
|
|
updateCommand(commandName, (body || {}) as Record<string, unknown>, workingDirectory);
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: buildDeferredRestartResponse(`Command ${commandName} updated successfully. Restart OpenCode to apply.`),
|
|
};
|
|
}
|
|
|
|
if (normalizedMethod === 'DELETE') {
|
|
deleteCommand(commandName, workingDirectory);
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: buildDeferredRestartResponse(`Command ${commandName} deleted successfully. Restart OpenCode to apply.`),
|
|
};
|
|
}
|
|
|
|
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
|
}
|
|
|
|
case 'api:config/mcp': {
|
|
const { method, name, body, directory } = (payload || {}) as {
|
|
method?: string;
|
|
name?: string;
|
|
body?: Record<string, unknown>;
|
|
directory?: string;
|
|
};
|
|
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
|
const mcpName = typeof name === 'string' ? name.trim() : '';
|
|
const workingDirectory = resolveWorkingDirectory(ctx, directory);
|
|
|
|
if (normalizedMethod === 'GET' && !mcpName) {
|
|
const configs = listMcpConfigs(workingDirectory);
|
|
return { id, type, success: true, data: configs };
|
|
}
|
|
|
|
if (!mcpName) {
|
|
return { id, type, success: false, error: 'MCP server name is required' };
|
|
}
|
|
|
|
if (normalizedMethod === 'GET') {
|
|
const config = getMcpConfig(mcpName, workingDirectory);
|
|
if (!config) {
|
|
return { id, type, success: false, error: `MCP server "${mcpName}" not found` };
|
|
}
|
|
return { id, type, success: true, data: config };
|
|
}
|
|
|
|
if (normalizedMethod === 'POST') {
|
|
const scope = body?.scope as 'user' | 'project' | undefined;
|
|
createMcpConfig(mcpName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: buildDeferredRestartResponse(`MCP server "${mcpName}" created. Restart OpenCode to apply.`),
|
|
};
|
|
}
|
|
|
|
if (normalizedMethod === 'PATCH') {
|
|
updateMcpConfig(mcpName, (body || {}) as Record<string, unknown>, workingDirectory);
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: buildDeferredRestartResponse(`MCP server "${mcpName}" updated. Restart OpenCode to apply.`),
|
|
};
|
|
}
|
|
|
|
if (normalizedMethod === 'DELETE') {
|
|
deleteMcpConfig(mcpName, workingDirectory);
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: buildDeferredRestartResponse(`MCP server "${mcpName}" deleted. Restart OpenCode to apply.`),
|
|
};
|
|
}
|
|
|
|
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
|
}
|
|
|
|
case 'api:config/plugins': {
|
|
const { method, target, pluginId, body, directory, specs, refresh } = (payload || {}) as {
|
|
method?: string;
|
|
target?: 'list' | 'registry' | 'entry' | 'file';
|
|
pluginId?: string;
|
|
body?: Record<string, unknown>;
|
|
directory?: string;
|
|
specs?: string[];
|
|
refresh?: boolean;
|
|
};
|
|
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
|
const workingDirectory = resolveWorkingDirectory(ctx, directory);
|
|
|
|
if ((target === 'list' || !target) && normalizedMethod === 'GET') {
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: {
|
|
entries: listPluginEntries(workingDirectory),
|
|
files: listPluginDirFiles(workingDirectory),
|
|
},
|
|
};
|
|
}
|
|
|
|
if (target === 'registry' && normalizedMethod === 'GET') {
|
|
const data = await queryPluginRegistry(Array.isArray(specs) ? specs : [], {
|
|
refresh: refresh === true,
|
|
workingDirectory,
|
|
});
|
|
return { id, type, success: true, data };
|
|
}
|
|
|
|
if (target === 'entry') {
|
|
if (normalizedMethod === 'GET') {
|
|
if (!pluginId) return { id, type, success: false, error: 'Plugin entry id is required' };
|
|
const entry = getPluginEntry(pluginId, workingDirectory);
|
|
if (!entry) return { id, type, success: false, error: 'Plugin entry not found' };
|
|
return { id, type, success: true, data: entry };
|
|
}
|
|
if (normalizedMethod === 'POST') {
|
|
createPluginEntry(body || {}, workingDirectory);
|
|
} else if (normalizedMethod === 'PATCH') {
|
|
if (!pluginId) return { id, type, success: false, error: 'Plugin entry id is required' };
|
|
updatePluginEntry(pluginId, body || {}, workingDirectory);
|
|
} else if (normalizedMethod === 'DELETE') {
|
|
if (!pluginId) return { id, type, success: false, error: 'Plugin entry id is required' };
|
|
deletePluginEntry(pluginId, workingDirectory);
|
|
} else {
|
|
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
|
}
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: pluginMutationPayload('Plugin entry changed'),
|
|
};
|
|
}
|
|
|
|
if (target === 'file') {
|
|
if (normalizedMethod === 'GET') {
|
|
if (!pluginId) return { id, type, success: false, error: 'Plugin file id is required' };
|
|
const file = readPluginDirFile(pluginId, workingDirectory);
|
|
if (!file) return { id, type, success: false, error: 'Plugin file not found' };
|
|
return { id, type, success: true, data: file };
|
|
}
|
|
if (normalizedMethod === 'POST') {
|
|
writePluginDirFile(body || {}, workingDirectory);
|
|
} else if (normalizedMethod === 'PUT') {
|
|
if (!pluginId) return { id, type, success: false, error: 'Plugin file id is required' };
|
|
const existing = readPluginDirFile(pluginId, workingDirectory);
|
|
if (!existing) return { id, type, success: false, error: 'Plugin file not found' };
|
|
writePluginDirFile({ fileName: existing.fileName, scope: existing.scope, content: body?.content }, workingDirectory, { overwrite: true });
|
|
} else if (normalizedMethod === 'DELETE') {
|
|
if (!pluginId) return { id, type, success: false, error: 'Plugin file id is required' };
|
|
deletePluginDirFile(pluginId, workingDirectory);
|
|
} else {
|
|
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
|
}
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: pluginMutationPayload('Plugin file changed'),
|
|
};
|
|
}
|
|
|
|
return { id, type, success: false, error: 'Unsupported plugin config request' };
|
|
}
|
|
|
|
case 'api:config/snippets': {
|
|
const { method, name, body, directory } = (payload || {}) as {
|
|
method?: string;
|
|
name?: string;
|
|
body?: Record<string, unknown>;
|
|
directory?: string;
|
|
};
|
|
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
|
const snippetName = typeof name === 'string' ? name.trim() : '';
|
|
const workingDirectory = resolveWorkingDirectory(ctx, directory);
|
|
|
|
if (normalizedMethod === 'GET' && !snippetName) {
|
|
return { id, type, success: true, data: listSnippets(workingDirectory) };
|
|
}
|
|
|
|
if (normalizedMethod === 'POST' && !snippetName) {
|
|
return { id, type, success: true, data: { text: expandSnippets(typeof body?.text === 'string' ? body.text : '', workingDirectory) } };
|
|
}
|
|
|
|
if (!snippetName) {
|
|
return { id, type, success: false, error: 'Snippet name is required' };
|
|
}
|
|
|
|
if (normalizedMethod === 'GET') {
|
|
const snippet = getSnippet(snippetName, workingDirectory);
|
|
if (!snippet) return { id, type, success: false, error: `Snippet "${snippetName}" not found` };
|
|
return { id, type, success: true, data: snippet };
|
|
}
|
|
|
|
if (normalizedMethod === 'POST') {
|
|
const scope = body?.scope === 'project' ? 'project' : 'global';
|
|
const snippet = createSnippet(snippetName, (body || {}) as Record<string, unknown>, workingDirectory, scope as SnippetScope);
|
|
return { id, type, success: true, data: { success: true, snippet } };
|
|
}
|
|
|
|
if (normalizedMethod === 'PATCH') {
|
|
const snippet = updateSnippet(snippetName, (body || {}) as Record<string, unknown>, workingDirectory);
|
|
return { id, type, success: true, data: { success: true, snippet } };
|
|
}
|
|
|
|
if (normalizedMethod === 'DELETE') {
|
|
deleteSnippet(snippetName, workingDirectory);
|
|
return { id, type, success: true, data: { success: true } };
|
|
}
|
|
|
|
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
|
}
|
|
|
|
case 'api:config/skills': {
|
|
const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown> };
|
|
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
|
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
|
|
|
if (!name && normalizedMethod === 'GET') {
|
|
const skills = await resolveDiscoveredSkills(deps, ctx, workingDirectory);
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: {
|
|
skills: skills.map((skill) => ({
|
|
...skill,
|
|
renamable: Boolean(
|
|
skill.path
|
|
&& skill.path !== '<built-in>'
|
|
&& isManagedSkillPath(skill.path, workingDirectory)
|
|
),
|
|
})),
|
|
},
|
|
};
|
|
}
|
|
|
|
const skillName = typeof name === 'string' ? name.trim() : '';
|
|
if (!skillName) {
|
|
return { id, type, success: false, error: 'Skill name is required' };
|
|
}
|
|
|
|
if (normalizedMethod === 'GET') {
|
|
const discoveredSkill = (await resolveDiscoveredSkills(deps, ctx, workingDirectory))
|
|
.find((skill) => skill.name === skillName);
|
|
const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null);
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: { name: skillName, sources, scope: sources.md.scope, source: sources.md.source },
|
|
};
|
|
}
|
|
|
|
if (normalizedMethod === 'POST') {
|
|
const scopeValue = body?.scope as string | undefined;
|
|
const sourceValue = body?.source as string | undefined;
|
|
const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined;
|
|
const normalizedSource = sourceValue === 'agents' ? 'agents' : 'opencode';
|
|
createSkill(skillName, { ...(body || {}), source: normalizedSource } as Record<string, unknown>, workingDirectory, scope);
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: buildDeferredRestartResponse(`Skill ${skillName} created successfully. Restart OpenCode to apply.`),
|
|
};
|
|
}
|
|
|
|
if (normalizedMethod === 'PATCH') {
|
|
if (typeof body?.renameTo === 'string') {
|
|
const newName = body.renameTo.trim();
|
|
renameSkill(skillName, newName, workingDirectory);
|
|
await ctx?.manager?.restart();
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: {
|
|
success: true,
|
|
name: newName,
|
|
requiresReload: true,
|
|
message: `Skill renamed to ${newName} successfully. Reloading interface…`,
|
|
reloadDelayMs: deps.clientReloadDelayMs,
|
|
},
|
|
};
|
|
}
|
|
|
|
updateSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory);
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: buildDeferredRestartResponse(`Skill ${skillName} updated successfully. Restart OpenCode to apply.`),
|
|
};
|
|
}
|
|
|
|
if (normalizedMethod === 'DELETE') {
|
|
deleteSkill(skillName, workingDirectory);
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: buildDeferredRestartResponse(`Skill ${skillName} deleted successfully. Restart OpenCode to apply.`),
|
|
};
|
|
}
|
|
|
|
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
|
}
|
|
|
|
case 'api:config/skills:catalog': {
|
|
const refresh = Boolean((payload as { refresh?: boolean } | undefined)?.refresh);
|
|
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
|
const settings = deps.readSettings(ctx);
|
|
const additionalSources = parseSkillsCatalogSources(settings);
|
|
const installedSkills = await resolveDiscoveredSkills(deps, ctx, workingDirectory);
|
|
const data = await getSkillsCatalog(workingDirectory, refresh, additionalSources, installedSkills);
|
|
return { id, type, success: true, data };
|
|
}
|
|
|
|
case 'api:config/skills:scan': {
|
|
const body = (payload || {}) as { source?: string; subpath?: string; gitIdentityId?: string };
|
|
const data = await scanSkillsRepositoryFromGit({
|
|
source: String(body.source || ''),
|
|
subpath: body.subpath,
|
|
});
|
|
return { id, type, success: true, data };
|
|
}
|
|
|
|
case 'api:config/skills:install': {
|
|
const body = (payload || {}) as {
|
|
source?: string;
|
|
subpath?: string;
|
|
scope?: 'user' | 'project';
|
|
targetSource?: 'opencode' | 'agents';
|
|
selections?: Array<{ skillDir: string }>;
|
|
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
|
|
conflictDecisions?: Record<string, 'skip' | 'overwrite'>;
|
|
};
|
|
|
|
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
|
|
|
const data = await installSkillsFromGit({
|
|
source: String(body.source || ''),
|
|
subpath: body.subpath,
|
|
scope: body.scope === 'project' ? 'project' : 'user',
|
|
targetSource: body.targetSource === 'agents' ? 'agents' : 'opencode',
|
|
workingDirectory: body.scope === 'project' ? workingDirectory : undefined,
|
|
selections: Array.isArray(body.selections) ? body.selections : [],
|
|
conflictPolicy: body.conflictPolicy,
|
|
conflictDecisions: body.conflictDecisions,
|
|
});
|
|
|
|
if (data.ok) {
|
|
const installed = data.installed || [];
|
|
const skipped = data.skipped || [];
|
|
const requiresRestart = installed.length > 0;
|
|
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: {
|
|
ok: true,
|
|
installed,
|
|
skipped,
|
|
...(requiresRestart
|
|
? buildDeferredRestartResponse('Skills installed successfully. Restart OpenCode to apply.')
|
|
: {
|
|
requiresReload: false,
|
|
message: 'No skills were installed',
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
return { id, type, success: true, data };
|
|
}
|
|
|
|
case 'api:config/skills/files': {
|
|
const { method, name, filePath, content } = (payload || {}) as {
|
|
method?: string;
|
|
name?: string;
|
|
filePath?: string;
|
|
content?: string;
|
|
};
|
|
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
|
|
|
const skillName = typeof name === 'string' ? name.trim() : '';
|
|
if (!skillName) {
|
|
return { id, type, success: false, error: 'Skill name is required' };
|
|
}
|
|
|
|
const relativePath = typeof filePath === 'string' ? filePath.trim() : '';
|
|
if (!relativePath) {
|
|
return { id, type, success: false, error: 'File path is required' };
|
|
}
|
|
|
|
const discoveredSkill = (await resolveDiscoveredSkills(deps, ctx, workingDirectory))
|
|
.find((skill) => skill.name === skillName);
|
|
const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null);
|
|
if (!sources.md.dir) {
|
|
return { id, type, success: false, error: `Skill "${skillName}" not found` };
|
|
}
|
|
|
|
const skillDir = sources.md.dir;
|
|
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
|
|
|
if (normalizedMethod === 'GET') {
|
|
const fileContent = readSkillSupportingFile(skillDir, relativePath);
|
|
if (fileContent === null) {
|
|
return { id, type, success: false, error: `File "${relativePath}" not found in skill "${skillName}"` };
|
|
}
|
|
return { id, type, success: true, data: { content: fileContent } };
|
|
}
|
|
|
|
if (normalizedMethod === 'PUT') {
|
|
writeSkillSupportingFile(skillDir, relativePath, content || '');
|
|
return { id, type, success: true, data: { success: true } };
|
|
}
|
|
|
|
if (normalizedMethod === 'DELETE') {
|
|
deleteSkillSupportingFile(skillDir, relativePath);
|
|
return { id, type, success: true, data: { success: true } };
|
|
}
|
|
|
|
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
|
}
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|