Skip to content

Commit 34e5594

Browse files
Merge pull request #675 from campfirein/feat/ENG-2697
feat: [ENG-2697] rewrite ByteRover skill — sub-skill split + Hermes connector
2 parents 910d4d5 + b49d341 commit 34e5594

37 files changed

Lines changed: 3275 additions & 783 deletions

src/oclif/commands/hub/install.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ export default class HubInstall extends Command {
4040
}),
4141
scope: Flags.string({
4242
char: 's',
43-
default: 'project',
4443
description: 'Install scope for skills (global: home directory, project: current project)',
4544
options: ['global', 'project'],
4645
}),
@@ -65,7 +64,9 @@ export default class HubInstall extends Command {
6564
agent: flags.agent,
6665
entryId: args.id,
6766
registry: flags.registry,
68-
scope: flags.scope as 'global' | 'project',
67+
// Forward scope only when explicitly set so the daemon can infer the
68+
// per-agent default (global for global-only skill agents).
69+
...(flags.scope ? {scope: flags.scope as 'global' | 'project'} : {}),
6970
})
7071

7172
if (format === 'json') {

src/server/core/domain/entities/agent.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ export const AGENT_CONNECTOR_CONFIG: Record<Agent, AgentConnectorConfig> = {
7070
default: 'skill',
7171
supported: ['rules', 'mcp', 'skill'],
7272
},
73+
Hermes: {
74+
default: 'skill',
75+
supported: ['mcp', 'skill'],
76+
},
7377
Junie: {
7478
default: 'skill',
7579
supported: ['rules', 'mcp', 'skill'],

src/server/infra/connectors/mcp/mcp-connector-config.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
import path from 'node:path'
2+
13
import type {Agent} from '../../../core/domain/entities/agent.js'
24
import type {McpServerConfig} from '../../../core/interfaces/storage/i-mcp-config-writer.js'
35

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

69
/**
710
* Supported MCP config file formats.
811
*/
9-
export type McpConfigFormat = 'json' | 'toml'
12+
export type McpConfigFormat = 'json' | 'toml' | 'yaml'
1013

1114
/**
1215
* Supported MCP config scope.
@@ -68,10 +71,22 @@ export type TomlMcpConnectorConfig = McpConnectorConfigBase & {
6871
serverName: string
6972
}
7073

74+
/**
75+
* YAML format configuration - uses key path navigation.
76+
*/
77+
export type YamlMcpConnectorConfig = McpConnectorConfigBase & {
78+
format: 'yaml'
79+
/**
80+
* YAML key path to the MCP server entry, including server name.
81+
* e.g., ['mcp_servers', 'brv'] navigates to { mcp_servers: { brv: ... } }
82+
*/
83+
serverKeyPath: readonly string[]
84+
}
85+
7186
/**
7287
* Configuration for agent-specific MCP settings.
7388
*/
74-
export type McpConnectorConfig = JsonMcpConnectorConfig | TomlMcpConnectorConfig
89+
export type McpConnectorConfig = JsonMcpConnectorConfig | TomlMcpConnectorConfig | YamlMcpConnectorConfig
7590

7691
/* eslint-disable perfectionist/sort-objects */
7792
/** Default MCP server configuration */
@@ -186,6 +201,14 @@ export const MCP_CONNECTOR_CONFIGS = {
186201
serverConfig: DEFAULT_SERVER_CONFIG,
187202
serverKeyPath: ['servers', 'brv'],
188203
},
204+
Hermes: {
205+
configPathResolver: () => path.join(resolveHermesHome(), 'config.yaml'),
206+
format: 'yaml',
207+
mode: 'auto',
208+
scope: 'global',
209+
serverConfig: DEFAULT_SERVER_CONFIG,
210+
serverKeyPath: ['mcp_servers', 'brv'],
211+
},
189212
Junie: {
190213
configPath: '.junie/mcp/mcp.json',
191214
format: 'json',

src/server/infra/connectors/mcp/mcp-connector.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {dump as yamlDump} from 'js-yaml'
12
import {set} from 'lodash-es'
23
import os from 'node:os'
34
import path from 'node:path'
@@ -13,12 +14,7 @@ import type {ConnectorOperationOptions, IConnector} from '../../../core/interfac
1314
import type {IFileService} from '../../../core/interfaces/services/i-file-service.js'
1415
import type {IRuleTemplateService} from '../../../core/interfaces/services/i-rule-template-service.js'
1516
import type {IMcpConfigWriter} from '../../../core/interfaces/storage/i-mcp-config-writer.js'
16-
import type {
17-
JsonMcpConnectorConfig,
18-
McpConnectorConfig,
19-
McpSupportedAgent,
20-
TomlMcpConnectorConfig,
21-
} from './mcp-connector-config.js'
17+
import type {McpConnectorConfig, McpSupportedAgent} from './mcp-connector-config.js'
2218

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

3128
/**
3229
* Options for constructing McpConnector.
@@ -211,6 +208,13 @@ export class McpConnector implements IConnector {
211208
})
212209
}
213210

211+
if (config.format === 'yaml') {
212+
return new YamlMcpConfigWriter({
213+
fileService: this.fileService,
214+
serverKeyPath: config.serverKeyPath,
215+
})
216+
}
217+
214218
return new TomlMcpConfigWriter({
215219
fileService: this.fileService,
216220
serverName: config.serverName,
@@ -222,15 +226,16 @@ export class McpConnector implements IConnector {
222226
*/
223227
private formatConfigContent(config: McpConnectorConfig): string {
224228
if (config.format === 'json') {
225-
// Build the nested JSON structure based on serverKeyPath
226-
const jsonConfig = config as JsonMcpConnectorConfig
227-
const result = set({}, jsonConfig.serverKeyPath, config.serverConfig)
229+
const result = set({}, config.serverKeyPath, config.serverConfig)
228230
return JSON.stringify(result, null, 2)
229231
}
230232

231-
// TOML format
232-
const tomlConfig = config as TomlMcpConnectorConfig
233-
const lines = [`[mcp_servers.${tomlConfig.serverName}]`]
233+
if (config.format === 'yaml') {
234+
const result = set({}, config.serverKeyPath, config.serverConfig)
235+
return yamlDump(result)
236+
}
237+
238+
const lines = [`[mcp_servers.${config.serverName}]`]
234239
for (const [key, value] of Object.entries(config.serverConfig)) {
235240
if (typeof value === 'string') {
236241
lines.push(`${key} = "${value}"`)
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import {dump as yamlDump, load as yamlLoad} from 'js-yaml'
2+
import {has, set, unset} from 'lodash-es'
3+
4+
import type {IFileService} from '../../../core/interfaces/services/i-file-service.js'
5+
import type {
6+
IMcpConfigWriter,
7+
McpConfigExistsResult,
8+
McpServerConfig,
9+
} from '../../../core/interfaces/storage/i-mcp-config-writer.js'
10+
11+
import {isRecord} from '../../../utils/type-guards.js'
12+
13+
export type YamlMcpConfigWriterOptions = {
14+
fileService: IFileService
15+
/**
16+
* YAML key path to the MCP server entry, including server name.
17+
* e.g., ['mcp_servers', 'brv'] navigates to { mcp_servers: { brv: ... } }
18+
*/
19+
serverKeyPath: readonly string[]
20+
}
21+
22+
function parseYamlAsRecord(content: string): Record<string, unknown> {
23+
const parsed: unknown = yamlLoad(content)
24+
if (!isRecord(parsed)) {
25+
throw new TypeError('Expected YAML root to be a mapping')
26+
}
27+
28+
return parsed
29+
}
30+
31+
/**
32+
* MCP config writer for YAML format files.
33+
* Used by agents whose MCP server list lives in a YAML config (e.g. Hermes).
34+
* Comments and key order are not preserved across round-trip — that is an
35+
* accepted trade-off given js-yaml's capabilities.
36+
*/
37+
export class YamlMcpConfigWriter implements IMcpConfigWriter {
38+
private readonly fileService: IFileService
39+
private readonly serverKeyPath: readonly string[]
40+
41+
constructor(options: YamlMcpConfigWriterOptions) {
42+
this.fileService = options.fileService
43+
this.serverKeyPath = options.serverKeyPath
44+
}
45+
46+
async exists(filePath: string): Promise<McpConfigExistsResult> {
47+
const fileExists = await this.fileService.exists(filePath)
48+
49+
if (!fileExists) {
50+
return {fileExists: false, serverExists: false}
51+
}
52+
53+
try {
54+
const content = await this.fileService.read(filePath)
55+
const data = parseYamlAsRecord(content)
56+
return {
57+
fileExists: true,
58+
serverExists: has(data, this.serverKeyPath),
59+
}
60+
} catch {
61+
return {fileExists: true, serverExists: false}
62+
}
63+
}
64+
65+
async remove(filePath: string): Promise<boolean> {
66+
const fileExists = await this.fileService.exists(filePath)
67+
68+
if (!fileExists) {
69+
return false
70+
}
71+
72+
let data: Record<string, unknown>
73+
try {
74+
data = parseYamlAsRecord(await this.fileService.read(filePath))
75+
} catch {
76+
return false
77+
}
78+
79+
if (!has(data, this.serverKeyPath)) {
80+
return false
81+
}
82+
83+
unset(data, this.serverKeyPath)
84+
await this.fileService.write(yamlDump(data), filePath, 'overwrite')
85+
return true
86+
}
87+
88+
async write(filePath: string, serverConfig: McpServerConfig): Promise<void> {
89+
let data: Record<string, unknown> = {}
90+
91+
if (await this.fileService.exists(filePath)) {
92+
try {
93+
data = parseYamlAsRecord(await this.fileService.read(filePath))
94+
} catch (error) {
95+
const details = error instanceof Error ? error.message : String(error)
96+
throw new Error(`Cannot update YAML MCP config at ${filePath}: ${details}`)
97+
}
98+
}
99+
100+
set(data, this.serverKeyPath, {...serverConfig})
101+
await this.fileService.write(yamlDump(data), filePath, 'overwrite')
102+
}
103+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import os from 'node:os'
2+
import path from 'node:path'
3+
4+
/**
5+
* Shared resolver for autonomous-agent home/config locations (Hermes, OpenClaw).
6+
*
7+
* Lives in `connectors/shared` so both the skill and MCP connectors can resolve
8+
* the same root without the MCP connector importing skill internals. When no
9+
* options are supplied the resolver falls back to `process.env` / `os.homedir()`
10+
* so call sites without an injection seam (e.g. an MCP `configPathResolver`)
11+
* still honor `HERMES_HOME` / `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH`.
12+
*/
13+
export type AgentPathResolverOptions = {
14+
env?: NodeJS.ProcessEnv
15+
homeDir?: string
16+
}
17+
18+
const resolveEnv = (options?: AgentPathResolverOptions): NodeJS.ProcessEnv => options?.env ?? process.env
19+
20+
const resolveHomeDir = (options?: AgentPathResolverOptions): string => options?.homeDir ?? os.homedir()
21+
22+
export function resolveUserPath(input: string, options?: AgentPathResolverOptions): string {
23+
const value = input.trim()
24+
const homeDir = resolveHomeDir(options)
25+
if (value === '~') {
26+
return homeDir
27+
}
28+
29+
if (value.startsWith('~/')) {
30+
return path.join(homeDir, value.slice(2))
31+
}
32+
33+
if (path.isAbsolute(value)) {
34+
return value
35+
}
36+
37+
return path.join(homeDir, value)
38+
}
39+
40+
/**
41+
* OpenClaw home dir, mirroring OpenClaw's `resolveRequiredHomeDir`: `OPENCLAW_HOME`
42+
* wins (with `~` expanded against the base home), otherwise the injected/OS home.
43+
* `options.homeDir` stands in for OpenClaw's OS-home chain (HOME/USERPROFILE/os.homedir).
44+
*/
45+
export function resolveOpenClawHomeDir(options?: AgentPathResolverOptions): string {
46+
const base = resolveHomeDir(options)
47+
const override = resolveEnv(options).OPENCLAW_HOME?.trim()
48+
if (!override) {
49+
return base
50+
}
51+
52+
if (override === '~' || override.startsWith('~/') || override.startsWith('~\\')) {
53+
return path.resolve(override.replace(/^~(?=$|[\\/])/u, base))
54+
}
55+
56+
return path.resolve(override)
57+
}
58+
59+
/**
60+
* OpenClaw path resolution, mirroring OpenClaw's `resolveHomeRelativePath`:
61+
* `~`-prefixed expands against the OpenClaw home; every other value is
62+
* `path.resolve`d (i.e. relative paths are CWD-relative, not home-relative).
63+
*/
64+
export function resolveOpenClawUserPath(input: string, options?: AgentPathResolverOptions): string {
65+
const trimmed = input.trim()
66+
if (!trimmed) {
67+
return trimmed
68+
}
69+
70+
if (trimmed.startsWith('~')) {
71+
const home = resolveOpenClawHomeDir(options)
72+
const expanded = trimmed === '~' ? home : path.join(home, trimmed.replace(/^~[\\/]/u, ''))
73+
return path.resolve(expanded)
74+
}
75+
76+
return path.resolve(trimmed)
77+
}
78+
79+
export function resolveOpenClawStateDir(options?: AgentPathResolverOptions): string {
80+
const override = resolveEnv(options).OPENCLAW_STATE_DIR?.trim()
81+
if (override) {
82+
return resolveOpenClawUserPath(override, options)
83+
}
84+
85+
return path.join(resolveOpenClawHomeDir(options), '.openclaw')
86+
}
87+
88+
export function resolveOpenClawConfigPath(options?: AgentPathResolverOptions): string {
89+
const override = resolveEnv(options).OPENCLAW_CONFIG_PATH?.trim()
90+
if (override) {
91+
return resolveOpenClawUserPath(override, options)
92+
}
93+
94+
return path.join(resolveOpenClawStateDir(options), 'openclaw.json')
95+
}
96+
97+
export function resolveHermesHome(options?: AgentPathResolverOptions): string {
98+
const override = resolveEnv(options).HERMES_HOME?.trim()
99+
if (override) {
100+
return resolveUserPath(override, options)
101+
}
102+
103+
return path.join(resolveHomeDir(options), '.hermes')
104+
}
105+
106+
/**
107+
* Default workspace dir for the OpenClaw default agent, mirroring OpenClaw's
108+
* `resolveDefaultAgentWorkspaceDir`. Note: this is HOME-based
109+
* (`<home>/.openclaw/workspace`) and intentionally does NOT honor
110+
* OPENCLAW_STATE_DIR — only the OPENCLAW_PROFILE suffix.
111+
*/
112+
export function resolveOpenClawDefaultWorkspaceDir(options?: AgentPathResolverOptions): string {
113+
const profile = resolveEnv(options).OPENCLAW_PROFILE?.trim()
114+
const home = resolveOpenClawHomeDir(options)
115+
if (profile && profile.toLowerCase() !== 'default') {
116+
return path.join(home, '.openclaw', `workspace-${profile}`)
117+
}
118+
119+
return path.join(home, '.openclaw', 'workspace')
120+
}

src/server/infra/connectors/shared/constants.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ const sliceBrvSection = (content: string): string | undefined => {
1919
return content.slice(startIdx, endIdx)
2020
}
2121

22+
/**
23+
* Boundary markers for the always-loaded BYTEROVER block that the SkillConnector
24+
* writes into autonomous agents' system-prompt context files.
25+
*
26+
* The marker strings intentionally match rule files so legacy detection keeps
27+
* treating all ByteRover-managed instruction blocks consistently.
28+
*/
29+
export const BYTEROVER_BLOCK_MARKERS = BRV_RULE_MARKERS
30+
2231
/**
2332
* Checks if the BRV markers section contains MCP tool references (brv-query/brv-curate).
2433
* Only checks within the markers section to avoid false positives from user content.

0 commit comments

Comments
 (0)