@vibe-forge/workspace-assets 0.8.4 → 0.9.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,174 @@
1
+ import { basename, dirname } from 'node:path'
2
+
3
+ import type {
4
+ AdapterAssetPlan,
5
+ AssetDiagnostic,
6
+ WorkspaceAssetAdapter,
7
+ WorkspaceAssetBundle,
8
+ WorkspaceMcpSelection,
9
+ WorkspaceSkillSelection
10
+ } from '@vibe-forge/types'
11
+
12
+ import { filterSkillAssets } from './document-assets'
13
+ import { isOpenCodeOverlayAsset } from './internal-types'
14
+
15
+ const resolveMcpServerSelection = (
16
+ bundle: WorkspaceAssetBundle,
17
+ selection: WorkspaceMcpSelection | undefined
18
+ ) => {
19
+ const include = selection?.include ?? (
20
+ bundle.defaultIncludeMcpServers.length > 0 ? bundle.defaultIncludeMcpServers : undefined
21
+ )
22
+ const exclude = selection?.exclude ?? (
23
+ bundle.defaultExcludeMcpServers.length > 0 ? bundle.defaultExcludeMcpServers : undefined
24
+ )
25
+
26
+ return {
27
+ include,
28
+ exclude
29
+ }
30
+ }
31
+
32
+ export function buildAdapterAssetPlan(params: {
33
+ adapter: WorkspaceAssetAdapter
34
+ bundle: WorkspaceAssetBundle
35
+ options: {
36
+ mcpServers?: WorkspaceMcpSelection
37
+ skills?: WorkspaceSkillSelection
38
+ promptAssetIds?: string[]
39
+ }
40
+ }): AdapterAssetPlan {
41
+ const diagnostics: AssetDiagnostic[] = []
42
+ const promptAssetIdSet = new Set(params.options.promptAssetIds ?? [])
43
+ const mcpSelection = resolveMcpServerSelection(params.bundle, params.options.mcpServers)
44
+ const selectedMcpServerNames = Object.keys(params.bundle.mcpServers).filter((name) => {
45
+ if (mcpSelection.include != null && !mcpSelection.include.includes(name)) return false
46
+ if (mcpSelection.exclude?.includes(name)) return false
47
+ return true
48
+ })
49
+ const mcpServers = Object.fromEntries(
50
+ selectedMcpServerNames.map((name) => [name, params.bundle.mcpServers[name].payload.config])
51
+ )
52
+
53
+ for (const assetId of promptAssetIdSet) {
54
+ diagnostics.push({
55
+ assetId,
56
+ adapter: params.adapter,
57
+ status: 'prompt',
58
+ reason: 'Mapped into the generated system prompt.'
59
+ })
60
+ }
61
+
62
+ for (const name of selectedMcpServerNames) {
63
+ diagnostics.push({
64
+ assetId: params.bundle.mcpServers[name].id,
65
+ adapter: params.adapter,
66
+ status: params.adapter === 'claude-code' ? 'native' : 'translated',
67
+ reason: params.adapter === 'claude-code'
68
+ ? 'Mapped into native MCP settings.'
69
+ : 'Translated into adapter-specific MCP configuration.'
70
+ })
71
+ }
72
+
73
+ for (const hookPlugin of params.bundle.hookPlugins) {
74
+ const nativeHookReason = params.adapter === 'claude-code'
75
+ ? 'Mapped into the isolated Claude Code native hooks bridge under .ai/.mock/.claude/settings.json.'
76
+ : params.adapter === 'codex'
77
+ ? 'Mapped into the isolated Codex native hooks bridge for SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, and Stop.'
78
+ : 'Mapped into the isolated OpenCode native hook plugin bridge under .ai/.mock/.config/opencode/plugins.'
79
+ diagnostics.push({
80
+ assetId: hookPlugin.id,
81
+ adapter: params.adapter,
82
+ status: 'native',
83
+ reason: nativeHookReason
84
+ })
85
+ }
86
+
87
+ const overlays: AdapterAssetPlan['overlays'] = []
88
+ if (params.adapter === 'opencode') {
89
+ const skillAssets = filterSkillAssets(params.bundle.skills, params.options.skills)
90
+ for (const skillAsset of skillAssets) {
91
+ overlays.push({
92
+ assetId: skillAsset.id,
93
+ kind: 'skill',
94
+ sourcePath: dirname(skillAsset.payload.definition.path),
95
+ targetPath: `skills/${basename(dirname(skillAsset.payload.definition.path))}`
96
+ })
97
+ diagnostics.push({
98
+ assetId: skillAsset.id,
99
+ adapter: 'opencode',
100
+ status: 'native',
101
+ reason: 'Mirrored into OPENCODE_CONFIG_DIR as a native skill.'
102
+ })
103
+ }
104
+
105
+ for (const asset of params.bundle.assets) {
106
+ if (!isOpenCodeOverlayAsset(asset)) continue
107
+ if (!asset.targets.includes('opencode')) continue
108
+
109
+ overlays.push({
110
+ assetId: asset.id,
111
+ kind: asset.kind,
112
+ sourcePath: asset.payload.sourcePath,
113
+ targetPath: asset.payload.targetSubpath
114
+ })
115
+ diagnostics.push({
116
+ assetId: asset.id,
117
+ adapter: 'opencode',
118
+ status: 'native',
119
+ reason: 'Mirrored into OPENCODE_CONFIG_DIR as a native OpenCode asset.'
120
+ })
121
+ }
122
+ }
123
+
124
+ if (params.adapter !== 'claude-code') {
125
+ for (const asset of params.bundle.assets) {
126
+ if (asset.kind !== 'nativePlugin' || !asset.enabled || !asset.targets.includes('claude-code')) continue
127
+ diagnostics.push({
128
+ assetId: asset.id,
129
+ adapter: params.adapter,
130
+ status: 'skipped',
131
+ reason: 'Claude marketplace plugin settings do not have a native mapping for this adapter.'
132
+ })
133
+ }
134
+ }
135
+
136
+ if (params.adapter === 'codex') {
137
+ for (const asset of params.bundle.assets) {
138
+ if (!['nativePlugin', 'agent', 'command', 'mode'].includes(asset.kind)) continue
139
+ if (asset.targets.includes('codex')) continue
140
+ if (asset.kind === 'nativePlugin' && asset.targets.includes('claude-code')) continue
141
+ diagnostics.push({
142
+ assetId: asset.id,
143
+ adapter: 'codex',
144
+ status: 'skipped',
145
+ reason: 'No stable native Codex mapping exists for this asset kind in V1.'
146
+ })
147
+ }
148
+ }
149
+
150
+ return {
151
+ adapter: params.adapter,
152
+ diagnostics,
153
+ mcpServers,
154
+ overlays,
155
+ native: params.adapter === 'claude-code'
156
+ ? {
157
+ enabledPlugins: params.bundle.enabledPlugins,
158
+ extraKnownMarketplaces: params.bundle.extraKnownMarketplaces
159
+ }
160
+ : params.adapter === 'codex' && params.bundle.hookPlugins.length > 0
161
+ ? {
162
+ codexHooks: {
163
+ supportedEvents: [
164
+ 'SessionStart',
165
+ 'UserPromptSubmit',
166
+ 'PreToolUse',
167
+ 'PostToolUse',
168
+ 'Stop'
169
+ ]
170
+ }
171
+ }
172
+ : {}
173
+ }
174
+ }
package/src/bundle.ts ADDED
@@ -0,0 +1,178 @@
1
+ import process from 'node:process'
2
+
3
+ import {
4
+ DEFAULT_VIBE_FORGE_MCP_SERVER_NAME,
5
+ buildConfigJsonVariables,
6
+ loadConfig,
7
+ resolveDefaultVibeForgeMcpServerConfig
8
+ } from '@vibe-forge/config'
9
+ import { DefinitionLoader } from '@vibe-forge/definition-loader'
10
+ import type { Config, WorkspaceAsset, WorkspaceAssetBundle } from '@vibe-forge/types'
11
+ import { resolveDocumentName, resolveSpecIdentifier } from '@vibe-forge/utils'
12
+
13
+ import {
14
+ createDocumentAsset,
15
+ dedupeDocumentAssets,
16
+ dedupeDocumentAssetsByIdentifier,
17
+ resolveRuleIdentifier,
18
+ resolveSkillIdentifier
19
+ } from './document-assets'
20
+ import { mergeRecord, uniqueValues } from './helpers'
21
+ import {
22
+ createClaudeNativePluginAssets,
23
+ createHookPluginAssets,
24
+ loadOpenCodeOverlayAssets,
25
+ loadPluginMcpAssets
26
+ } from './plugin-assets'
27
+
28
+ const readConfigForWorkspace = async (cwd: string) => {
29
+ return loadConfig({
30
+ cwd,
31
+ jsonVariables: buildConfigJsonVariables(cwd, process.env)
32
+ })
33
+ }
34
+
35
+ export async function resolveWorkspaceAssetBundle(params: {
36
+ cwd: string
37
+ configs?: [Config?, Config?]
38
+ useDefaultVibeForgeMcpServer?: boolean
39
+ }): Promise<WorkspaceAssetBundle> {
40
+ const [config, userConfig] = params.configs ?? await readConfigForWorkspace(params.cwd)
41
+ const enabledPlugins = mergeRecord(config?.enabledPlugins, userConfig?.enabledPlugins)
42
+ const extraKnownMarketplaces = mergeRecord(config?.extraKnownMarketplaces, userConfig?.extraKnownMarketplaces)
43
+ const loader = new DefinitionLoader(params.cwd)
44
+
45
+ const [
46
+ rawRules,
47
+ rawSpecs,
48
+ rawEntities,
49
+ rawSkills,
50
+ pluginMcpAssets,
51
+ openCodeOverlayAssets
52
+ ] = await Promise.all([
53
+ loader.loadDefaultRules(),
54
+ loader.loadDefaultSpecs(),
55
+ loader.loadDefaultEntities(),
56
+ loader.loadDefaultSkills(),
57
+ loadPluginMcpAssets(params.cwd, enabledPlugins),
58
+ loadOpenCodeOverlayAssets(params.cwd, enabledPlugins)
59
+ ])
60
+
61
+ const assets: WorkspaceAsset[] = []
62
+
63
+ const rules = dedupeDocumentAssetsByIdentifier(
64
+ dedupeDocumentAssets(
65
+ rawRules.map((definition) => createDocumentAsset({ cwd: params.cwd, kind: 'rule', definition })),
66
+ enabledPlugins
67
+ ),
68
+ asset => resolveRuleIdentifier(asset.payload.definition.path, asset.payload.definition.attributes.name)
69
+ )
70
+ const specs = dedupeDocumentAssetsByIdentifier(
71
+ dedupeDocumentAssets(
72
+ rawSpecs.map((definition) => createDocumentAsset({ cwd: params.cwd, kind: 'spec', definition })),
73
+ enabledPlugins
74
+ ),
75
+ asset => resolveSpecIdentifier(asset.payload.definition.path, asset.payload.definition.attributes.name)
76
+ )
77
+ const entities = dedupeDocumentAssetsByIdentifier(
78
+ dedupeDocumentAssets(
79
+ rawEntities.map((definition) => createDocumentAsset({ cwd: params.cwd, kind: 'entity', definition })),
80
+ enabledPlugins
81
+ ),
82
+ asset =>
83
+ resolveDocumentName(
84
+ asset.payload.definition.path,
85
+ asset.payload.definition.attributes.name,
86
+ ['readme.md', 'index.json']
87
+ )
88
+ )
89
+ const skills = dedupeDocumentAssetsByIdentifier(
90
+ dedupeDocumentAssets(
91
+ rawSkills.map((definition) => createDocumentAsset({ cwd: params.cwd, kind: 'skill', definition })),
92
+ enabledPlugins
93
+ ),
94
+ asset => resolveSkillIdentifier(asset.payload.definition.path, asset.payload.definition.attributes.name)
95
+ )
96
+
97
+ assets.push(...rules, ...specs, ...entities, ...skills)
98
+
99
+ const mcpServers = new Map<string, Extract<WorkspaceAsset, { kind: 'mcpServer' }>>()
100
+ if (params.useDefaultVibeForgeMcpServer !== false) {
101
+ const defaultVibeForgeMcpServer = resolveDefaultVibeForgeMcpServerConfig()
102
+ if (defaultVibeForgeMcpServer != null) {
103
+ mcpServers.set(DEFAULT_VIBE_FORGE_MCP_SERVER_NAME, {
104
+ id: `mcpServer:fallback:${DEFAULT_VIBE_FORGE_MCP_SERVER_NAME}`,
105
+ kind: 'mcpServer',
106
+ origin: 'fallback',
107
+ scope: 'adapter',
108
+ enabled: true,
109
+ targets: ['claude-code', 'codex', 'opencode'],
110
+ payload: {
111
+ name: DEFAULT_VIBE_FORGE_MCP_SERVER_NAME,
112
+ config: defaultVibeForgeMcpServer
113
+ }
114
+ })
115
+ }
116
+ }
117
+ const userMcpServers = userConfig?.mcpServers ?? {}
118
+ for (const [name, serverConfig] of Object.entries(userMcpServers)) {
119
+ mcpServers.set(name, {
120
+ id: `mcpServer:user:${name}`,
121
+ kind: 'mcpServer',
122
+ origin: 'config',
123
+ scope: 'user',
124
+ enabled: true,
125
+ targets: ['claude-code', 'codex', 'opencode'],
126
+ payload: {
127
+ name,
128
+ config: serverConfig
129
+ }
130
+ })
131
+ }
132
+ for (const asset of pluginMcpAssets) {
133
+ mcpServers.set(asset.payload.name, asset)
134
+ }
135
+ for (const [name, serverConfig] of Object.entries(config?.mcpServers ?? {})) {
136
+ mcpServers.set(name, {
137
+ id: `mcpServer:project:${name}`,
138
+ kind: 'mcpServer',
139
+ origin: 'config',
140
+ scope: 'project',
141
+ enabled: true,
142
+ targets: ['claude-code', 'codex', 'opencode'],
143
+ payload: {
144
+ name,
145
+ config: serverConfig
146
+ }
147
+ })
148
+ }
149
+ assets.push(...mcpServers.values())
150
+
151
+ const hookPlugins = [
152
+ ...createHookPluginAssets(userConfig?.plugins, enabledPlugins, 'user'),
153
+ ...createHookPluginAssets(config?.plugins, enabledPlugins, 'project')
154
+ ]
155
+ const claudeNativePlugins = createClaudeNativePluginAssets(enabledPlugins)
156
+ assets.push(...hookPlugins, ...claudeNativePlugins, ...openCodeOverlayAssets)
157
+
158
+ return {
159
+ cwd: params.cwd,
160
+ assets,
161
+ rules,
162
+ specs,
163
+ entities,
164
+ skills,
165
+ mcpServers: Object.fromEntries(mcpServers.entries()),
166
+ hookPlugins,
167
+ enabledPlugins,
168
+ extraKnownMarketplaces,
169
+ defaultIncludeMcpServers: uniqueValues([
170
+ ...(config?.defaultIncludeMcpServers ?? []),
171
+ ...(userConfig?.defaultIncludeMcpServers ?? [])
172
+ ]),
173
+ defaultExcludeMcpServers: uniqueValues([
174
+ ...(config?.defaultExcludeMcpServers ?? []),
175
+ ...(userConfig?.defaultExcludeMcpServers ?? [])
176
+ ])
177
+ }
178
+ }
@@ -0,0 +1,191 @@
1
+ import { basename, dirname } from 'node:path'
2
+
3
+ import type {
4
+ RuleReference,
5
+ SkillSelection,
6
+ WorkspaceAsset,
7
+ WorkspaceAssetAdapter,
8
+ WorkspaceAssetBundle,
9
+ WorkspaceAssetKind,
10
+ WorkspaceSkillSelection
11
+ } from '@vibe-forge/types'
12
+ import { normalizePath, resolveDocumentName, resolveRelativePath, resolveSpecIdentifier } from '@vibe-forge/utils'
13
+ import { glob } from 'fast-glob'
14
+
15
+ import { assetOriginPriority, isPluginEnabled, resolvePluginIdFromPath, toAssetScope } from './helpers'
16
+ import type { WorkspaceDocumentAsset, WorkspaceDocumentPayload } from './internal-types'
17
+
18
+ const isLocalRuleReference = (
19
+ rule: RuleReference
20
+ ): rule is Extract<RuleReference, { path: string }> => (
21
+ rule != null &&
22
+ typeof rule === 'object' &&
23
+ 'path' in rule &&
24
+ typeof rule.path === 'string'
25
+ )
26
+
27
+ export const createDocumentAsset = <
28
+ TKind extends Extract<WorkspaceAssetKind, 'rule' | 'spec' | 'entity' | 'skill'>,
29
+ TDefinition,
30
+ >(
31
+ params: {
32
+ cwd: string
33
+ kind: TKind
34
+ definition: TDefinition & { path: string }
35
+ targets?: WorkspaceAssetAdapter[]
36
+ }
37
+ ): Extract<WorkspaceAsset, { kind: TKind }> => {
38
+ const pluginId = resolvePluginIdFromPath(params.cwd, params.definition.path)
39
+ const origin: WorkspaceAsset['origin'] = pluginId == null ? 'project' : 'plugin'
40
+ return {
41
+ id: `${params.kind}:${resolveRelativePath(params.cwd, params.definition.path)}`,
42
+ kind: params.kind,
43
+ pluginId,
44
+ origin,
45
+ scope: toAssetScope(origin),
46
+ enabled: true,
47
+ targets: params.targets ?? ['claude-code', 'codex', 'opencode'],
48
+ payload: {
49
+ definition: params.definition as any,
50
+ sourcePath: params.definition.path
51
+ }
52
+ } as Extract<WorkspaceAsset, { kind: TKind }>
53
+ }
54
+
55
+ export const dedupeDocumentAssets = <
56
+ TAsset extends Extract<WorkspaceAsset, { kind: 'rule' | 'spec' | 'entity' | 'skill' }>,
57
+ >(
58
+ assets: TAsset[],
59
+ enabledPlugins: Record<string, boolean>
60
+ ) => assets.filter((asset) => isPluginEnabled(enabledPlugins, asset.pluginId))
61
+
62
+ const compareDocumentAssetPriority = (
63
+ left: Extract<WorkspaceAsset, { kind: 'rule' | 'spec' | 'entity' | 'skill' }>,
64
+ right: Extract<WorkspaceAsset, { kind: 'rule' | 'spec' | 'entity' | 'skill' }>
65
+ ) => {
66
+ const originDiff = assetOriginPriority[left.origin] - assetOriginPriority[right.origin]
67
+ if (originDiff !== 0) return originDiff
68
+ return left.payload.definition.path.localeCompare(right.payload.definition.path)
69
+ }
70
+
71
+ export const dedupeDocumentAssetsByIdentifier = <
72
+ TAsset extends Extract<WorkspaceAsset, { kind: 'rule' | 'spec' | 'entity' | 'skill' }>,
73
+ >(
74
+ assets: TAsset[],
75
+ resolveIdentifier: (asset: TAsset) => string
76
+ ) => {
77
+ const selected = new Map<string, TAsset>()
78
+
79
+ for (const asset of [...assets].sort(compareDocumentAssetPriority)) {
80
+ const identifier = resolveIdentifier(asset)
81
+ if (!selected.has(identifier)) selected.set(identifier, asset)
82
+ }
83
+
84
+ return Array.from(selected.values()).sort(compareDocumentAssetPriority)
85
+ }
86
+
87
+ export const resolveRuleIdentifier = (
88
+ path: string,
89
+ explicitName?: string
90
+ ) => resolveDocumentName(path, explicitName)
91
+
92
+ export const resolveSkillIdentifier = (
93
+ path: string,
94
+ explicitName?: string
95
+ ) => resolveDocumentName(path, explicitName, ['skill.md'])
96
+
97
+ export const pickSpecAsset = (
98
+ bundle: WorkspaceAssetBundle,
99
+ name: string
100
+ ): Extract<WorkspaceAsset, { kind: 'spec' }> | undefined => {
101
+ const assets = bundle.specs.filter((asset) => {
102
+ const definition = asset.payload.definition
103
+ return resolveSpecIdentifier(definition.path, definition.attributes.name) === name
104
+ })
105
+ return assets.find(asset => asset.origin === 'project') ?? assets[0]
106
+ }
107
+
108
+ export const pickEntityAsset = (
109
+ bundle: WorkspaceAssetBundle,
110
+ name: string
111
+ ): Extract<WorkspaceAsset, { kind: 'entity' }> | undefined => {
112
+ const assets = bundle.entities.filter((asset) => {
113
+ const definition = asset.payload.definition
114
+ const identifier = resolveDocumentName(definition.path, definition.attributes.name, ['readme.md', 'index.json'])
115
+ return identifier === name
116
+ })
117
+ return assets.find(asset => asset.origin === 'project') ?? assets[0]
118
+ }
119
+
120
+ export const filterSkillAssets = (
121
+ skills: Array<Extract<WorkspaceAsset, { kind: 'skill' }>>,
122
+ selection?: WorkspaceSkillSelection
123
+ ): Array<Extract<WorkspaceAsset, { kind: 'skill' }>> => {
124
+ if (selection == null) return skills
125
+
126
+ const include = selection.include != null && selection.include.length > 0
127
+ ? new Set(selection.include)
128
+ : undefined
129
+ const exclude = new Set(selection.exclude ?? [])
130
+
131
+ return skills.filter((skill) => {
132
+ const name = basename(dirname(skill.payload.definition.path))
133
+ return (include == null || include.has(name)) && !exclude.has(name)
134
+ })
135
+ }
136
+
137
+ export const dedupeSkillAssets = (
138
+ skills: Array<Extract<WorkspaceAsset, { kind: 'skill' }>>
139
+ ): Array<Extract<WorkspaceAsset, { kind: 'skill' }>> => {
140
+ const seen = new Set<string>()
141
+ return skills.filter((skill) => {
142
+ if (seen.has(skill.payload.definition.path)) return false
143
+ seen.add(skill.payload.definition.path)
144
+ return true
145
+ })
146
+ }
147
+
148
+ export const resolveRulePatterns = (rules: RuleReference[]) => (
149
+ rules.flatMap((rule) => {
150
+ if (typeof rule === 'string') return [rule]
151
+ if (isLocalRuleReference(rule)) return [rule.path]
152
+ return []
153
+ })
154
+ )
155
+
156
+ export const resolveIncludedSkillNames = (selection: string[] | SkillSelection) => (
157
+ Array.isArray(selection)
158
+ ? selection
159
+ : selection.type === 'include'
160
+ ? selection.list
161
+ : []
162
+ )
163
+
164
+ export const resolveExcludedSkillNames = (selection: string[] | SkillSelection) => (
165
+ Array.isArray(selection)
166
+ ? []
167
+ : selection.type === 'exclude'
168
+ ? selection.list
169
+ : []
170
+ )
171
+
172
+ export const resolveSelectedRuleAssets = async (
173
+ bundle: WorkspaceAssetBundle,
174
+ patterns: string[]
175
+ ): Promise<Array<Extract<WorkspaceAsset, { kind: 'rule' }>>> => {
176
+ const matchedPaths = new Set(
177
+ (await glob(patterns, { cwd: bundle.cwd, absolute: true }))
178
+ .map(normalizePath)
179
+ )
180
+ return bundle.rules.filter((asset) => matchedPaths.has(normalizePath(asset.payload.definition.path)))
181
+ }
182
+
183
+ export const toDocumentDefinitions = <TDefinition>(
184
+ assets: Array<WorkspaceDocumentAsset<TDefinition>>
185
+ ) => assets.map(asset => asset.payload.definition)
186
+
187
+ export const toPromptAssetIds = (assets: Array<{ id: string }>) => (
188
+ Array.from(new Set(assets.map(asset => asset.id)))
189
+ )
190
+
191
+ export type { WorkspaceDocumentAsset, WorkspaceDocumentPayload }
package/src/helpers.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { WorkspaceAsset } from '@vibe-forge/types'
2
+ import { resolveRelativePath } from '@vibe-forge/utils'
3
+
4
+ export const resolvePluginIdFromPath = (cwd: string, path: string) => {
5
+ const relativePath = resolveRelativePath(cwd, path)
6
+ const match = relativePath.match(/^\.ai\/plugins\/([^/]+)\//)
7
+ return match?.[1]
8
+ }
9
+
10
+ export const isPluginEnabled = (
11
+ enabledPlugins: Record<string, boolean>,
12
+ pluginId?: string
13
+ ) => pluginId == null || enabledPlugins[pluginId] !== false
14
+
15
+ export const mergeRecord = <T>(left?: Record<string, T>, right?: Record<string, T>) => ({
16
+ ...(left ?? {}),
17
+ ...(right ?? {})
18
+ })
19
+
20
+ export const uniqueValues = (values: string[]) => Array.from(new Set(values.filter(Boolean)))
21
+
22
+ export const assetOriginPriority: Record<WorkspaceAsset['origin'], number> = {
23
+ project: 0,
24
+ plugin: 1,
25
+ config: 2,
26
+ fallback: 3
27
+ }
28
+
29
+ export const toAssetScope = (origin: WorkspaceAsset['origin']): WorkspaceAsset['scope'] => (
30
+ origin === 'config'
31
+ ? 'project'
32
+ : origin === 'fallback'
33
+ ? 'adapter'
34
+ : 'workspace'
35
+ )