Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/oclif/commands/hub/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export default class HubInstall extends Command {
}),
scope: Flags.string({
char: 's',
default: 'project',
description: 'Install scope for skills (global: home directory, project: current project)',
options: ['global', 'project'],
}),
Expand All @@ -65,7 +64,9 @@ export default class HubInstall extends Command {
agent: flags.agent,
entryId: args.id,
registry: flags.registry,
scope: flags.scope as 'global' | 'project',
// Forward scope only when explicitly set so the daemon can infer the
// per-agent default (global for global-only skill agents).
...(flags.scope ? {scope: flags.scope as 'global' | 'project'} : {}),
})

if (format === 'json') {
Expand Down
4 changes: 4 additions & 0 deletions src/server/core/domain/entities/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ export const AGENT_CONNECTOR_CONFIG: Record<Agent, AgentConnectorConfig> = {
default: 'skill',
supported: ['rules', 'mcp', 'skill'],
},
Hermes: {
default: 'skill',
supported: ['mcp', 'skill'],
},
Junie: {
default: 'skill',
supported: ['rules', 'mcp', 'skill'],
Expand Down
27 changes: 25 additions & 2 deletions src/server/infra/connectors/mcp/mcp-connector-config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import path from 'node:path'

import type {Agent} from '../../../core/domain/entities/agent.js'
import type {McpServerConfig} from '../../../core/interfaces/storage/i-mcp-config-writer.js'

import {resolveHermesHome} from '../shared/agent-path-resolver.js'
import {getClaudeDesktopConfigPath} from './claude-desktop-config-path.js'

/**
* Supported MCP config file formats.
*/
export type McpConfigFormat = 'json' | 'toml'
export type McpConfigFormat = 'json' | 'toml' | 'yaml'

/**
* Supported MCP config scope.
Expand Down Expand Up @@ -68,10 +71,22 @@ export type TomlMcpConnectorConfig = McpConnectorConfigBase & {
serverName: string
}

/**
* YAML format configuration - uses key path navigation.
*/
export type YamlMcpConnectorConfig = McpConnectorConfigBase & {
format: 'yaml'
/**
* YAML key path to the MCP server entry, including server name.
* e.g., ['mcp_servers', 'brv'] navigates to { mcp_servers: { brv: ... } }
*/
serverKeyPath: readonly string[]
}

/**
* Configuration for agent-specific MCP settings.
*/
export type McpConnectorConfig = JsonMcpConnectorConfig | TomlMcpConnectorConfig
export type McpConnectorConfig = JsonMcpConnectorConfig | TomlMcpConnectorConfig | YamlMcpConnectorConfig

/* eslint-disable perfectionist/sort-objects */
/** Default MCP server configuration */
Expand Down Expand Up @@ -186,6 +201,14 @@ export const MCP_CONNECTOR_CONFIGS = {
serverConfig: DEFAULT_SERVER_CONFIG,
serverKeyPath: ['servers', 'brv'],
},
Hermes: {
configPathResolver: () => path.join(resolveHermesHome(), 'config.yaml'),
format: 'yaml',
mode: 'auto',
scope: 'global',
serverConfig: DEFAULT_SERVER_CONFIG,
serverKeyPath: ['mcp_servers', 'brv'],
},
Comment thread
cuongdo-byterover marked this conversation as resolved.
Junie: {
configPath: '.junie/mcp/mcp.json',
format: 'json',
Expand Down
29 changes: 17 additions & 12 deletions src/server/infra/connectors/mcp/mcp-connector.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {dump as yamlDump} from 'js-yaml'
import {set} from 'lodash-es'
import os from 'node:os'
import path from 'node:path'
Expand All @@ -13,12 +14,7 @@ import type {ConnectorOperationOptions, IConnector} from '../../../core/interfac
import type {IFileService} from '../../../core/interfaces/services/i-file-service.js'
import type {IRuleTemplateService} from '../../../core/interfaces/services/i-rule-template-service.js'
import type {IMcpConfigWriter} from '../../../core/interfaces/storage/i-mcp-config-writer.js'
import type {
JsonMcpConnectorConfig,
McpConnectorConfig,
McpSupportedAgent,
TomlMcpConnectorConfig,
} from './mcp-connector-config.js'
import type {McpConnectorConfig, McpSupportedAgent} from './mcp-connector-config.js'

import {AGENT_CONNECTOR_CONFIG} from '../../../core/domain/entities/agent.js'
import {RULES_CONNECTOR_CONFIGS} from '../rules/rules-connector-config.js'
Expand All @@ -27,6 +23,7 @@ import {RuleFileManager} from '../shared/rule-file-manager.js'
import {JsonMcpConfigWriter} from './json-mcp-config-writer.js'
import {MCP_CONNECTOR_CONFIGS} from './mcp-connector-config.js'
import {TomlMcpConfigWriter} from './toml-mcp-config-writer.js'
import {YamlMcpConfigWriter} from './yaml-mcp-config-writer.js'

/**
* Options for constructing McpConnector.
Expand Down Expand Up @@ -211,6 +208,13 @@ export class McpConnector implements IConnector {
})
}

if (config.format === 'yaml') {
return new YamlMcpConfigWriter({
fileService: this.fileService,
serverKeyPath: config.serverKeyPath,
})
}

return new TomlMcpConfigWriter({
fileService: this.fileService,
serverName: config.serverName,
Expand All @@ -222,15 +226,16 @@ export class McpConnector implements IConnector {
*/
private formatConfigContent(config: McpConnectorConfig): string {
if (config.format === 'json') {
// Build the nested JSON structure based on serverKeyPath
const jsonConfig = config as JsonMcpConnectorConfig
const result = set({}, jsonConfig.serverKeyPath, config.serverConfig)
const result = set({}, config.serverKeyPath, config.serverConfig)
return JSON.stringify(result, null, 2)
}

// TOML format
const tomlConfig = config as TomlMcpConnectorConfig
const lines = [`[mcp_servers.${tomlConfig.serverName}]`]
if (config.format === 'yaml') {
const result = set({}, config.serverKeyPath, config.serverConfig)
return yamlDump(result)
}

const lines = [`[mcp_servers.${config.serverName}]`]
for (const [key, value] of Object.entries(config.serverConfig)) {
if (typeof value === 'string') {
lines.push(`${key} = "${value}"`)
Expand Down
103 changes: 103 additions & 0 deletions src/server/infra/connectors/mcp/yaml-mcp-config-writer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {dump as yamlDump, load as yamlLoad} from 'js-yaml'
import {has, set, unset} from 'lodash-es'

import type {IFileService} from '../../../core/interfaces/services/i-file-service.js'
import type {
IMcpConfigWriter,
McpConfigExistsResult,
McpServerConfig,
} from '../../../core/interfaces/storage/i-mcp-config-writer.js'

import {isRecord} from '../../../utils/type-guards.js'

export type YamlMcpConfigWriterOptions = {
fileService: IFileService
/**
* YAML key path to the MCP server entry, including server name.
* e.g., ['mcp_servers', 'brv'] navigates to { mcp_servers: { brv: ... } }
*/
serverKeyPath: readonly string[]
}

function parseYamlAsRecord(content: string): Record<string, unknown> {
const parsed: unknown = yamlLoad(content)
if (!isRecord(parsed)) {
throw new TypeError('Expected YAML root to be a mapping')
}

return parsed
}

/**
* MCP config writer for YAML format files.
* Used by agents whose MCP server list lives in a YAML config (e.g. Hermes).
* Comments and key order are not preserved across round-trip — that is an
* accepted trade-off given js-yaml's capabilities.
*/
export class YamlMcpConfigWriter implements IMcpConfigWriter {
private readonly fileService: IFileService
private readonly serverKeyPath: readonly string[]

constructor(options: YamlMcpConfigWriterOptions) {
this.fileService = options.fileService
this.serverKeyPath = options.serverKeyPath
}

async exists(filePath: string): Promise<McpConfigExistsResult> {
const fileExists = await this.fileService.exists(filePath)

if (!fileExists) {
return {fileExists: false, serverExists: false}
}

try {
const content = await this.fileService.read(filePath)
const data = parseYamlAsRecord(content)
return {
fileExists: true,
serverExists: has(data, this.serverKeyPath),
}
} catch {
return {fileExists: true, serverExists: false}
}
}

async remove(filePath: string): Promise<boolean> {
const fileExists = await this.fileService.exists(filePath)

if (!fileExists) {
return false
}

let data: Record<string, unknown>
try {
data = parseYamlAsRecord(await this.fileService.read(filePath))
} catch {
return false
}

if (!has(data, this.serverKeyPath)) {
return false
}

unset(data, this.serverKeyPath)
await this.fileService.write(yamlDump(data), filePath, 'overwrite')
return true
}

async write(filePath: string, serverConfig: McpServerConfig): Promise<void> {
let data: Record<string, unknown> = {}

if (await this.fileService.exists(filePath)) {
try {
data = parseYamlAsRecord(await this.fileService.read(filePath))
} catch (error) {
const details = error instanceof Error ? error.message : String(error)
throw new Error(`Cannot update YAML MCP config at ${filePath}: ${details}`)
}
}

set(data, this.serverKeyPath, {...serverConfig})
await this.fileService.write(yamlDump(data), filePath, 'overwrite')
}
Comment thread
cuongdo-byterover marked this conversation as resolved.
Comment thread
cuongdo-byterover marked this conversation as resolved.
}
120 changes: 120 additions & 0 deletions src/server/infra/connectors/shared/agent-path-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import os from 'node:os'
import path from 'node:path'

/**
* Shared resolver for autonomous-agent home/config locations (Hermes, OpenClaw).
*
* Lives in `connectors/shared` so both the skill and MCP connectors can resolve
* the same root without the MCP connector importing skill internals. When no
* options are supplied the resolver falls back to `process.env` / `os.homedir()`
* so call sites without an injection seam (e.g. an MCP `configPathResolver`)
* still honor `HERMES_HOME` / `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH`.
*/
export type AgentPathResolverOptions = {
env?: NodeJS.ProcessEnv
homeDir?: string
}

const resolveEnv = (options?: AgentPathResolverOptions): NodeJS.ProcessEnv => options?.env ?? process.env

const resolveHomeDir = (options?: AgentPathResolverOptions): string => options?.homeDir ?? os.homedir()

export function resolveUserPath(input: string, options?: AgentPathResolverOptions): string {
const value = input.trim()
const homeDir = resolveHomeDir(options)
if (value === '~') {
return homeDir
}

if (value.startsWith('~/')) {
Comment thread
cuongdo-byterover marked this conversation as resolved.
return path.join(homeDir, value.slice(2))
}

if (path.isAbsolute(value)) {
return value
}

return path.join(homeDir, value)
}
Comment thread
cuongdo-byterover marked this conversation as resolved.

/**
* OpenClaw home dir, mirroring OpenClaw's `resolveRequiredHomeDir`: `OPENCLAW_HOME`
* wins (with `~` expanded against the base home), otherwise the injected/OS home.
* `options.homeDir` stands in for OpenClaw's OS-home chain (HOME/USERPROFILE/os.homedir).
*/
export function resolveOpenClawHomeDir(options?: AgentPathResolverOptions): string {
const base = resolveHomeDir(options)
const override = resolveEnv(options).OPENCLAW_HOME?.trim()
if (!override) {
return base
}

if (override === '~' || override.startsWith('~/') || override.startsWith('~\\')) {
return path.resolve(override.replace(/^~(?=$|[\\/])/u, base))
}

return path.resolve(override)
}

/**
* OpenClaw path resolution, mirroring OpenClaw's `resolveHomeRelativePath`:
* `~`-prefixed expands against the OpenClaw home; every other value is
* `path.resolve`d (i.e. relative paths are CWD-relative, not home-relative).
*/
export function resolveOpenClawUserPath(input: string, options?: AgentPathResolverOptions): string {
const trimmed = input.trim()
if (!trimmed) {
return trimmed
}

if (trimmed.startsWith('~')) {
const home = resolveOpenClawHomeDir(options)
const expanded = trimmed === '~' ? home : path.join(home, trimmed.replace(/^~[\\/]/u, ''))
return path.resolve(expanded)
}

return path.resolve(trimmed)
}

export function resolveOpenClawStateDir(options?: AgentPathResolverOptions): string {
const override = resolveEnv(options).OPENCLAW_STATE_DIR?.trim()
if (override) {
return resolveOpenClawUserPath(override, options)
}

return path.join(resolveOpenClawHomeDir(options), '.openclaw')
}

export function resolveOpenClawConfigPath(options?: AgentPathResolverOptions): string {
const override = resolveEnv(options).OPENCLAW_CONFIG_PATH?.trim()
if (override) {
return resolveOpenClawUserPath(override, options)
}

return path.join(resolveOpenClawStateDir(options), 'openclaw.json')
}

export function resolveHermesHome(options?: AgentPathResolverOptions): string {
const override = resolveEnv(options).HERMES_HOME?.trim()
if (override) {
return resolveUserPath(override, options)
}

return path.join(resolveHomeDir(options), '.hermes')
}

/**
* Default workspace dir for the OpenClaw default agent, mirroring OpenClaw's
* `resolveDefaultAgentWorkspaceDir`. Note: this is HOME-based
* (`<home>/.openclaw/workspace`) and intentionally does NOT honor
* OPENCLAW_STATE_DIR — only the OPENCLAW_PROFILE suffix.
*/
export function resolveOpenClawDefaultWorkspaceDir(options?: AgentPathResolverOptions): string {
const profile = resolveEnv(options).OPENCLAW_PROFILE?.trim()
const home = resolveOpenClawHomeDir(options)
if (profile && profile.toLowerCase() !== 'default') {
return path.join(home, '.openclaw', `workspace-${profile}`)
}

return path.join(home, '.openclaw', 'workspace')
}
9 changes: 9 additions & 0 deletions src/server/infra/connectors/shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ const sliceBrvSection = (content: string): string | undefined => {
return content.slice(startIdx, endIdx)
}

/**
* Boundary markers for the always-loaded BYTEROVER block that the SkillConnector
* writes into autonomous agents' system-prompt context files.
*
* The marker strings intentionally match rule files so legacy detection keeps
* treating all ByteRover-managed instruction blocks consistently.
*/
export const BYTEROVER_BLOCK_MARKERS = BRV_RULE_MARKERS
Comment thread
cuongdo-byterover marked this conversation as resolved.

/**
* Checks if the BRV markers section contains MCP tool references (brv-query/brv-curate).
* Only checks within the markers section to avoid false positives from user content.
Expand Down
Loading
Loading