* Group settings navigation menu Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Revert "Group settings navigation menu" This reverts commit 5983a4e82074b8dab1084af1cadd803ba28ea65d. * Standardize settings layout feedback Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Fix settings save status timer typing Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Retain settings save status Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Report color mode save state Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Match Appearance settings to two-column layout Rebuild Appearance into Color mode & Theme, Localization, and Density & type sections with responsive two-column grids, consistent section headers, page description, and green save status. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Migrate settings pages to SettingsPageLayout and SettingsSection Replace ScrollableOverlay/max-w-3xl shells with the shared settings layout primitives across entity and static settings pages, normalize section headers, and add settings.page.behavior.description locales. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Unify remaining settings pages on shared section chrome Wire Appearance, Projects, and Remote Instances through SettingsSection/SettingsPageLayout so every settings surface shares the same header, divider, and page shell treatment. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Align settings UI with OpenChamber design system kit Apply kit geometry and control specs: 840px content width, 32/48 padding, fixed 260/280 sidebars, radius/spacing tokens, settings select height, stepper dimensions, and shared field/link typography. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Migrate OpenChamberVisualSettings to shared settings primitives Replace ad-hoc radio/checkbox/chip/field layouts with SettingsSection shared chrome for Appearance, Density, Navigation, Chat/behavior, and Privacy while preserving handlers and data-settings-item anchors. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Migrate settings pages to shared field/checkbox/radio primitives Unify Defaults, Notifications, Behavior, Git, Session Retention, Passkeys, OpenCode CLI, Commands, About, Keyboard Shortcuts, and Desktop Network on SettingsFieldRow / SettingsCheckboxRow / SettingsRadioGroup / SettingsChipGroup for consistent grid, spacing, and DRY layout. Also remove the GitPage double SettingsSection wrap around GitHubSettings. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Unify remaining settings pages onto shared field primitives Migrate Agents, Snippets, Skills, Usage, MCP identity rows and selects to SettingsFieldRow/CheckboxRow/ChipGroup and SETTINGS_SELECT_*; align page titles; light-touch Voice/Tunnel/Providers/Plugins without rewriting complex OAuth, permissions, or tunnel flows. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Polish settings section dividers and transport helper text Slightly stronger section borders for clearer group separation, and keep message-stream transport description under the chip control. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Fix settings grid alignment, control heights, and Chat section titles Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Tighten settings grid: flat Chat 2x2 and full-width stacked selects Message options use a flat two-column grid so row headers share a baseline. Stacked selects fill their column; field-row selects keep a fixed sm:w-56 width. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Align mixed settings controls to shared FieldRow/CheckboxRow grid Separate FieldRows from CheckboxRows with SettingsInset, move enum radios into ControlGroups, and convert misplaced StackedFields to full-width FieldRows so left edges no longer clash. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Translate Behavior response-style preset labels for es and pl Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Group settings nav into categories and improve icons/order Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Fix settings layout: fewer hrs, shared control widths, chat transport section Remove SettingsInset top borders, align App install/Density controls to full cluster width, give Message Stream Transport its own Chat section, and fold Sessions Small Model into the first section to cut extra dividers. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Align Voice and MCP settings labels with shared heading classes Swap form field labels to SETTINGS_FIELD_LABEL_CLASS and use SettingsGroupTitle for MCP control-group headings (manual auth fallback, request headers). Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Differentiate settings heading levels by context Page titles are larger and quieter than section titles; group and field labels use dedicated shared classes so hierarchy is consistent across settings surfaces without ad-hoc typography mixes. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Use shared settings title classes in SettingsView home Wire home and unavailable headings through the shared L1/L2 class constants so they stay aligned with SettingsPageLayout. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Keep settings number steppers from stretching +/- buttons Density & type NumberInputs no longer flex-grow across the row, and NumberInput locks minus/plus to fixed width so the plus side cannot inflate when the control is placed in a full-width cluster. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Place spacing and input-bar offset on their own row Density & type now lays out as font families, then font sizes, then Spacing Density / Input Bar Offset on the row below. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Regroup settings nav and improve Voice layout Drop Advanced/Usage/Git nav categories: Voice and About under Interface, Usage under OpenCode, Git under Workspace. Voice provider chips and STT model cards use shared settings primitives with roomier spacing and a two-column model grid. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * Space out chat feature groups in visual settings * Polish settings: save-state wiring, container-query layouts, unified dropdown triggers - Wire shared save indicator into Behavior page saves and git identity CRUD - Convert settings layout primitives and page rows from viewport to container queries so narrow panes stack instead of clipping - Unify custom dropdown triggers (model/agent/provider pickers) and remaining page selects on the settings control size - Bump global radius scale by 1px; align variant input with select size - Migrate stray raw controls (MCP OAuth checkbox, worktree remove button, git identity dialog rows, skills dialog labels) onto shared primitives - Make settings nav items span full sidebar width; trim nav to 240px * Add General settings page, regroup nav, cap control widths, promote chat feature headers * Mobile settings nav: plain background and touch-sized rows * Hide secondary settings descriptions behind clickable info hints * Move quota credentials to Usage, navigation settings to General, rename External Tunnel * Quiet settings save indicator: silent success, delayed spinner, visible errors * Rewrite settings-ui-patterns skill around shared primitives and refactored conventions * Remove settings starter page; open last visited page, defaulting to General * Settings polish: spacing, control sizes, trigger widths, readable model names, device dates * Centralize dropdown trigger chrome, settings nav polish, mobile-only input bar offset * Fix global line-height regression, auto-hide first-section divider, shortcut row spacing * Global line-height 1.45, align scheduled tasks header button with select * Hide editor toolbar and About in VS Code, animate chat render preview outside desktop dialog * Rebuild agent tool permissions on source-of-truth model Edit the agent's own permission map verbatim (inherit vs explicit actions, pattern rules only for pattern-capable keys), save permission-only, drop the server-side non-wildcard re-merge that resurrected deleted rules, and surface session-granted rules as read-only. * Agents model parameters polish: row spacing, variant dropdown, unified widths, dash for unset numbers --------- Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
644 lines
21 KiB
JavaScript
644 lines
21 KiB
JavaScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import {
|
|
CONFIG_FILE,
|
|
AGENT_DIR,
|
|
AGENT_SCOPE,
|
|
ensureDirs,
|
|
parseMdFile,
|
|
writeMdFile,
|
|
readConfigLayers,
|
|
readConfigFile,
|
|
writeConfig,
|
|
getJsonEntrySource,
|
|
getJsonWriteTarget,
|
|
isPromptFileReference,
|
|
resolvePromptFilePath,
|
|
writePromptFile,
|
|
} from './shared.js';
|
|
|
|
// ============== AGENT SCOPE HELPERS ==============
|
|
|
|
/**
|
|
* Ensure project-level agent directory exists
|
|
*/
|
|
function ensureProjectAgentDir(workingDirectory) {
|
|
const projectAgentDir = path.join(workingDirectory, '.opencode', 'agents');
|
|
if (!fs.existsSync(projectAgentDir)) {
|
|
fs.mkdirSync(projectAgentDir, { recursive: true });
|
|
}
|
|
const legacyProjectAgentDir = path.join(workingDirectory, '.opencode', 'agent');
|
|
if (!fs.existsSync(legacyProjectAgentDir)) {
|
|
fs.mkdirSync(legacyProjectAgentDir, { recursive: true });
|
|
}
|
|
return projectAgentDir;
|
|
}
|
|
|
|
/**
|
|
* Get project-level agent path
|
|
*/
|
|
function getProjectAgentPath(workingDirectory, agentName) {
|
|
const pluralPath = path.join(workingDirectory, '.opencode', 'agents', `${agentName}.md`);
|
|
const legacyPath = path.join(workingDirectory, '.opencode', 'agent', `${agentName}.md`);
|
|
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
|
return pluralPath;
|
|
}
|
|
|
|
/**
|
|
* Create a per-request lookup cache for user-level agent path resolution.
|
|
*/
|
|
function createAgentLookupCache() {
|
|
return {
|
|
userAgentIndexByName: new Map(),
|
|
userAgentLookupByName: new Map(),
|
|
userAgentIndexReady: false,
|
|
};
|
|
}
|
|
|
|
function buildUserAgentIndex(cache) {
|
|
if (cache.userAgentIndexReady) return;
|
|
cache.userAgentIndexReady = true;
|
|
|
|
if (!fs.existsSync(AGENT_DIR)) return;
|
|
|
|
const dirsToVisit = [AGENT_DIR];
|
|
while (dirsToVisit.length > 0) {
|
|
const dir = dirsToVisit.pop();
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
const agentName = entry.name.slice(0, -3);
|
|
if (!cache.userAgentIndexByName.has(agentName)) {
|
|
cache.userAgentIndexByName.set(agentName, path.join(dir, entry.name));
|
|
}
|
|
}
|
|
|
|
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
const entry = entries[i];
|
|
if (entry.isDirectory()) {
|
|
dirsToVisit.push(path.join(dir, entry.name));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function getIndexedUserAgentPath(agentName, cache) {
|
|
if (cache.userAgentLookupByName.has(agentName)) {
|
|
return cache.userAgentLookupByName.get(agentName);
|
|
}
|
|
|
|
buildUserAgentIndex(cache);
|
|
const found = cache.userAgentIndexByName.get(agentName) || null;
|
|
cache.userAgentLookupByName.set(agentName, found);
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* Get user-level agent path — walks subfolders to support grouped layouts.
|
|
* e.g. ~/.config/opencode/agents/business/ceo-diginno.md
|
|
*/
|
|
function getUserAgentPath(agentName, lookupCache = null) {
|
|
// 1. Check flat path first (legacy / newly created agents)
|
|
const pluralPath = path.join(AGENT_DIR, `${agentName}.md`);
|
|
if (fs.existsSync(pluralPath)) return pluralPath;
|
|
|
|
const legacyPath = path.join(AGENT_DIR, '..', 'agent', `${agentName}.md`);
|
|
if (fs.existsSync(legacyPath)) return legacyPath;
|
|
|
|
// 2. Lookup subfolders for grouped layout
|
|
const cache = lookupCache || createAgentLookupCache();
|
|
const found = getIndexedUserAgentPath(agentName, cache);
|
|
if (found) return found;
|
|
|
|
// 3. Return expected flat path as default (for new agent creation)
|
|
return pluralPath;
|
|
}
|
|
|
|
/**
|
|
* Determine agent scope based on where the .md file exists
|
|
* Priority: project level > user level > null (built-in only)
|
|
*/
|
|
function getAgentScope(agentName, workingDirectory, lookupCache = null) {
|
|
if (workingDirectory) {
|
|
const projectPath = getProjectAgentPath(workingDirectory, agentName);
|
|
if (fs.existsSync(projectPath)) {
|
|
return { scope: AGENT_SCOPE.PROJECT, path: projectPath };
|
|
}
|
|
}
|
|
|
|
const userPath = getUserAgentPath(agentName, lookupCache);
|
|
if (fs.existsSync(userPath)) {
|
|
return { scope: AGENT_SCOPE.USER, path: userPath };
|
|
}
|
|
|
|
return { scope: null, path: null };
|
|
}
|
|
|
|
/**
|
|
* Get the path where an agent should be written based on scope
|
|
*/
|
|
function getAgentWritePath(agentName, workingDirectory, requestedScope, lookupCache = null) {
|
|
// For updates: check existing location first (project takes precedence)
|
|
const existing = getAgentScope(agentName, workingDirectory, lookupCache);
|
|
if (existing.path) {
|
|
return existing;
|
|
}
|
|
|
|
// For new agents or built-in overrides: use requested scope or default to user
|
|
const scope = requestedScope || AGENT_SCOPE.USER;
|
|
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
|
|
return {
|
|
scope: AGENT_SCOPE.PROJECT,
|
|
path: getProjectAgentPath(workingDirectory, agentName)
|
|
};
|
|
}
|
|
|
|
return {
|
|
scope: AGENT_SCOPE.USER,
|
|
path: getUserAgentPath(agentName, lookupCache)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Detect where an agent's permission field is currently defined
|
|
* Priority: project .md > user .md > project JSON > user JSON
|
|
* Returns: { source: 'md'|'json'|null, scope: 'project'|'user'|null, path: string|null }
|
|
*/
|
|
function getAgentPermissionSource(agentName, workingDirectory, lookupCache = null) {
|
|
// Check project-level .md first
|
|
if (workingDirectory) {
|
|
const projectMdPath = getProjectAgentPath(workingDirectory, agentName);
|
|
if (fs.existsSync(projectMdPath)) {
|
|
const { frontmatter } = parseMdFile(projectMdPath);
|
|
if (frontmatter.permission !== undefined) {
|
|
return { source: 'md', scope: AGENT_SCOPE.PROJECT, path: projectMdPath };
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check user-level .md
|
|
const userMdPath = getUserAgentPath(agentName, lookupCache);
|
|
if (fs.existsSync(userMdPath)) {
|
|
const { frontmatter } = parseMdFile(userMdPath);
|
|
if (frontmatter.permission !== undefined) {
|
|
return { source: 'md', scope: AGENT_SCOPE.USER, path: userMdPath };
|
|
}
|
|
}
|
|
|
|
// Check JSON layers in effective override order. readConfigLayers merges
|
|
// user -> project -> custom, so custom wins over project, project over user.
|
|
const layers = readConfigLayers(workingDirectory);
|
|
|
|
const customJsonPermission = layers.customConfig?.agent?.[agentName]?.permission;
|
|
if (customJsonPermission !== undefined && layers.paths.customPath) {
|
|
return { source: 'json', scope: 'custom', path: layers.paths.customPath };
|
|
}
|
|
|
|
const projectJsonPermission = layers.projectConfig?.agent?.[agentName]?.permission;
|
|
if (projectJsonPermission !== undefined && layers.paths.projectPath) {
|
|
return { source: 'json', scope: AGENT_SCOPE.PROJECT, path: layers.paths.projectPath };
|
|
}
|
|
|
|
const userJsonPermission = layers.userConfig?.agent?.[agentName]?.permission;
|
|
if (userJsonPermission !== undefined) {
|
|
return { source: 'json', scope: AGENT_SCOPE.USER, path: layers.paths.userPath };
|
|
}
|
|
|
|
return { source: null, scope: null, path: null };
|
|
}
|
|
|
|
function applyAgentPermission(target, newPermission) {
|
|
if (newPermission == null) {
|
|
delete target.permission;
|
|
} else {
|
|
target.permission = newPermission;
|
|
}
|
|
}
|
|
|
|
function getAgentSources(agentName, workingDirectory, lookupCache = createAgentLookupCache()) {
|
|
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
|
|
const projectExists = projectPath && fs.existsSync(projectPath);
|
|
|
|
const userPath = getUserAgentPath(agentName, lookupCache);
|
|
const userExists = fs.existsSync(userPath);
|
|
|
|
const mdPath = projectExists ? projectPath : (userExists ? userPath : null);
|
|
const mdExists = !!mdPath;
|
|
const mdScope = projectExists ? AGENT_SCOPE.PROJECT : (userExists ? AGENT_SCOPE.USER : null);
|
|
|
|
const layers = readConfigLayers(workingDirectory);
|
|
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
|
const jsonSection = jsonSource.section;
|
|
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
|
|
const jsonScope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
|
|
|
|
const sources = {
|
|
md: {
|
|
exists: mdExists,
|
|
path: mdPath,
|
|
scope: mdScope,
|
|
fields: []
|
|
},
|
|
json: {
|
|
exists: jsonSource.exists,
|
|
path: jsonPath,
|
|
scope: jsonSource.exists ? jsonScope : null,
|
|
fields: []
|
|
},
|
|
projectMd: {
|
|
exists: projectExists,
|
|
path: projectPath
|
|
},
|
|
userMd: {
|
|
exists: userExists,
|
|
path: userPath
|
|
}
|
|
};
|
|
|
|
if (mdExists) {
|
|
const { frontmatter, body } = parseMdFile(mdPath);
|
|
sources.md.fields = Object.keys(frontmatter);
|
|
if (body) {
|
|
sources.md.fields.push('prompt');
|
|
}
|
|
}
|
|
|
|
if (jsonSection) {
|
|
sources.json.fields = Object.keys(jsonSection);
|
|
}
|
|
|
|
return sources;
|
|
}
|
|
|
|
function getAgentConfig(agentName, workingDirectory, lookupCache = createAgentLookupCache()) {
|
|
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
|
|
const projectExists = projectPath && fs.existsSync(projectPath);
|
|
|
|
const userPath = getUserAgentPath(agentName, lookupCache);
|
|
const userExists = fs.existsSync(userPath);
|
|
|
|
if (projectExists || userExists) {
|
|
const mdPath = projectExists ? projectPath : userPath;
|
|
const { frontmatter, body } = parseMdFile(mdPath);
|
|
|
|
return {
|
|
source: 'md',
|
|
scope: projectExists ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER,
|
|
config: {
|
|
...frontmatter,
|
|
...(typeof body === 'string' && body.length > 0 ? { prompt: body } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
const layers = readConfigLayers(workingDirectory);
|
|
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
|
|
|
if (jsonSource.exists && jsonSource.section) {
|
|
const scope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
|
|
return {
|
|
source: 'json',
|
|
scope,
|
|
config: { ...jsonSource.section },
|
|
};
|
|
}
|
|
|
|
return {
|
|
source: 'none',
|
|
scope: null,
|
|
config: {},
|
|
};
|
|
}
|
|
|
|
function createAgent(agentName, config, workingDirectory, scope) {
|
|
ensureDirs();
|
|
const lookupCache = createAgentLookupCache();
|
|
|
|
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
|
|
const userPath = getUserAgentPath(agentName, lookupCache);
|
|
|
|
if (projectPath && fs.existsSync(projectPath)) {
|
|
throw new Error(`Agent ${agentName} already exists as project-level .md file`);
|
|
}
|
|
|
|
if (fs.existsSync(userPath)) {
|
|
throw new Error(`Agent ${agentName} already exists as user-level .md file`);
|
|
}
|
|
|
|
const layers = readConfigLayers(workingDirectory);
|
|
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
|
if (jsonSource.exists) {
|
|
throw new Error(`Agent ${agentName} already exists in opencode.json`);
|
|
}
|
|
|
|
let targetPath;
|
|
let targetScope;
|
|
|
|
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
|
|
ensureProjectAgentDir(workingDirectory);
|
|
targetPath = projectPath;
|
|
targetScope = AGENT_SCOPE.PROJECT;
|
|
} else {
|
|
targetPath = userPath;
|
|
targetScope = AGENT_SCOPE.USER;
|
|
}
|
|
|
|
const { prompt, scope: _scopeFromConfig, ...rawFrontmatter } = config;
|
|
const frontmatter = Object.fromEntries(
|
|
Object.entries(rawFrontmatter).filter(([, value]) => value !== null && value !== undefined)
|
|
);
|
|
|
|
writeMdFile(targetPath, frontmatter, prompt || '');
|
|
console.log(`Created new agent: ${agentName} (scope: ${targetScope}, path: ${targetPath})`);
|
|
}
|
|
|
|
function updateAgent(agentName, updates, workingDirectory) {
|
|
ensureDirs();
|
|
const lookupCache = createAgentLookupCache();
|
|
|
|
const { scope, path: mdPath } = getAgentWritePath(agentName, workingDirectory, undefined, lookupCache);
|
|
const mdExists = mdPath && fs.existsSync(mdPath);
|
|
|
|
const layers = readConfigLayers(workingDirectory);
|
|
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
|
const jsonSection = jsonSource.section;
|
|
const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0;
|
|
const jsonTarget = jsonSource.exists
|
|
? { config: jsonSource.config, path: jsonSource.path }
|
|
: getJsonWriteTarget(layers, AGENT_SCOPE.USER);
|
|
let config = jsonTarget.config || {};
|
|
|
|
const isBuiltinOverride = !mdExists && !hasJsonFields;
|
|
|
|
let targetPath = mdPath;
|
|
let targetScope = scope;
|
|
|
|
if (!mdExists && isBuiltinOverride) {
|
|
targetPath = getUserAgentPath(agentName, lookupCache);
|
|
targetScope = AGENT_SCOPE.USER;
|
|
}
|
|
|
|
let mdData = mdExists ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {}, body: '' } : null);
|
|
|
|
let mdModified = false;
|
|
let jsonModified = false;
|
|
const creatingNewMd = isBuiltinOverride;
|
|
|
|
for (const [field, value] of Object.entries(updates)) {
|
|
// Skip undefined values — they would overwrite existing frontmatter fields with nothing
|
|
if (value === undefined) continue;
|
|
|
|
if (field === 'prompt') {
|
|
if (value === null) {
|
|
if (mdExists || creatingNewMd) {
|
|
if (mdData) {
|
|
mdData.body = '';
|
|
mdModified = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (isPromptFileReference(jsonSection?.prompt)) {
|
|
const promptFilePath = resolvePromptFilePath(jsonSection.prompt);
|
|
if (!promptFilePath) {
|
|
throw new Error(`Invalid prompt file reference for agent ${agentName}`);
|
|
}
|
|
writePromptFile(promptFilePath, '');
|
|
continue;
|
|
}
|
|
|
|
if (config.agent?.[agentName]) {
|
|
delete config.agent[agentName].prompt;
|
|
|
|
if (Object.keys(config.agent[agentName]).length === 0) {
|
|
delete config.agent[agentName];
|
|
}
|
|
if (Object.keys(config.agent).length === 0) {
|
|
delete config.agent;
|
|
}
|
|
|
|
jsonModified = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
|
|
|
|
if (mdExists || creatingNewMd) {
|
|
if (mdData) {
|
|
mdData.body = normalizedValue;
|
|
mdModified = true;
|
|
}
|
|
continue;
|
|
} else if (isPromptFileReference(jsonSection?.prompt)) {
|
|
const promptFilePath = resolvePromptFilePath(jsonSection.prompt);
|
|
if (!promptFilePath) {
|
|
throw new Error(`Invalid prompt file reference for agent ${agentName}`);
|
|
}
|
|
writePromptFile(promptFilePath, normalizedValue);
|
|
continue;
|
|
} else if (isPromptFileReference(normalizedValue)) {
|
|
if (!config.agent) config.agent = {};
|
|
if (!config.agent[agentName]) config.agent[agentName] = {};
|
|
config.agent[agentName].prompt = normalizedValue;
|
|
jsonModified = true;
|
|
continue;
|
|
}
|
|
|
|
if (!config.agent) config.agent = {};
|
|
if (!config.agent[agentName]) config.agent[agentName] = {};
|
|
config.agent[agentName].prompt = normalizedValue;
|
|
jsonModified = true;
|
|
continue;
|
|
}
|
|
|
|
if (field === 'permission') {
|
|
const permissionSource = getAgentPermissionSource(agentName, workingDirectory, lookupCache);
|
|
// The client edits the complete source permission map; persist it verbatim.
|
|
// (The old non-wildcard re-merge resurrected rules the user deleted.)
|
|
const newPermission = value && typeof value === 'object' && Object.keys(value).length === 0 ? null : value;
|
|
|
|
if (permissionSource.source === 'md') {
|
|
if (mdData && permissionSource.path === targetPath) {
|
|
applyAgentPermission(mdData.frontmatter, newPermission);
|
|
mdModified = true;
|
|
} else {
|
|
const existingMdData = parseMdFile(permissionSource.path);
|
|
applyAgentPermission(existingMdData.frontmatter, newPermission);
|
|
writeMdFile(permissionSource.path, existingMdData.frontmatter, existingMdData.body);
|
|
console.log(`Updated permission in .md file: ${permissionSource.path}`);
|
|
}
|
|
} else if (permissionSource.source === 'json') {
|
|
if (permissionSource.path === (jsonTarget.path || CONFIG_FILE)) {
|
|
if (!config.agent) config.agent = {};
|
|
if (!config.agent[agentName]) config.agent[agentName] = {};
|
|
applyAgentPermission(config.agent[agentName], newPermission);
|
|
jsonModified = true;
|
|
} else {
|
|
const existingConfig = readConfigFile(permissionSource.path);
|
|
if (!existingConfig.agent) existingConfig.agent = {};
|
|
if (!existingConfig.agent[agentName]) existingConfig.agent[agentName] = {};
|
|
applyAgentPermission(existingConfig.agent[agentName], newPermission);
|
|
writeConfig(existingConfig, permissionSource.path);
|
|
console.log(`Updated permission in JSON: ${permissionSource.path}`);
|
|
}
|
|
} else {
|
|
if (mdExists && mdData) {
|
|
applyAgentPermission(mdData.frontmatter, newPermission);
|
|
mdModified = true;
|
|
} else if (hasJsonFields) {
|
|
if (!config.agent) config.agent = {};
|
|
if (!config.agent[agentName]) config.agent[agentName] = {};
|
|
applyAgentPermission(config.agent[agentName], newPermission);
|
|
jsonModified = true;
|
|
} else {
|
|
const writeTarget = getJsonWriteTarget(layers, AGENT_SCOPE.USER);
|
|
if (!writeTarget.config.agent) writeTarget.config.agent = {};
|
|
if (!writeTarget.config.agent[agentName]) writeTarget.config.agent[agentName] = {};
|
|
applyAgentPermission(writeTarget.config.agent[agentName], newPermission);
|
|
writeConfig(writeTarget.config, writeTarget.path);
|
|
console.log(`Created permission in JSON: ${writeTarget.path}`);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const inMd = mdData?.frontmatter?.[field] !== undefined;
|
|
const inJson = jsonSection?.[field] !== undefined;
|
|
|
|
if (value === null) {
|
|
if (mdData && inMd) {
|
|
delete mdData.frontmatter[field];
|
|
mdModified = true;
|
|
}
|
|
|
|
if (inJson && config.agent?.[agentName]) {
|
|
delete config.agent[agentName][field];
|
|
|
|
if (Object.keys(config.agent[agentName]).length === 0) {
|
|
delete config.agent[agentName];
|
|
}
|
|
if (Object.keys(config.agent).length === 0) {
|
|
delete config.agent;
|
|
}
|
|
|
|
jsonModified = true;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (inJson) {
|
|
if (!config.agent) config.agent = {};
|
|
if (!config.agent[agentName]) config.agent[agentName] = {};
|
|
config.agent[agentName][field] = value;
|
|
jsonModified = true;
|
|
} else if (inMd || creatingNewMd) {
|
|
if (mdData) {
|
|
mdData.frontmatter[field] = value;
|
|
mdModified = true;
|
|
}
|
|
} else {
|
|
if ((mdExists || creatingNewMd) && mdData) {
|
|
mdData.frontmatter[field] = value;
|
|
mdModified = true;
|
|
} else {
|
|
if (!config.agent) config.agent = {};
|
|
if (!config.agent[agentName]) config.agent[agentName] = {};
|
|
config.agent[agentName][field] = value;
|
|
jsonModified = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mdModified && mdData) {
|
|
writeMdFile(targetPath, mdData.frontmatter, mdData.body);
|
|
}
|
|
|
|
if (jsonModified) {
|
|
writeConfig(config, jsonTarget.path || CONFIG_FILE);
|
|
}
|
|
|
|
console.log(`Updated agent: ${agentName} (scope: ${targetScope}, md: ${mdModified}, json: ${jsonModified})`);
|
|
}
|
|
|
|
function deleteJsonAgentEntry(config, agentName) {
|
|
const agentMap = config.agent;
|
|
if (!agentMap || typeof agentMap !== 'object' || Array.isArray(agentMap) || !agentMap[agentName]) return false;
|
|
delete agentMap[agentName];
|
|
if (Object.keys(agentMap).length === 0) {
|
|
delete config.agent;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function deleteAgent(agentName, workingDirectory, scope) {
|
|
const lookupCache = createAgentLookupCache();
|
|
const requestedScope = scope === AGENT_SCOPE.PROJECT || scope === AGENT_SCOPE.USER ? scope : null;
|
|
|
|
if ((!requestedScope || requestedScope === AGENT_SCOPE.PROJECT) && workingDirectory) {
|
|
const projectPath = getProjectAgentPath(workingDirectory, agentName);
|
|
if (fs.existsSync(projectPath)) {
|
|
fs.unlinkSync(projectPath);
|
|
console.log(`Deleted project-level agent .md file: ${projectPath}`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!requestedScope || requestedScope === AGENT_SCOPE.USER) {
|
|
const userPath = getUserAgentPath(agentName, lookupCache);
|
|
if (fs.existsSync(userPath)) {
|
|
fs.unlinkSync(userPath);
|
|
console.log(`Deleted user-level agent .md file: ${userPath}`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const layers = readConfigLayers(workingDirectory);
|
|
|
|
if (requestedScope === AGENT_SCOPE.PROJECT) {
|
|
if (layers.paths.projectPath && deleteJsonAgentEntry(layers.projectConfig, agentName)) {
|
|
writeConfig(layers.projectConfig, layers.paths.projectPath);
|
|
console.log(`Removed project-level agent from opencode.json: ${agentName}`);
|
|
return;
|
|
}
|
|
throw new Error(`Project agent ${agentName} not found`);
|
|
}
|
|
|
|
if (requestedScope === AGENT_SCOPE.USER) {
|
|
const userJsonPath = layers.paths.customPath || layers.paths.userPath;
|
|
const userJsonConfig = layers.paths.customPath ? layers.customConfig : layers.userConfig;
|
|
if (userJsonPath && deleteJsonAgentEntry(userJsonConfig, agentName)) {
|
|
writeConfig(userJsonConfig, userJsonPath);
|
|
console.log(`Removed user-level agent from opencode.json: ${agentName}`);
|
|
return;
|
|
}
|
|
throw new Error(`User agent ${agentName} not found`);
|
|
}
|
|
|
|
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
|
if (jsonSource.exists && jsonSource.config && jsonSource.path && deleteJsonAgentEntry(jsonSource.config, agentName)) {
|
|
writeConfig(jsonSource.config, jsonSource.path);
|
|
console.log(`Removed agent from opencode.json: ${agentName}`);
|
|
return;
|
|
}
|
|
|
|
throw new Error(`Agent ${agentName} is built-in or not deletable`);
|
|
}
|
|
|
|
export {
|
|
getAgentSources,
|
|
getAgentConfig,
|
|
createAgent,
|
|
updateAgent,
|
|
deleteAgent,
|
|
};
|