From 60b6e95086447b5b355749b206a93326940df503 Mon Sep 17 00:00:00 2001 From: zswll2 Date: Sun, 10 May 2026 23:07:03 +0800 Subject: [PATCH 01/26] i18n: add missing translation keys to en.json and zh-CN.json --- client-next/messages/en.json | 17 ++++++++++++++--- client-next/messages/zh-CN.json | 9 ++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/client-next/messages/en.json b/client-next/messages/en.json index 901e3b6d..361dc743 100644 --- a/client-next/messages/en.json +++ b/client-next/messages/en.json @@ -435,7 +435,9 @@ "noMatch": "No skills match \"{search}\"", "unknownError": "Unknown error", "delete": "Delete", - "cancel": "Cancel" + "cancel": "Cancel", + "deleted": "Skill {name} deleted", + "deleteFailed": "Failed to delete skill: {error}" }, "plugins": { "incompatibleWarning": "Incompatible with Antigravity. Use openskills instead.", @@ -452,7 +454,9 @@ "noMatch": "No plugins match \"{search}\"", "unknownError": "Unknown error", "delete": "Delete", - "cancel": "Cancel" + "cancel": "Cancel", + "deleted": "Plugin {name} deleted", + "deleteFailed": "Failed to delete plugin: {error}" }, "commands": { "loadFailed": "Failed to load commands", @@ -645,7 +649,14 @@ "modelInterface": "Model Interface", "input": "Input", "output": "Output", - "cost": "Cost" + "cost": "Cost", + "range24h": "Last 24 hours", + "range7d": "Last 7 days", + "range30d": "Last 30 days", + "range3m": "Last 3 months", + "range6m": "Last 6 months", + "range1y": "Last year", + "rangeCustom": "Custom range" }, "config": { "invalidJson": "Invalid JSON: {error}", diff --git a/client-next/messages/zh-CN.json b/client-next/messages/zh-CN.json index 97ca5387..c768ec6f 100644 --- a/client-next/messages/zh-CN.json +++ b/client-next/messages/zh-CN.json @@ -649,7 +649,14 @@ "modelInterface": "\u6a21\u578b\u63a5\u53e3", "input": "\u8f93\u5165", "output": "\u8f93\u51fa", - "cost": "\u8d39\u7528" + "cost": "\u8d39\u7528", + "range24h": "\u8fc7\u53bb 24 \u5c0f\u65f6", + "range7d": "\u8fc7\u53bb 7 \u5929", + "range30d": "\u8fc7\u53bb 30 \u5929", + "range3m": "\u8fc7\u53bb 3 \u4e2a\u6708", + "range6m": "\u8fc7\u53bb 6 \u4e2a\u6708", + "range1y": "\u8fc7\u53bb\u4e00\u5e74", + "rangeCustom": "\u81ea\u5b9a\u4e49\u8303\u56f4" }, "config": { "invalidJson": "\u65e0\u6548\u7684 JSON: {error}", From 245690adb1df3916268c1d6d45d42c8acc8a4994 Mon Sep 17 00:00:00 2001 From: zswll2 Date: Sun, 2 Aug 2026 12:58:00 +0800 Subject: [PATCH 02/26] feat(config-providers): detect omo.jsonc and [opencode] block for OMO provider Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- server/lib/config-providers.js | 195 ++++++++++++++++++++++++++++++--- 1 file changed, 180 insertions(+), 15 deletions(-) diff --git a/server/lib/config-providers.js b/server/lib/config-providers.js index abde610a..150e806d 100644 --- a/server/lib/config-providers.js +++ b/server/lib/config-providers.js @@ -1,5 +1,6 @@ const fs = require('fs'); const path = require('path'); +const os = require('os'); const crypto = require('crypto'); const jsoncParser = require('jsonc-parser'); @@ -25,6 +26,8 @@ const PROVIDER_RULES = Object.freeze({ id: PROVIDER_IDS.OH_MY_OPENAGENT, displayName: 'Oh My OpenAgent', basenames: Object.freeze([ + 'omo.jsonc', + 'omo.json', 'oh-my-openagent.json', 'oh-my-openagent.jsonc', 'oh-my-opencode.json', @@ -42,6 +45,19 @@ const PROVIDER_RULES = Object.freeze({ const OPENAGENT_PROFILE_DIRNAME = 'oh-my-openagent-configs'; +const OMO_BASENAMES = Object.freeze(['omo.jsonc', 'omo.json']); + +const OPENAGENT_LEGACY_BASENAMES = Object.freeze([ + 'oh-my-openagent.json', + 'oh-my-openagent.jsonc', + 'oh-my-opencode.json', + 'oh-my-opencode.jsonc' +]); + +const MAX_OMO_PROJECT_SCAN_DEPTH = 256; + +const OMO_PROFILE_DIR_SUFFIX_RE = /(?:^|[\\/])profiles[\\/]([^\\/]+)[\\/]*$/; + const CONTRACT_METHOD_NAMES = Object.freeze([ 'detect', 'load', @@ -283,27 +299,156 @@ const createProviderDetectionResult = ({ id, displayName, candidates, existing, diagnostics }); +const isSymlinkedPathSync = (targetPath) => { + try { + return fs.lstatSync(targetPath).isSymbolicLink(); + } catch { + return false; + } +}; + +const realpathOrSelfSync = (targetPath) => { + try { + return fs.realpathSync(targetPath); + } catch { + return targetPath; + } +}; + +const findLoadableOmoConfigPathInDir = (dirPath) => { + const omoDir = path.join(dirPath, '.omo'); + if (isSymlinkedPathSync(omoDir)) return null; + for (const basename of OMO_BASENAMES) { + const candidate = path.join(omoDir, basename); + if (isFileSync(candidate) && !isSymlinkedPathSync(candidate)) return candidate; + } + return null; +}; + +// Mirrors plugin findProjectConfigPathsFarthestFirst: walk up from cwd (max 256 +// layers), skip symlinked .omo dirs, stop at $HOME (exclusive), then reverse so +// the farthest ancestor comes first. Returns the .omo directory roots that +// actually contain a loadable omo.jsonc/omo.json. +const findProjectOmoRootsFarthestFirst = ({ cwd = process.cwd(), homeDir = os.homedir() } = {}) => { + const startDir = normalizePath(cwd); + const homeBoundary = normalizePath(homeDir); + const realHomeBoundary = realpathOrSelfSync(homeBoundary); + const nearestFirst = []; + let currentDir = startDir; + for (let depth = 0; depth < MAX_OMO_PROJECT_SCAN_DEPTH; depth += 1) { + const normalizedCurrentDir = normalizePath(currentDir); + if (normalizedCurrentDir === homeBoundary || realpathOrSelfSync(normalizedCurrentDir) === realHomeBoundary) { + break; + } + const configPath = findLoadableOmoConfigPathInDir(currentDir); + if (configPath) nearestFirst.push(normalizePath(path.dirname(configPath))); + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) break; + currentDir = parentDir; + } + return nearestFirst.reverse(); +}; + +// User-level ~/.omo first, then project-level .omo roots farthest-first. +// Reusable by server/index.js for wiring omo roots into the caller-supplied roots. +const getOmoSearchRoots = ({ cwd = process.cwd(), homeDir = os.homedir() } = {}) => { + const userRoot = normalizePath(path.join(homeDir, '.omo')); + const projectRoots = findProjectOmoRootsFarthestFirst({ cwd, homeDir }); + return uniqNormalizedPaths([userRoot, ...projectRoots]); +}; + +const buildOmoRootCandidates = (omoRoots) => { + const candidates = []; + for (const root of omoRoots) { + for (const basename of OMO_BASENAMES) { + candidates.push(toAbsolutePath(root, basename)); + } + } + return uniqNormalizedPaths(candidates); +}; + +const isOmoBasename = (basename) => OMO_BASENAMES.includes(basename); + +// Returns only the "[opencode]" block of an omo config file (never the whole file). +const getOmoConfigBlock = (filePath) => { + if (!filePath || !isFileSync(filePath)) return {}; + try { + const parsed = parseJsonText(fs.readFileSync(filePath, 'utf8')); + if (!isPlainObject(parsed)) return {}; + const block = parsed['[opencode]']; + return isPlainObject(block) ? block : {}; + } catch { + return {}; + } +}; + +const profileName = (value) => (value === '' ? undefined : value); + +// Mirrors plugin profileNameFromOpenCodeConfigDir: extract profile name from the +// "/profiles/" suffix of OPENCODE_CONFIG_DIR (not the bare basename). +const profileNameFromOpenCodeConfigDir = (configDirPath) => { + if (typeof configDirPath !== 'string') return undefined; + const match = configDirPath.match(OMO_PROFILE_DIR_SUFFIX_RE); + return profileName(match ? match[1] : undefined); +}; + +// Mirrors plugin resolveOmoProfileName: OMO_PROFILE > OCX_PROFILE > OPENCODE_CONFIG_DIR profile suffix. +const getResolvedActiveProfile = (options = {}) => { + const env = options.env ?? process.env; + return ( + profileName(options.profile) ?? + profileName(env.OMO_PROFILE) ?? + profileName(env.OCX_PROFILE) ?? + profileNameFromOpenCodeConfigDir(env.OPENCODE_CONFIG_DIR) + ); +}; + const detectSingleProvider = (rule, options = {}) => { const roots = resolveRoots({ roots: options.roots, customPaths: options.customPaths }); const candidates = buildCandidatesForRule(rule, roots); - const existing = findExistingPaths(candidates); + let existing = findExistingPaths(candidates); const diagnostics = []; - - if (existing.length > 1) { - diagnostics.push(createDiagnostic({ - severity: 'warning', - code: 'DUPLICATE_PROVIDER_CONFIG', - message: `Multiple config files detected for ${rule.displayName}`, - details: { paths: existing } - })); - } + let activePath = existing[0] || null; if (rule.id === PROVIDER_IDS.OH_MY_OPENAGENT) { - const hasPrimary = existing.some((p) => { + const omoRoots = getOmoSearchRoots(); + const omoRootSet = new Set(omoRoots.map((root) => normalizePath(root))); + + const omoExisting = findExistingPaths(buildOmoRootCandidates(omoRoots)); + + const omoOutsideRoots = []; + const legacyExisting = []; + for (const p of existing) { + if (isOmoBasename(getPathBasenameAnySeparator(p))) { + if (!omoRootSet.has(normalizePath(path.dirname(p)))) omoOutsideRoots.push(p); + } else { + legacyExisting.push(p); + } + } + + if (omoOutsideRoots.length > 0) { + diagnostics.push(createDiagnostic({ + severity: 'warning', + code: 'OPENAGENT_OMO_OUTSIDE_ROOT', + message: 'Detected omo.jsonc outside an omo search root; ignoring it', + details: { paths: omoOutsideRoots, omoRoots } + })); + } + + if (legacyExisting.length > 1) { + diagnostics.push(createDiagnostic({ + severity: 'warning', + code: 'DUPLICATE_PROVIDER_CONFIG', + message: `Multiple config files detected for ${rule.displayName}`, + details: { paths: legacyExisting } + })); + } + + const hasPrimary = legacyExisting.some((p) => { const basename = getPathBasenameAnySeparator(p); return basename === 'oh-my-openagent.json' || basename === 'oh-my-openagent.jsonc'; }); - const hasLegacy = existing.some((p) => { + const hasLegacy = legacyExisting.some((p) => { const basename = getPathBasenameAnySeparator(p); return basename === 'oh-my-opencode.json' || basename === 'oh-my-opencode.jsonc'; }); @@ -312,12 +457,29 @@ const detectSingleProvider = (rule, options = {}) => { severity: 'warning', code: 'OPENAGENT_ALIAS_DUPLICATE', message: 'Detected both oh-my-openagent and legacy oh-my-opencode config aliases', - details: { paths: existing } + details: { paths: legacyExisting } + })); + } + + activePath = omoExisting[0] || legacyExisting[0] || null; + if (activePath && OPENAGENT_LEGACY_BASENAMES.includes(getPathBasenameAnySeparator(activePath))) { + diagnostics.push(createDiagnostic({ + severity: 'warning', + code: 'OPENAGENT_LEGACY_CONFIG', + message: 'Using legacy oh-my-openagent/oh-my-opencode config; prefer omo.jsonc in an omo root', + details: { path: activePath } })); } - } - const activePath = existing[0] || null; + existing = uniqNormalizedPaths([...omoExisting, ...legacyExisting]); + } else if (existing.length > 1) { + diagnostics.push(createDiagnostic({ + severity: 'warning', + code: 'DUPLICATE_PROVIDER_CONFIG', + message: `Multiple config files detected for ${rule.displayName}`, + details: { paths: existing } + })); + } diagnostics.push(...parseConfigForDiagnostics(activePath, { parseJsonc: options.parseJsonc })); if (rule.id === PROVIDER_IDS.OH_MY_OPENCODE_SLIM && activePath) { @@ -570,6 +732,9 @@ module.exports = { getOpenAgentProfilePath, isOpenAgentProfilePath, listOpenAgentProfilePaths, + getOmoSearchRoots, + getOmoConfigBlock, + getResolvedActiveProfile, createProviderDetectionResult, detectSingleProvider, detectProviders, From 38117cafc23ccd9ff4c8fc90ca44d7939141ebfd Mon Sep 17 00:00:00 2001 From: zswll2 Date: Sun, 2 Aug 2026 13:02:23 +0800 Subject: [PATCH 03/26] fix(config-providers): scope omo detection to caller roots when provided Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- server/lib/config-providers.js | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/server/lib/config-providers.js b/server/lib/config-providers.js index 150e806d..20a237ae 100644 --- a/server/lib/config-providers.js +++ b/server/lib/config-providers.js @@ -367,6 +367,27 @@ const buildOmoRootCandidates = (omoRoots) => { return uniqNormalizedPaths(candidates); }; +// Derive omo candidate roots from the caller-provided search scope when one is +// given (caller roots that are `.omo` dirs are used directly; other caller roots +// contribute their `root/.omo` subdir only when it holds a loadable omo config). +// Fall back to the homedir-based getOmoSearchRoots() only when the caller scoped +// nothing, so caller-scoped detection never leaks the real ~/.omo into results. +const resolveOmoCandidateRoots = ({ roots = [], customPaths = [] } = {}) => { + const callerRoots = uniqNormalizedPaths([...(roots || []), ...(customPaths || [])]); + if (callerRoots.length === 0) return getOmoSearchRoots(); + const omoRoots = []; + for (const root of callerRoots) { + if (getPathBasenameAnySeparator(root) === '.omo') { + omoRoots.push(root); + continue; + } + if (findLoadableOmoConfigPathInDir(root)) { + omoRoots.push(normalizePath(path.join(root, '.omo'))); + } + } + return uniqNormalizedPaths(omoRoots); +}; + const isOmoBasename = (basename) => OMO_BASENAMES.includes(basename); // Returns only the "[opencode]" block of an omo config file (never the whole file). @@ -411,7 +432,7 @@ const detectSingleProvider = (rule, options = {}) => { let activePath = existing[0] || null; if (rule.id === PROVIDER_IDS.OH_MY_OPENAGENT) { - const omoRoots = getOmoSearchRoots(); + const omoRoots = resolveOmoCandidateRoots({ roots, customPaths: options.customPaths }); const omoRootSet = new Set(omoRoots.map((root) => normalizePath(root))); const omoExisting = findExistingPaths(buildOmoRootCandidates(omoRoots)); From 4a9ef4d83ae3a4cdca9f0f54f3991a9101669cd3 Mon Sep 17 00:00:00 2001 From: zswll2 Date: Sun, 2 Aug 2026 13:05:59 +0800 Subject: [PATCH 04/26] test(config-providers): cover omo.jsonc detection and block read Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- server/lib/config-providers.test.js | 89 +++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/server/lib/config-providers.test.js b/server/lib/config-providers.test.js index 3b7a0fd7..6e46e773 100644 --- a/server/lib/config-providers.test.js +++ b/server/lib/config-providers.test.js @@ -337,3 +337,92 @@ describe('provider write/import helper semantics', () => { expect(withAllowedPath).toEqual({ ok: true, path: candidatePath }); }); }); + +describe('omo config detection and [opencode] block reading', () => { + it('selects omo.jsonc from an omo root over a legacy openagent config', () => { + const tempDir = makeTempDir(); + const omoRoot = path.join(tempDir, '.omo'); + fs.mkdirSync(omoRoot, { recursive: true }); + const omoPath = path.join(omoRoot, 'omo.jsonc'); + const legacyPath = path.join(tempDir, 'oh-my-openagent.json'); + fs.writeFileSync(omoPath, '{ "[opencode]": { "theme": "dark" } }'); + fs.writeFileSync(legacyPath, '{"a":1}'); + + const detect = () => providers.detectProviders({ roots: [tempDir] }) + .find((provider) => provider.id === providers.PROVIDER_IDS.OH_MY_OPENAGENT); + + const withOmo = detect(); + expect(withOmo.activePath).toBe(omoPath); + expect(withOmo.diagnostics.some((d) => d.code === 'OPENAGENT_LEGACY_CONFIG')).toBe(false); + + fs.unlinkSync(omoPath); + const legacyOnly = detect(); + expect(legacyOnly.activePath).toBe(legacyPath); + const legacyDiagnostic = legacyOnly.diagnostics.find((d) => d.code === 'OPENAGENT_LEGACY_CONFIG'); + expect(legacyDiagnostic).toBeTruthy(); + expect(legacyDiagnostic.severity).toBe('warning'); + expect(legacyDiagnostic.details.path).toBe(legacyPath); + }); + + it('prefers the ~/.omo user root over another omo root when both hold omo.jsonc', () => { + const homeDir = makeTempDir(); + const otherRoot = makeTempDir(); + const userOmoDir = path.join(homeDir, '.omo'); + const otherOmoDir = path.join(otherRoot, '.omo'); + fs.mkdirSync(userOmoDir, { recursive: true }); + fs.mkdirSync(otherOmoDir, { recursive: true }); + const userOmoPath = path.join(userOmoDir, 'omo.jsonc'); + const otherOmoPath = path.join(otherOmoDir, 'omo.jsonc'); + fs.writeFileSync(userOmoPath, '{ "[opencode]": { "theme": "user" } }'); + fs.writeFileSync(otherOmoPath, '{ "[opencode]": { "theme": "other" } }'); + + const openAgent = providers.detectProviders({ roots: [userOmoDir, otherOmoDir] }) + .find((provider) => provider.id === providers.PROVIDER_IDS.OH_MY_OPENAGENT); + + expect(openAgent.activePath).toBe(userOmoPath); + expect(providers.getOmoConfigBlock(openAgent.activePath)).toEqual({ theme: 'user' }); + }); + + it('extracts only the [opencode] block from an omo config file', () => { + const tempDir = makeTempDir(); + const omoPath = path.join(tempDir, 'omo.jsonc'); + fs.writeFileSync(omoPath, [ + '{', + ' // omo config carries plugin metadata plus the [opencode] block', + ' "plugin": { "version": "1.0.0" },', + ' "[opencode]": {', + ' "theme": "dark",', + ' "model": "fast",', + ' },', + '}' + ].join('\n')); + + expect(providers.getOmoConfigBlock(omoPath)).toEqual({ theme: 'dark', model: 'fast' }); + }); + + it('returns {} without throwing for omo.jsonc missing the [opencode] block', () => { + const tempDir = makeTempDir(); + const omoPath = path.join(tempDir, 'omo.jsonc'); + fs.writeFileSync(omoPath, '{ "plugin": { "version": "1.0.0" } }'); + + expect(providers.getOmoConfigBlock(omoPath)).toEqual({}); + }); + + it('warns and never selects omo.jsonc found outside omo search roots', () => { + const homeDir = makeTempDir(); + const outsideRoot = path.join(homeDir, '.config', 'opencode'); + fs.mkdirSync(outsideRoot, { recursive: true }); + const omoPath = path.join(outsideRoot, 'omo.jsonc'); + fs.writeFileSync(omoPath, '{ "[opencode]": { "theme": "dark" } }'); + + const openAgent = providers.detectProviders({ roots: [outsideRoot] }) + .find((provider) => provider.id === providers.PROVIDER_IDS.OH_MY_OPENAGENT); + + expect(openAgent.activePath).toBeNull(); + expect(openAgent.exists).toBe(false); + const outsideDiagnostic = openAgent.diagnostics.find((d) => d.code === 'OPENAGENT_OMO_OUTSIDE_ROOT'); + expect(outsideDiagnostic).toBeTruthy(); + expect(outsideDiagnostic.severity).toBe('warning'); + expect(outsideDiagnostic.details.paths).toEqual([omoPath]); + }); +}); From 1317005ee823f963297cd9499fd5abfa2f2d5405 Mon Sep 17 00:00:00 2001 From: zswll2 Date: Sun, 2 Aug 2026 13:10:33 +0800 Subject: [PATCH 05/26] feat(config-providers): jsonc-preserving block writes and profiles CRUD Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- server/lib/config-providers.js | 149 +++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/server/lib/config-providers.js b/server/lib/config-providers.js index 20a237ae..7baac3ae 100644 --- a/server/lib/config-providers.js +++ b/server/lib/config-providers.js @@ -403,6 +403,26 @@ const getOmoConfigBlock = (filePath) => { } }; +const OMO_SCHEMA_URL = 'https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json'; + +// Skeleton created when an omo.jsonc does not exist yet (MINOR-1 missing-file +// contract): comment header + $schema (MINOR-b, matches the real ~/.omo/omo.jsonc) +// + empty [opencode] block, so writeOmoBlock can always edit a well-formed doc. +const OMO_SKELETON_JSONC = `// OMO configuration +{ + "$schema": "${OMO_SCHEMA_URL}", + "[opencode]": {} +} +`; + +// Matches the plugin's FORMATTING_OPTIONS so newly inserted omo.jsonc nodes use +// the same 2-space style as the real ~/.omo/omo.jsonc. +const OMO_JSONC_FORMATTING_OPTIONS = Object.freeze({ + eol: '\n', + insertSpaces: true, + tabSize: 2 +}); + const profileName = (value) => (value === '' ? undefined : value); // Mirrors plugin profileNameFromOpenCodeConfigDir: extract profile name from the @@ -424,6 +444,130 @@ const getResolvedActiveProfile = (options = {}) => { ); }; +const assertOmoPath = (filePath) => { + const targetPath = normalizePath(filePath); + if (!targetPath) throw new Error('omo config path is required'); + return targetPath; +}; + +// Parses a block key into a jsonc-parser path: '[opencode]' -> ['[opencode]'], +// 'profiles..[opencode]' -> ['profiles', sanitizedName, '[opencode]']. +// Null for anything else. Names go through sanitizeConfigProfileName, so dots +// inside a name never collide with the '.' path separators of the key string. +const parseOmoBlockKeyPath = (key) => { + if (typeof key !== 'string' || key.trim() === '') return null; + const trimmed = key.trim(); + if (trimmed === '[opencode]') return ['[opencode]']; + const match = trimmed.match(/^profiles\.(.+)\.\[opencode\]$/); + if (!match) return null; + const safeName = sanitizeConfigProfileName(match[1]); + if (!safeName) return null; + return ['profiles', safeName, '[opencode]']; +}; + +// Surgically writes `block` into an omo.jsonc via jsonc-parser modify/applyEdits: +// only the targeted node changes, so the comment header, $schema, _migrations and +// every other block survive (C2 data protection — never whole-file rewrites). +// Missing file -> skeleton created first (MINOR-1); write is atomic through +// atomicWriteTextSync (MINOR-c: temp file + rename, a crash cannot corrupt omo.jsonc). +const writeOmoBlock = (filePath, block, key = '[opencode]') => { + const targetPath = assertOmoPath(filePath); + if (!isPlainObject(block)) { + throw new Error(`omo block must be a plain object, got ${typeof block}`); + } + const blockPath = parseOmoBlockKeyPath(key); + if (!blockPath) { + throw new Error(`Invalid omo block key: ${JSON.stringify(key)} (expected "[opencode]" or "profiles..[opencode]")`); + } + + const existed = pathExistsSync(targetPath); + if (existed && !isFileSync(targetPath)) { + throw new Error(`omo config path is not a file: ${targetPath}`); + } + + let text; + if (existed) { + text = fs.readFileSync(targetPath, 'utf8'); + if (!isPlainObject(parseJsonText(text))) { + throw new Error(`omo config root must be a JSONC object: ${targetPath}`); + } + } else { + text = OMO_SKELETON_JSONC; + } + + const edits = jsoncParser.modify(text, blockPath, block, { + formattingOptions: OMO_JSONC_FORMATTING_OPTIONS + }); + atomicWriteTextSync(targetPath, jsoncParser.applyEdits(text, edits), 'utf8'); + return { path: targetPath, created: !existed }; +}; + +// Keys of config.profiles (missing/malformed file -> []). +const listOmoProfiles = (filePath) => { + if (typeof filePath !== 'string' || !isFileSync(filePath)) return []; + try { + const parsed = parseJsonText(fs.readFileSync(filePath, 'utf8')); + if (!isPlainObject(parsed) || !isPlainObject(parsed.profiles)) return []; + return Object.keys(parsed.profiles); + } catch { + return []; + } +}; + +// Whole profiles. object, or null (missing/malformed file, invalid name, +// absent profile). Read-side never throws, matching getOmoConfigBlock. +const getOmoProfile = (filePath, name) => { + if (typeof filePath !== 'string' || !isFileSync(filePath)) return null; + const safeName = sanitizeConfigProfileName(name); + if (!safeName) return null; + try { + const parsed = parseJsonText(fs.readFileSync(filePath, 'utf8')); + if (!isPlainObject(parsed) || !isPlainObject(parsed.profiles)) return null; + const profile = parsed.profiles[safeName]; + return isPlainObject(profile) ? profile : null; + } catch { + return null; + } +}; + +// Writes profiles..[opencode] as one surgical jsonc edit, preserving any +// sibling keys of the profile. Invalid profile name -> throws (m-3 decision, +// aligned with getOpenAgentProfilePath's null -> 400 path in server/index.js). +const setOmoProfile = (filePath, name, block) => { + const safeName = sanitizeConfigProfileName(name); + if (!safeName) { + throw new Error(`Invalid omo profile name: ${JSON.stringify(name)}`); + } + return writeOmoBlock(filePath, block, `profiles.${safeName}.[opencode]`); +}; + +// Removes profiles. via jsonc modify with an undefined value (jsonc-parser +// deletion semantics, verified empirically). Missing file or absent profile is a +// no-op; invalid profile name -> throws. +const deleteOmoProfile = (filePath, name) => { + const safeName = sanitizeConfigProfileName(name); + if (!safeName) { + throw new Error(`Invalid omo profile name: ${JSON.stringify(name)}`); + } + const targetPath = assertOmoPath(filePath); + if (!isFileSync(targetPath)) { + return { path: targetPath, removed: false }; + } + const text = fs.readFileSync(targetPath, 'utf8'); + const parsed = parseJsonText(text); + if (!isPlainObject(parsed)) { + throw new Error(`omo config root must be a JSONC object: ${targetPath}`); + } + const removed = isPlainObject(parsed.profiles) && Object.prototype.hasOwnProperty.call(parsed.profiles, safeName); + const edits = jsoncParser.modify(text, ['profiles', safeName], undefined, { + formattingOptions: OMO_JSONC_FORMATTING_OPTIONS + }); + if (edits.length > 0) { + atomicWriteTextSync(targetPath, jsoncParser.applyEdits(text, edits), 'utf8'); + } + return { path: targetPath, removed }; +}; + const detectSingleProvider = (rule, options = {}) => { const roots = resolveRoots({ roots: options.roots, customPaths: options.customPaths }); const candidates = buildCandidatesForRule(rule, roots); @@ -756,6 +900,11 @@ module.exports = { getOmoSearchRoots, getOmoConfigBlock, getResolvedActiveProfile, + writeOmoBlock, + listOmoProfiles, + getOmoProfile, + setOmoProfile, + deleteOmoProfile, createProviderDetectionResult, detectSingleProvider, detectProviders, From 6c3b43b97ea83869a76470d24f37a7a11ea2b65f Mon Sep 17 00:00:00 2001 From: zswll2 Date: Sun, 2 Aug 2026 13:15:00 +0800 Subject: [PATCH 06/26] feat(server): prioritize ~/.omo/omo.jsonc in path resolution Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- server/index.js | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/server/index.js b/server/index.js index 20120d05..7d01446a 100644 --- a/server/index.js +++ b/server/index.js @@ -727,6 +727,17 @@ const getPaths = () => { }; const getOhMyOpenCodeConfigPath = () => { + // OMO-specific resolution ONLY (Todo 5) — must NOT touch getPaths() + // candidates: getPaths().current feeds 20+ opencode config reads/writes + // (loadConfig/saveConfig, /api/config, backup/restore), and saveConfig's + // whole-file JSON.stringify would overwrite omo.jsonc (C-A data loss). + // ~/.omo/omo.jsonc first, then ~/.omo/omo.json, then legacy fallback. + const omoDir = path.join(os.homedir(), '.omo'); + const omoJsonc = path.join(omoDir, 'omo.jsonc'); + const omoJson = path.join(omoDir, 'omo.json'); + if (fs.existsSync(omoJsonc)) return omoJsonc; + if (fs.existsSync(omoJson)) return omoJson; + // Legacy fallback (deprioritized): oh-my-openagent.json / oh-my-opencode.json const cp = getConfigPath(); if (!cp) return null; const dir = path.dirname(cp); @@ -2148,6 +2159,7 @@ function loadOhMyOpenCodeConfig() { } function saveOhMyOpenCodeConfig(config) { + // TODO: unused — do not wire to omo.jsonc (whole-file overwrite would corrupt); route through writeOmoBlock if ever reused const configPath = getOhMyOpenCodeConfigPath(); if (!configPath) throw new Error('No opencode config path found'); atomicWriteFileSync(configPath, JSON.stringify(config, null, 2)); @@ -2159,7 +2171,12 @@ function getProviderSearchRoots() { function detectConfigProviders() { return configProviders.detectProviders({ - roots: getProviderSearchRoots() + // MINOR-8: omo roots FIRST — guarantees E1 activePath prefers + // ~/.omo/omo.jsonc. If omo roots were appended after getSearchRoots(), + // cwd's legacy oh-my-openagent.json would become existing[0] + // (config-providers.js:320) and silently re-activate the C1 failure + // class in legacy-present environments. + roots: [...configProviders.getOmoSearchRoots(), ...getSearchRoots()] }); } @@ -5589,7 +5606,10 @@ module.exports = { savePoolMetadata, loadStudioConfig, saveStudioConfig, - buildAccountPool + buildAccountPool, + getPaths, + getSearchRoots, + getOhMyOpenCodeConfigPath }; app.get('/api/prompts/global', (req, res) => { const cp = getConfigPath(); From 02f375696449e768221c10e092760b7a1c6b44f9 Mon Sep 17 00:00:00 2001 From: zswll2 Date: Sun, 2 Aug 2026 13:17:56 +0800 Subject: [PATCH 07/26] test(config-providers): jsonc preservation and profiles CRUD Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- server/lib/config-providers.test.js | 144 ++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/server/lib/config-providers.test.js b/server/lib/config-providers.test.js index 6e46e773..9dfb26d7 100644 --- a/server/lib/config-providers.test.js +++ b/server/lib/config-providers.test.js @@ -426,3 +426,147 @@ describe('omo config detection and [opencode] block reading', () => { expect(outsideDiagnostic.details.paths).toEqual([omoPath]); }); }); + +describe('omo profiles jsonc-fidelity writes and CRUD', () => { + it('preserves comment header, $schema, _migrations and existing profiles through setOmoProfile', () => { + const tempDir = makeTempDir(); + const omoPath = path.join(tempDir, 'omo.jsonc'); + fs.writeFileSync(omoPath, [ + '// OMO configuration', + '{', + ' "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json",', + ' "[opencode]": { "theme": "dark" },', + ' "_migrations": [', + ' "2026-07-opencode-config-unification",', + ' "2026-08-reasoning-unification"', + ' ],', + ' "profiles": {', + ' "existing": {', + ' "[opencode]": { "model": "fast" }', + ' }', + ' }', + '}', + ].join('\n')); + + const result = providers.setOmoProfile(omoPath, 'work', { theme: 'light' }); + + const raw = fs.readFileSync(omoPath, 'utf8'); + expect(raw).toContain('// OMO configuration'); + expect(raw).toContain('"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json"'); + expect(raw).toContain('2026-07-opencode-config-unification'); + expect(raw).toContain('2026-08-reasoning-unification'); + + const parsed = providers.parseJsonText(raw); + expect(parsed['[opencode]']).toEqual({ theme: 'dark' }); + expect(parsed._migrations).toEqual([ + '2026-07-opencode-config-unification', + '2026-08-reasoning-unification' + ]); + expect(parsed.profiles.existing).toEqual({ '[opencode]': { model: 'fast' } }); + expect(parsed.profiles.work).toEqual({ '[opencode]': { theme: 'light' } }); + expect(result).toEqual({ path: omoPath, created: false }); + }); + + it('creates a skeleton omo.jsonc with schema header when the file is missing', () => { + const tempDir = makeTempDir(); + const omoPath = path.join(tempDir, 'omo.jsonc'); + + const result = providers.setOmoProfile(omoPath, 'work', { theme: 'light' }); + expect(result).toEqual({ path: omoPath, created: true }); + + const raw = fs.readFileSync(omoPath, 'utf8'); + expect(raw).toContain('// OMO configuration'); + expect(raw).toContain('"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json"'); + expect(providers.getOmoConfigBlock(omoPath)).toEqual({}); + expect(providers.getOmoProfile(omoPath, 'work')).toEqual({ '[opencode]': { theme: 'light' } }); + }); + + it('creates, lists, reads, rewrites and deletes omo profiles', () => { + const tempDir = makeTempDir(); + const omoPath = path.join(tempDir, 'omo.jsonc'); + + providers.setOmoProfile(omoPath, 'work', { theme: 'light' }); + expect(providers.listOmoProfiles(omoPath)).toEqual(['work']); + expect(providers.getOmoProfile(omoPath, 'work')).toEqual({ '[opencode]': { theme: 'light' } }); + + providers.setOmoProfile(omoPath, 'home', { model: 'fast' }); + expect([...providers.listOmoProfiles(omoPath)].sort()).toEqual(['home', 'work']); + expect(providers.getOmoProfile(omoPath, 'home')).toEqual({ '[opencode]': { model: 'fast' } }); + + providers.setOmoProfile(omoPath, 'work', { theme: 'dark', model: 'fast' }); + expect(providers.getOmoProfile(omoPath, 'work')).toEqual({ '[opencode]': { theme: 'dark', model: 'fast' } }); + + const deleted = providers.deleteOmoProfile(omoPath, 'work'); + expect(deleted).toEqual({ path: omoPath, removed: true }); + expect(providers.listOmoProfiles(omoPath)).toEqual(['home']); + expect(providers.getOmoProfile(omoPath, 'work')).toBeNull(); + + const deletedAgain = providers.deleteOmoProfile(omoPath, 'work'); + expect(deletedAgain).toEqual({ path: omoPath, removed: false }); + expect(providers.listOmoProfiles(omoPath)).toEqual(['home']); + }); + + it('removes only the targeted profile, leaving header, $schema, _migrations and sibling profiles intact', () => { + const tempDir = makeTempDir(); + const omoPath = path.join(tempDir, 'omo.jsonc'); + fs.writeFileSync(omoPath, [ + '// OMO configuration', + '{', + ' "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json",', + ' "[opencode]": { "theme": "dark" },', + ' "_migrations": ["2026-08-reasoning-unification"],', + ' "profiles": {', + ' "work": { "[opencode]": { "theme": "light" } },', + ' "keep": { "[opencode]": { "model": "fast" } }', + ' }', + '}', + ].join('\n')); + + const deleted = providers.deleteOmoProfile(omoPath, 'work'); + expect(deleted).toEqual({ path: omoPath, removed: true }); + + const raw = fs.readFileSync(omoPath, 'utf8'); + expect(raw).toContain('// OMO configuration'); + expect(raw).toContain('"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json"'); + expect(raw).toContain('2026-08-reasoning-unification'); + expect(raw).toContain('"keep"'); + expect(raw).not.toContain('"work"'); + + const parsed = providers.parseJsonText(raw); + expect(parsed['[opencode]']).toEqual({ theme: 'dark' }); + expect(parsed._migrations).toEqual(['2026-08-reasoning-unification']); + expect(parsed.profiles.keep).toEqual({ '[opencode]': { model: 'fast' } }); + expect(providers.getOmoProfile(omoPath, 'work')).toBeNull(); + }); + + it('returns {} when an omo config file has no [opencode] block', () => { + const tempDir = makeTempDir(); + const omoPath = path.join(tempDir, 'omo.jsonc'); + fs.writeFileSync(omoPath, [ + '// OMO configuration', + '{', + ' "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json",', + ' "_migrations": ["2026-08-reasoning-unification"],', + ' "profiles": { "work": { "[opencode]": { "theme": "dark" } } }', + '}', + ].join('\n')); + + expect(providers.getOmoConfigBlock(omoPath)).toEqual({}); + }); + + it('throws on invalid profile names for write entry points', () => { + const tempDir = makeTempDir(); + const omoPath = path.join(tempDir, 'omo.jsonc'); + fs.writeFileSync(omoPath, '{ "[opencode]": {} }'); + + for (const badName of ['', ' ', '!!!', '#$%', '.json', undefined, null]) { + expect(() => providers.setOmoProfile(omoPath, badName, {})).toThrow(/Invalid omo profile name/); + expect(() => providers.deleteOmoProfile(omoPath, badName)).toThrow(/Invalid omo profile name/); + } + expect(() => providers.writeOmoBlock(omoPath, {}, 'profiles..[opencode]')).toThrow(/Invalid omo block key/); + + // read-side entry points never throw on invalid names + expect(providers.getOmoProfile(omoPath, '!!!')).toBeNull(); + expect(providers.listOmoProfiles(omoPath)).toEqual([]); + }); +}); From 054c0711f0de5a018b056251b407f29faf9d8f40 Mon Sep 17 00:00:00 2001 From: zswll2 Date: Sun, 2 Aug 2026 13:19:05 +0800 Subject: [PATCH 08/26] refactor(profile-manager): replace symlink switching with omo.jsonc profiles blocks Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- server/profile-manager.js | 125 ++++++++++++++++---------------------- 1 file changed, 54 insertions(+), 71 deletions(-) diff --git a/server/profile-manager.js b/server/profile-manager.js index 883e50f6..a301bcf7 100644 --- a/server/profile-manager.js +++ b/server/profile-manager.js @@ -1,98 +1,81 @@ -const fs = require('fs'); const path = require('path'); const os = require('os'); +const configProviders = require('./lib/config-providers'); -const HOME_DIR = os.homedir(); -const OPENCODE_DIR = path.join(HOME_DIR, '.config', 'opencode'); -const PROFILES_DIR = path.join(HOME_DIR, '.config', 'opencode-profiles'); +// Profiles live in the ~/.omo/omo.jsonc "profiles" block (Todo 7: symlink +// mechanism replaced by bake-only activation). os.homedir() respects $HOME on +// POSIX, so sandboxed QA with HOME=$(mktemp -d) works out of the box. +const OMO_CONFIG_PATH = path.join(os.homedir(), '.omo', 'omo.jsonc'); +// Matches the legacy contract (tests assert these exact rejections): names must +// be non-empty strings without path separators or dot-navigation. Names are now +// JSON keys inside profiles (never filesystem paths), but the checks stay. function safeName(name) { - if (!name || typeof name !== 'string' || name.includes('/') || name.includes('\\')) { + if (typeof name !== 'string' || name === '' || name.includes('/') || name.includes('\\')) { throw new Error('Invalid profile name'); } - const target = path.resolve(PROFILES_DIR, name); - if (path.dirname(target) !== path.resolve(PROFILES_DIR)) { + if (name === '.' || name === '..') { throw new Error('Invalid profile name'); } return name; } -if (!fs.existsSync(PROFILES_DIR)) { - fs.mkdirSync(PROFILES_DIR, { recursive: true }); -} - -function isSymlink(filepath) { - try { - return fs.lstatSync(filepath).isSymbolicLink(); - } catch { - return false; - } -} - -function init() { - const defaultProfilePath = path.join(PROFILES_DIR, 'default'); - - if (fs.existsSync(OPENCODE_DIR) && !isSymlink(OPENCODE_DIR)) { - if (!fs.existsSync(defaultProfilePath)) { - console.log('[Profiles] Migrating existing config to "default" profile'); - fs.renameSync(OPENCODE_DIR, defaultProfilePath); - fs.symlinkSync(defaultProfilePath, OPENCODE_DIR, 'junction'); - } - } else if (!fs.existsSync(OPENCODE_DIR) && !isSymlink(OPENCODE_DIR)) { - fs.mkdirSync(defaultProfilePath, { recursive: true }); - fs.symlinkSync(defaultProfilePath, OPENCODE_DIR, 'junction'); - } -} - function listProfiles() { - init(); - const profiles = fs.readdirSync(PROFILES_DIR).filter(f => { - return fs.statSync(path.join(PROFILES_DIR, f)).isDirectory(); - }); - - let active = null; - if (isSymlink(OPENCODE_DIR)) { - const target = fs.readlinkSync(OPENCODE_DIR); - active = path.basename(target); - } else { - active = 'default (unmanaged)'; - } - - return { profiles, active }; + return { + profiles: configProviders.listOmoProfiles(OMO_CONFIG_PATH), + // Todo 1 chain: OMO_PROFILE > OCX_PROFILE > OPENCODE_CONFIG_DIR suffix; + // null when none is resolved (undefined normalized to null). + active: configProviders.getResolvedActiveProfile() || null + }; } function createProfile(name) { - safeName(name); - const dir = path.join(PROFILES_DIR, name); - if (fs.existsSync(dir)) throw new Error('Profile already exists'); - fs.mkdirSync(dir, { recursive: true }); + const safe = safeName(name); + // setOmoProfile writes profiles..[opencode] surgically and creates the + // omo.jsonc skeleton first when the file does not exist yet (Todo 3). + configProviders.setOmoProfile(OMO_CONFIG_PATH, safe, {}); return { success: true }; } function deleteProfile(name) { - safeName(name); - const { active } = listProfiles(); - if (name === active) throw new Error('Cannot delete active profile'); - if (name === 'default') throw new Error('Cannot delete default profile'); - - const dir = path.join(PROFILES_DIR, name); - if (fs.existsSync(dir)) { - fs.rmSync(dir, { recursive: true, force: true }); + const safe = safeName(name); + const active = configProviders.getResolvedActiveProfile() || null; + if (safe === active) { + throw new Error('Cannot delete active profile'); } - return { success: true }; + // Read-side existence check first: deleting an absent profile (incl. files + // with no "profiles" block) must no-op, not hit jsonc-parser's + // "Can not delete in empty document" on the write-side delete. + if (!configProviders.getOmoProfile(OMO_CONFIG_PATH, safe)) { + return { success: true, removed: false }; + } + configProviders.deleteOmoProfile(OMO_CONFIG_PATH, safe); + return { success: true, removed: true }; } function activateProfile(name) { - safeName(name); - const target = path.join(PROFILES_DIR, name); - if (!fs.existsSync(target)) throw new Error('Profile not found'); - - if (fs.existsSync(OPENCODE_DIR)) { - fs.rmSync(OPENCODE_DIR, { recursive: true, force: true }); + const safe = safeName(name); + const profile = configProviders.getOmoProfile(OMO_CONFIG_PATH, safe); + if (!profile) { + throw new Error('Profile not found'); } - - fs.symlinkSync(target, OPENCODE_DIR, 'junction'); - return { success: true }; + + // M-C: baking merges only [opencode] back into the top-level [opencode] + // block. A profile carrying any other top-level key (agents/categories/ + // models/[senpi]/[codex] — all allowed by OmoConfigProfileSchema) would lose + // that content silently, so refuse the bake and leave profile + file intact. + const foreignKeys = Object.keys(profile).filter((key) => key !== '[opencode]'); + if (foreignKeys.length > 0) { + throw new Error(`profile 含非 [opencode] 键,无法 bake: ${foreignKeys.join(', ')}`); + } + + const merged = configProviders.deepMergePreservingUnknown( + configProviders.getOmoConfigBlock(OMO_CONFIG_PATH), + profile['[opencode]'] || {} + ); + configProviders.writeOmoBlock(OMO_CONFIG_PATH, merged, '[opencode]'); + configProviders.deleteOmoProfile(OMO_CONFIG_PATH, safe); + return { success: true, message: `已激活 ${safe}` }; } module.exports = { @@ -101,4 +84,4 @@ module.exports = { createProfile, deleteProfile, activateProfile -}; \ No newline at end of file +}; From 3c71749c37dc0dc6f26af712dce4770d935812df Mon Sep 17 00:00:00 2001 From: zswll2 Date: Sun, 2 Aug 2026 13:22:52 +0800 Subject: [PATCH 09/26] test(profile-manager): cover omo profiles block semantics Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- server/profile-manager.test.js | 168 +++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/server/profile-manager.test.js b/server/profile-manager.test.js index 3112fabc..fbc65eda 100644 --- a/server/profile-manager.test.js +++ b/server/profile-manager.test.js @@ -1,5 +1,8 @@ const { test } = require('node:test'); const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); const { safeName } = require('./profile-manager'); test('safeName rejects traversal and dot names', () => { @@ -13,3 +16,168 @@ test('safeName accepts direct-child names', () => { assert.strictEqual(safeName(ok), ok); } }); + +// --------------------------------------------------------------------------- +// Todo 8: omo profiles block semantics. profile-manager.js captures +// OMO_CONFIG_PATH = path.join(os.homedir(), '.omo', 'omo.jsonc') at require +// time, so every sandboxed case reloads the module fresh with process.env.HOME +// pointed at a temp home. os.homedir() reads $HOME live (verified on Node 24), +// but the PATH constant is frozen at load — hence the require.cache clearing. +// --------------------------------------------------------------------------- + +function makeTempHome(t) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-test-')); + fs.mkdirSync(path.join(home, '.omo'), { recursive: true }); + t.after(() => { + try { + fs.rmSync(home, { recursive: true, force: true }); + } catch { + // no-op + } + }); + return home; +} + +function writeOmo(home, doc) { + const file = path.join(home, '.omo', 'omo.jsonc'); + fs.writeFileSync(file, JSON.stringify(doc, null, 2), 'utf8'); + return file; +} + +// Hermetic reload: active-source vars (OMO_PROFILE / OCX_PROFILE / +// OPENCODE_CONFIG_DIR) are cleared unless explicitly provided, then the module +// is re-required so OMO_CONFIG_PATH resolves under the temp HOME. +function loadProfileManager(home, env = {}) { + for (const key of ['HOME', 'OMO_PROFILE', 'OCX_PROFILE', 'OPENCODE_CONFIG_DIR']) { + if (key in env) process.env[key] = env[key]; + else delete process.env[key]; + } + process.env.HOME = home; + for (const mod of ['./profile-manager', './lib/config-providers']) { + delete require.cache[require.resolve(path.join(__dirname, mod))]; + } + return require('./profile-manager'); +} + +test('safeName rejects non-string values', () => { + for (const bad of [undefined, 123, true, ['x'], {}]) { + assert.throws(() => safeName(bad), /Invalid profile name/, `should reject ${JSON.stringify(bad)}`); + } +}); + +test('listProfiles reports empty state when no profiles exist', (t) => { + const home = makeTempHome(t); + writeOmo(home, { '[opencode]': {} }); + const pm = loadProfileManager(home); + assert.deepStrictEqual(pm.listProfiles(), { profiles: [], active: null }); +}); + +test('createProfile writes the profile block into omo.jsonc', (t) => { + const home = makeTempHome(t); + const omoFile = writeOmo(home, { '[opencode]': {} }); + const pm = loadProfileManager(home); + assert.deepStrictEqual(pm.createProfile('work'), { success: true }); + const parsed = JSON.parse(fs.readFileSync(omoFile, 'utf8')); + assert.ok(parsed.profiles && parsed.profiles.work, 'profiles.work should exist'); + assert.deepStrictEqual(parsed.profiles.work, { '[opencode]': {} }); + assert.deepStrictEqual(pm.listProfiles().profiles, ['work']); +}); + +test('deleteProfile rejects deleting the active profile', (t) => { + const home = makeTempHome(t); + writeOmo(home, { profiles: { work: { '[opencode]': {} } } }); + const pm = loadProfileManager(home, { OMO_PROFILE: 'work' }); + assert.throws(() => pm.deleteProfile('work'), /Cannot delete active profile/); + assert.deepStrictEqual(pm.listProfiles().profiles, ['work'], 'profile survives the rejected delete'); +}); + +test('deleteProfile removes a non-active profile', (t) => { + const home = makeTempHome(t); + const omoFile = writeOmo(home, { profiles: { work: { '[opencode]': {} } } }); + const pm = loadProfileManager(home, { OMO_PROFILE: 'personal' }); + assert.deepStrictEqual(pm.deleteProfile('work'), { success: true, removed: true }); + const parsed = JSON.parse(fs.readFileSync(omoFile, 'utf8')); + assert.ok(!parsed.profiles || !parsed.profiles.work, 'source profile deleted'); +}); + +test('activateProfile bakes [opencode], deletes source block, touches no other fs path', (t) => { + const home = makeTempHome(t); + const omoFile = writeOmo(home, { + '[opencode]': { model: 'old', nested: { keep: 1 } }, + profiles: { work: { '[opencode]': { model: 'new', temperature: 0.7, nested: { extra: true } } } } + }); + // AC8: unrelated filesystem paths that activation must NOT delete/rename + // (a real directory here — NOT a symlink, which the old implementation + // would have rmSync'd; bake-only must leave it byte-identical). + const configDir = path.join(home, '.config', 'opencode'); + const configFile = path.join(configDir, 'opencode.jsonc'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configFile, '{"schema":"x"}', 'utf8'); + const notesFile = path.join(home, 'notes.txt'); + fs.writeFileSync(notesFile, 'keep me', 'utf8'); + + const pm = loadProfileManager(home); + assert.deepStrictEqual(pm.activateProfile('work'), { success: true, message: '已激活 work' }); + + const parsed = JSON.parse(fs.readFileSync(omoFile, 'utf8')); + assert.strictEqual(parsed['[opencode]'].model, 'new', 'profile [opencode] baked into top-level'); + assert.strictEqual(parsed['[opencode]'].temperature, 0.7, 'non-conflicting profile key copied'); + assert.deepStrictEqual(parsed['[opencode]'].nested, { keep: 1, extra: true }, 'deep merge preserves unknown keys'); + assert.ok(!parsed.profiles || !parsed.profiles.work, 'source profile block deleted'); + // AC8: no other filesystem path deleted/renamed + assert.strictEqual(fs.readFileSync(configFile, 'utf8'), '{"schema":"x"}'); + assert.ok(fs.existsSync(configDir) && fs.statSync(configDir).isDirectory(), 'config dir still exists'); + assert.strictEqual(fs.readFileSync(notesFile, 'utf8'), 'keep me'); + assert.deepStrictEqual(pm.listProfiles().profiles, []); +}); + +test('active resolves from OMO_PROFILE (MINOR-8 source #1)', (t) => { + const home = makeTempHome(t); + writeOmo(home, { profiles: { work: { '[opencode]': {} } } }); + const pm = loadProfileManager(home, { OMO_PROFILE: 'work' }); + assert.strictEqual(pm.listProfiles().active, 'work'); +}); + +test('active resolves from OCX_PROFILE when OMO_PROFILE unset (MINOR-8 source #2)', (t) => { + const home = makeTempHome(t); + writeOmo(home, { profiles: { personal: { '[opencode]': {} } } }); + const pm = loadProfileManager(home, { OCX_PROFILE: 'personal' }); + assert.strictEqual(pm.listProfiles().active, 'personal'); +}); + +test('active resolves from OPENCODE_CONFIG_DIR /profiles/ suffix (MINOR-8 source #3)', (t) => { + const home = makeTempHome(t); + writeOmo(home, { profiles: { work: { '[opencode]': {} } } }); + const pm = loadProfileManager(home, { OPENCODE_CONFIG_DIR: '/home/u/.config/opencode/profiles/work' }); + assert.strictEqual(pm.listProfiles().active, 'work'); +}); + +test('active precedence OMO_PROFILE > OCX_PROFILE > OPENCODE_CONFIG_DIR (MINOR-8)', (t) => { + const home = makeTempHome(t); + writeOmo(home, { + profiles: { a: { '[opencode]': {} }, b: { '[opencode]': {} }, c: { '[opencode]': {} } } + }); + let pm = loadProfileManager(home, { + OMO_PROFILE: 'a', + OCX_PROFILE: 'b', + OPENCODE_CONFIG_DIR: '/x/profiles/c' + }); + assert.strictEqual(pm.listProfiles().active, 'a'); + pm = loadProfileManager(home, { OCX_PROFILE: 'b', OPENCODE_CONFIG_DIR: '/x/profiles/c' }); + assert.strictEqual(pm.listProfiles().active, 'b'); + pm = loadProfileManager(home, { OPENCODE_CONFIG_DIR: '/x/profiles/c' }); + assert.strictEqual(pm.listProfiles().active, 'c'); +}); + +test('activateProfile rejects non-[opencode] top-level keys and preserves profile + file (M-C)', (t) => { + const home = makeTempHome(t); + const omoFile = writeOmo(home, { + '[opencode]': { model: 'old' }, + profiles: { work: { agents: { x: 1 }, '[opencode]': { model: 'new' } } } + }); + const before = fs.readFileSync(omoFile, 'utf8'); + const pm = loadProfileManager(home); + assert.throws(() => pm.activateProfile('work'), /无法 bake|agents/); + assert.strictEqual(fs.readFileSync(omoFile, 'utf8'), before, 'file byte-identical after reject'); + assert.deepStrictEqual(pm.listProfiles().profiles, ['work'], 'profile still present'); +}); From e6e028e271f8b669c6d190c87237ea147caeb5fc Mon Sep 17 00:00:00 2001 From: zswll2 Date: Sun, 2 Aug 2026 13:31:20 +0800 Subject: [PATCH 10/26] feat(server): adapt provider APIs to omo block semantics Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- server/index.js | 248 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 211 insertions(+), 37 deletions(-) diff --git a/server/index.js b/server/index.js index 7d01446a..e5b192da 100644 --- a/server/index.js +++ b/server/index.js @@ -2357,6 +2357,67 @@ function buildWriteContent({ provider, body = {}, parsed, targetPath }) { }; } +// ============================================ +// OMO BLOCK SEMANTICS (Todo 6) +// ============================================ + +const OMO_CONFIG_BASENAMES = ['omo.jsonc', 'omo.json']; + +function isOmoConfigPath(targetPath) { + return OMO_CONFIG_BASENAMES.includes(path.basename(targetPath || '')); +} + +// MAJOR-B: OMO payload gate, applied to the INCOMING payload itself (before any +// wrap into {"[opencode]": block}). Rejects non-plain-object payloads (array/ +// string — defense in depth: parseProviderInput already rejects those) and +// payloads carrying document-level control keys ([opencode]/profiles/$schema/ +// _migrations — the user pasted a whole omo doc). Keys INSIDE the [opencode] +// block are unconstrained by design (plugin: [opencode] = record(string, +// unknown)), so no whitelist is applied there. Failure -> 400, nothing written. +function gateOmoBlockPayload(config) { + const controlKeys = ['[opencode]', 'profiles', '$schema', '_migrations']; + if (!configProviders.isPlainObject(config)) { + return { + ok: false, + diagnostics: [{ + severity: 'error', + code: 'OMO_PAYLOAD_NOT_PLAIN_OBJECT', + message: `OMO config payload must be a plain object, got ${Array.isArray(config) ? 'array' : typeof config}` + }] + }; + } + const present = controlKeys.filter((key) => Object.prototype.hasOwnProperty.call(config, key)); + if (present.length > 0) { + return { + ok: false, + diagnostics: [{ + severity: 'error', + code: 'OMO_DOCUMENT_LEVEL_KEYS', + message: 'OMO payload contains document-level control keys; send the bare [opencode] block instead', + details: { keys: present } + }] + }; + } + return { ok: true }; +} + +// M-F: response shape { profiles: [{name, path, active}], activePath }. +// active comes from getResolvedActiveProfile() (Todo 1 env chain: +// OMO_PROFILE > OCX_PROFILE > OPENCODE_CONFIG_DIR suffix) — never reimplemented. +function buildOmoProfilesResponse(provider) { + const activePath = provider ? provider.activePath : null; + const activeName = configProviders.getResolvedActiveProfile() || null; + const names = activePath ? configProviders.listOmoProfiles(activePath) : []; + return { + profiles: names.map((name) => ({ + name, + path: activePath, + active: name === activeName + })), + activePath + }; +} + function loadProviderDetails(provider) { if (!provider || !provider.activePath || !provider.exists) { return { @@ -2370,11 +2431,27 @@ function loadProviderDetails(provider) { try { const raw = configProviders.readConfigTextSync(provider.activePath, 'utf8'); const stats = fs.statSync(provider.activePath); - const config = configProviders.parseJsonText(raw); const revision = configProviders.buildContentRevision({ content: raw, stats }); + // OMO block semantics: expose ONLY the [opencode] block. MINOR-1 pins raw + // to the BARE block JSON (JSON.stringify(block, null, 2)) — the editor + // sends detail.raw straight back on save (provider-detail.tsx:197-200), + // so a whole-doc raw would trip the MAJOR-B control-key gate. revision + // stays the WHOLE-FILE hash (M3: compareExpectedRevision/ + // getCurrentRevisionForPath hash the whole file; a block-level hash + // would 409 on every save). Legacy (non-omo) paths keep whole-file + // semantics. + let config; + let detailRaw; + if (provider.id === configProviders.PROVIDER_IDS.OH_MY_OPENAGENT && isOmoConfigPath(provider.activePath)) { + config = configProviders.getOmoConfigBlock(provider.activePath); + detailRaw = JSON.stringify(config, null, 2); + } else { + config = configProviders.parseJsonText(raw); + detailRaw = raw; + } return { config, - raw, + raw: detailRaw, revision, diagnostics: provider.diagnostics || [] }; @@ -2534,11 +2611,7 @@ app.get('/api/config-providers/:id/profiles', (req, res) => { const guard = requireOpenAgentProvider(provider); if (!guard.ok) return res.status(guard.status).json(guard.payload); - res.json({ - profileDir: configProviders.getOpenAgentProfileDir(provider), - activePath: configProviders.getOpenAgentDefaultActivePath(provider), - profiles: listOpenAgentProfiles(provider) - }); + res.json(buildOmoProfilesResponse(provider)); } catch (error) { res.status(500).json({ error: error.message }); } @@ -2550,32 +2623,48 @@ app.post('/api/config-providers/:id/profiles', (req, res) => { const guard = requireOpenAgentProvider(provider); if (!guard.ok) return res.status(guard.status).json(guard.payload); - const profilePath = configProviders.getOpenAgentProfilePath(provider, req.body && req.body.name); - if (!profilePath) { + const name = req.body && req.body.name; + if (typeof name !== 'string' || name === '') { return res.status(400).json({ success: false, diagnostics: [{ severity: 'error', code: 'OPENAGENT_PROFILE_NAME_REQUIRED', - message: 'OpenAgent config profile name is required' + message: 'OpenAgent profile name is required' }] }); } - const content = typeof req.body?.raw === 'string' ? req.body.raw : '{}\n'; - const parsed = parseAndValidateProviderPayload(provider, { raw: content }); + const payload = (typeof req.body?.raw === 'string' || (req.body?.config && typeof req.body.config === 'object')) + ? req.body + : { raw: '{}\n' }; + const parsed = parseAndValidateProviderPayload(provider, payload); if (!parsed.ok) return res.status(400).json({ success: false, diagnostics: parsed.diagnostics }); - const pathSafety = validatePathWritable(profilePath); - if (!pathSafety.ok) return res.status(400).json({ success: false, diagnostics: pathSafety.diagnostics }); + const gate = gateOmoBlockPayload(parsed.config); + if (!gate.ok) return res.status(400).json({ success: false, diagnostics: gate.diagnostics }); + + let writeResult; + try { + // setOmoProfile writes profiles..[opencode] surgically (Todo 3) + // — never copies a file. + writeResult = configProviders.setOmoProfile(provider.activePath, name, parsed.config); + } catch (error) { + return res.status(400).json({ + success: false, + diagnostics: [{ + severity: 'error', + code: 'OPENAGENT_PROFILE_CREATE_FAILED', + message: error.message + }] + }); + } - const createResult = configProviders.createFileIfMissingSync(profilePath, parsed.raw, 'utf8'); - const updatedProvider = getProviderByIdOrNull(provider.id) || provider; res.json({ success: true, - created: createResult.created, - profile: buildOpenAgentProfileRecord(updatedProvider, profilePath, getCurrentRevisionForPath(configProviders.getOpenAgentDefaultActivePath(updatedProvider))), - profiles: listOpenAgentProfiles(updatedProvider) + created: writeResult.created, + path: writeResult.path, + profiles: buildOmoProfilesResponse(provider).profiles }); } catch (error) { res.status(500).json({ error: error.message }); @@ -2588,27 +2677,42 @@ app.post('/api/config-providers/:id/profiles/switch', (req, res) => { const guard = requireOpenAgentProvider(provider); if (!guard.ok) return res.status(guard.status).json(guard.payload); - const selected = resolveOpenAgentProfileSelection(provider, req.body || {}); - if (!selected.ok) return res.status(400).json({ success: false, diagnostics: selected.diagnostics }); - - const raw = configProviders.readConfigTextSync(selected.path, 'utf8'); - const parsed = parseAndValidateProviderPayload(provider, { raw }); - if (!parsed.ok) return res.status(400).json({ success: false, diagnostics: parsed.diagnostics }); + const name = req.body && req.body.name; + if (typeof name !== 'string' || name === '') { + return res.status(400).json({ + success: false, + diagnostics: [{ + severity: 'error', + code: 'OPENAGENT_PROFILE_NAME_REQUIRED', + message: 'OpenAgent profile name is required' + }] + }); + } - const activePath = configProviders.getOpenAgentDefaultActivePath(provider); - const pathSafety = validatePathWritable(activePath); - if (!pathSafety.ok) return res.status(400).json({ success: false, diagnostics: pathSafety.diagnostics }); + let result; + try { + // M2/MINOR-a: bake-only activation fully delegated to profileManager; + // M-C foreign-key rejection fires exactly once, there — not here. + result = profileManager.activateProfile(name); + } catch (error) { + return res.status(400).json({ + success: false, + diagnostics: [{ + severity: 'error', + code: 'OPENAGENT_PROFILE_ACTIVATE_FAILED', + message: error.message + }] + }); + } - configProviders.writeConfigTextAtomicSync(activePath, raw, 'utf8'); const updatedProvider = getProviderByIdOrNull(provider.id) || provider; const updatedDetails = loadProviderDetails(updatedProvider); res.json({ success: true, - path: activePath, - selectedPath: selected.path, + ...result, diagnostics: updatedDetails.diagnostics || [], revision: updatedDetails.revision, - profiles: listOpenAgentProfiles(updatedProvider) + profiles: buildOmoProfilesResponse(updatedProvider).profiles }); } catch (error) { res.status(500).json({ error: error.message }); @@ -2667,7 +2771,29 @@ app.post('/api/config-providers/:id/save', (req, res) => { targetPath: pathDecision.path }); - configProviders.writeConfigTextAtomicSync(pathDecision.path, writePayload.raw, 'utf8'); + if (provider.id === configProviders.PROVIDER_IDS.OH_MY_OPENAGENT && isOmoConfigPath(pathDecision.path)) { + // MAJOR-B: gate the incoming payload itself (before any wrap). + const gate = gateOmoBlockPayload(parsed.config); + if (!gate.ok) { + return res.status(400).json({ success: false, diagnostics: gate.diagnostics }); + } + // M3 round-trip: getOmoConfigBlock reads config["[opencode]"], so the + // bare editor block must be written under "[opencode]" or the next + // read loses the edit. raw -> replace the block (editor WYSIWYG); + // config -> deep-merge into the existing block so sibling agents and + // their enabled keys survive (mirrors buildWriteContent scoped to the + // block instead of the whole file). + let block = parsed.config; + if (typeof req.body?.raw !== 'string') { + block = configProviders.deepMergePreservingUnknown( + configProviders.getOmoConfigBlock(pathDecision.path), + parsed.config + ); + } + configProviders.writeOmoBlock(pathDecision.path, block, '[opencode]'); + } else { + configProviders.writeConfigTextAtomicSync(pathDecision.path, writePayload.raw, 'utf8'); + } const updatedProvider = getProviderByIdOrNull(provider.id) || provider; const updatedDetails = loadProviderDetails(updatedProvider); res.json({ @@ -2704,6 +2830,27 @@ app.post('/api/config-providers/:id/create', (req, res) => { return res.status(400).json({ success: false, diagnostics: parsed.diagnostics }); } + if (provider.id === configProviders.PROVIDER_IDS.OH_MY_OPENAGENT && isOmoConfigPath(pathDecision.path)) { + // MAJOR-A: OMO create delegates Todo 3's skeleton creation — + // writeOmoBlock writes the skeleton (comment header + $schema + + // {"[opencode]": {}}) when the file is missing, then lands the + // block. An existing file is rejected with 400 (never overwrite). + if (fs.existsSync(pathDecision.path)) { + return res.status(400).json({ + success: false, + diagnostics: [{ + severity: 'error', + code: 'OMO_CONFIG_EXISTS', + message: 'OMO config file already exists' + }] + }); + } + const gate = gateOmoBlockPayload(parsed.config); + if (!gate.ok) return res.status(400).json({ success: false, diagnostics: gate.diagnostics }); + configProviders.writeOmoBlock(pathDecision.path, parsed.config, '[opencode]'); + return res.json({ success: true, created: true, path: pathDecision.path }); + } + const createResult = configProviders.createFileIfMissingSync(pathDecision.path, parsed.raw, 'utf8'); res.json({ success: true, created: createResult.created, path: pathDecision.path }); } catch (error) { @@ -2754,7 +2901,22 @@ app.post('/api/config-providers/:id/import', (req, res) => { targetPath: pathDecision.path }); - configProviders.writeConfigTextAtomicSync(pathDecision.path, writePayload.raw, 'utf8'); + if (provider.id === configProviders.PROVIDER_IDS.OH_MY_OPENAGENT && isOmoConfigPath(pathDecision.path)) { + // MAJOR-A: OMO import never whole-file overwrites (C2). Gate first + // (same as save), then MINOR-5 merge into the existing block so + // existing agents survive. + const gate = gateOmoBlockPayload(parsed.config); + if (!gate.ok) { + return res.status(400).json({ success: false, diagnostics: gate.diagnostics }); + } + const merged = configProviders.deepMergePreservingUnknown( + configProviders.getOmoConfigBlock(pathDecision.path), + parsed.config + ); + configProviders.writeOmoBlock(pathDecision.path, merged, '[opencode]'); + } else { + configProviders.writeConfigTextAtomicSync(pathDecision.path, writePayload.raw, 'utf8'); + } res.json({ success: true, imported: true, @@ -2821,7 +2983,10 @@ app.post('/api/ohmyopencode', (req, res) => { const choices = agentPrefs.choices || []; const available = choices.find(c => c.available); if (available) { - if (!currentConfig.agents) currentConfig.agents = {}; + if (!configProviders.isPlainObject(currentConfig.agents)) currentConfig.agents = {}; + const existingAgent = configProviders.isPlainObject(currentConfig.agents[agentName]) + ? currentConfig.agents[agentName] + : {}; const agentConfig = { model: available.model }; if (available.thinking && available.thinking.type === 'enabled') { @@ -2832,7 +2997,9 @@ app.post('/api/ohmyopencode', (req, res) => { agentConfig.reasoning = { effort: available.reasoning.effort }; } - currentConfig.agents[agentName] = agentConfig; + // E6: field-level merge — enabled and other unknown keys survive; + // never a whole-agent replace. + currentConfig.agents[agentName] = configProviders.deepMergePreservingUnknown(existingAgent, agentConfig); } else if (choices.length > 0) { warnings.push(`No available model for agent "${agentName}"`); } @@ -2846,7 +3013,14 @@ app.post('/api/ohmyopencode', (req, res) => { if (!pathDecision.ok) { return res.status(400).json({ success: false, diagnostics: pathDecision.diagnostics }); } - configProviders.writeConfigTextAtomicSync(pathDecision.path, JSON.stringify(currentConfig, null, 2), 'utf8'); + // MINOR-3: write point pinned to writeOmoBlock for omo files — the old + // whole-file writeConfigTextAtomicSync(JSON.stringify) would clobber + // omo.jsonc (C2). Legacy (non-omo) paths keep their whole-file JSON write. + if (isOmoConfigPath(pathDecision.path)) { + configProviders.writeOmoBlock(pathDecision.path, currentConfig, '[opencode]'); + } else { + configProviders.writeConfigTextAtomicSync(pathDecision.path, JSON.stringify(currentConfig, null, 2), 'utf8'); + } const ohMyPath = pathDecision.path; triggerGitHubAutoSync(); From d140ef67904abeee89e06b8b38127de8f6c8d5ed Mon Sep 17 00:00:00 2001 From: zswll2 Date: Sun, 2 Aug 2026 13:37:07 +0800 Subject: [PATCH 11/26] feat(client): adapt profiles UI to omo block names Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- client-next/src/app/config/page.tsx | 2 +- client-next/src/components/provider-card.tsx | 2 +- .../src/components/provider-detail.tsx | 22 ++++++++----------- client-next/src/types/index.ts | 5 +---- 4 files changed, 12 insertions(+), 19 deletions(-) diff --git a/client-next/src/app/config/page.tsx b/client-next/src/app/config/page.tsx index 004a1142..78cafa56 100644 --- a/client-next/src/app/config/page.tsx +++ b/client-next/src/app/config/page.tsx @@ -93,7 +93,7 @@ function ProvidersTab() { Each provider manages its own config file. Import and export only work within the same provider. - OpenAgent keeps legacy oh-my-opencode naming without automatic migration. + OpenAgent: OMO 配置块(profiles.<name>),切换会把选中 profile 合并进当前配置并移除该 profile 块。 Slim validates tui.json as a companion file. Remote shell install and sync are not available. diff --git a/client-next/src/components/provider-card.tsx b/client-next/src/components/provider-card.tsx index 60eb3e0d..e998021b 100644 --- a/client-next/src/components/provider-card.tsx +++ b/client-next/src/components/provider-card.tsx @@ -20,7 +20,7 @@ const PROVIDER_ICONS: Record = { const PROVIDER_DESCRIPTIONS: Record = { opencode: "Standard OpenCode configuration file", - "oh-my-openagent": "OpenAgent plugin config. Legacy oh-my-opencode names are preserved and not auto-migrated.", + "oh-my-openagent": "OMO 配置块(profiles.),切换会把选中 profile 合并进当前配置并移除该 profile 块", "oh-my-opencode-slim": "Slim configuration. Validates tui.json as a companion file.", }; diff --git a/client-next/src/components/provider-detail.tsx b/client-next/src/components/provider-detail.tsx index 8c364848..e8ea73ca 100644 --- a/client-next/src/components/provider-detail.tsx +++ b/client-next/src/components/provider-detail.tsx @@ -107,8 +107,7 @@ export function ProviderDetailPanel({ providerId, onBack, onRefresh }: ProviderD const [exporting, setExporting] = useState(false); const [profiles, setProfiles] = useState([]); - const [profileDir, setProfileDir] = useState(null); - const [selectedProfilePath, setSelectedProfilePath] = useState(""); + const [selectedProfileName, setSelectedProfileName] = useState(""); const [switchingProfile, setSwitchingProfile] = useState(false); const [showProfileDialog, setShowProfileDialog] = useState(false); const [profileName, setProfileName] = useState(""); @@ -118,16 +117,14 @@ export function ProviderDetailPanel({ providerId, onBack, onRefresh }: ProviderD const loadProfiles = useCallback(async () => { if (!isOpenAgent) { setProfiles([]); - setProfileDir(null); - setSelectedProfilePath(""); + setSelectedProfileName(""); return; } const result = await getConfigProviderProfiles(providerId); setProfiles(result.profiles ?? []); - setProfileDir(result.profileDir ?? null); const activeProfile = result.profiles?.find((profile) => profile.active); - setSelectedProfilePath(activeProfile?.path ?? result.profiles?.[0]?.path ?? ""); + setSelectedProfileName(activeProfile?.name ?? result.profiles?.[0]?.name ?? ""); }, [isOpenAgent, providerId]); const loadDetail = useCallback(async () => { @@ -340,7 +337,7 @@ export function ProviderDetailPanel({ providerId, onBack, onRefresh }: ProviderD raw: rawText || "{}\n", }); setProfiles(result.profiles ?? []); - setSelectedProfilePath(result.profile.path); + setSelectedProfileName(profileName); setShowProfileDialog(false); setProfileName(""); toast.success("OpenAgent config profile created"); @@ -355,10 +352,10 @@ export function ProviderDetailPanel({ providerId, onBack, onRefresh }: ProviderD }; const handleSwitchProfile = async () => { - if (!selectedProfilePath || hasChanges) return; + if (!selectedProfileName || hasChanges) return; try { setSwitchingProfile(true); - const result = await switchConfigProviderProfile(providerId, { path: selectedProfilePath }); + const result = await switchConfigProviderProfile(providerId, { name: selectedProfileName }); setProfiles(result.profiles ?? []); setDiagnostics(result.diagnostics ?? []); toast.success("OpenAgent config switched"); @@ -514,7 +511,6 @@ export function ProviderDetailPanel({ providerId, onBack, onRefresh }: ProviderD

Save named OpenAgent config files and switch the active plugin config by copying one into place. - {profileDir && {profileDir}}

+ ) : isActive ? ( )} - {profile !== 'default' && !isActive && ( + {!isLegacy && profile !== 'default' && !isActive && ( ) : isActive ? ( ) : ( - ) : isActive ? ( ) : (