Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
126 changes: 126 additions & 0 deletions apps/desktop/src/main/agent-access/installer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { AgentAccessInstaller } from './installer'

function planFixture(target: string, overrides: Partial<Parameters<AgentAccessInstaller['apply']>[0]> = {}) {
return {
target,
url: 'http://127.0.0.1:8790/v1/mcp/agents',
headers: { Authorization: 'Bearer evr_test' },
mcpConfig: '{}',
skillFiles: [
{ path: 'SKILL.md', content: '---\nname: everroom-agent\n---\nbody' },
{ path: 'references/tools.md', content: '# tools' },
],
skillRoot: target === 'claude-code' || target === 'cursor' || target === 'codex'
? `~/.${target === 'cursor' ? 'cursor' : target === 'codex' ? 'codex' : 'claude'}/skills/everroom-agent/`
: null,
notes: [],
...overrides,
}
}

const homes: string[] = []

function makeHome(): string {
const home = mkdtempSync(join(tmpdir(), 'agent-access-installer-'))
homes.push(home)
return home
}

afterEach(() => {
while (homes.length > 0) rmSync(homes.pop()!, { recursive: true, force: true })
})

describe('AgentAccessInstaller', () => {
it('claude-code:写技能目录 + 合并 ~/.claude.json 且保留既有配置', () => {
const home = makeHome()
const claudeJson = join(home, '.claude.json')
writeFileSync(claudeJson, JSON.stringify({ otherSetting: true, mcpServers: { github: { url: 'x' } } }), 'utf8')
const installer = new AgentAccessInstaller(home)

const outcome = installer.apply(planFixture('claude-code'))
expect(outcome.writtenFiles.some((file) => file.endsWith('SKILL.md'))).toBe(true)
expect(outcome.manualSteps).toHaveLength(0)

const config = JSON.parse(readFileSync(claudeJson, 'utf8')) as {
otherSetting: boolean
mcpServers: Record<string, unknown>
}
expect(config.otherSetting).toBe(true)
expect(config.mcpServers.github).toBeDefined()
expect(config.mcpServers.everroom).toMatchObject({
type: 'http',
url: 'http://127.0.0.1:8790/v1/mcp/agents',
})
expect(outcome.backups.length).toBe(1)
})

it('cursor:新建 ~/.cursor/mcp.json 并写技能;重复安装幂等', () => {
const home = makeHome()
const installer = new AgentAccessInstaller(home)
installer.apply(planFixture('cursor'))
const outcome = installer.apply(planFixture('cursor'))

const config = JSON.parse(readFileSync(join(home, '.cursor', 'mcp.json'), 'utf8')) as {
mcpServers: Record<string, unknown>
}
expect(config.mcpServers.everroom).toMatchObject({ url: 'http://127.0.0.1:8790/v1/mcp/agents' })
expect(outcome.backups.length).toBe(1) // 第二次安装时已有文件 → 备份一次
})

it('claude.json 损坏时不写入、返回手动步骤提示', () => {
const home = makeHome()
const claudeJson = join(home, '.claude.json')
writeFileSync(claudeJson, '{broken', 'utf8')
const installer = new AgentAccessInstaller(home)

const outcome = installer.apply(planFixture('claude-code'))
expect(outcome.notes.some((note) => note.includes('不是有效 JSON'))).toBe(true)
expect(readFileSync(claudeJson, 'utf8')).toBe('{broken')
})

it('codex:只写技能目录,TOML 走手动片段且 token 已替换', () => {
const home = makeHome()
const installer = new AgentAccessInstaller(home)
const outcome = installer.apply(planFixture('codex', {
mcpConfig: '[mcp_servers.everroom]\nurl = "http://x"\nhttp_headers = { Authorization = "Bearer <token>" }',
}))
expect(outcome.manualSteps).toHaveLength(1)
expect(outcome.manualSteps[0]!.content).toContain('"Bearer evr_test"')
expect(outcome.manualSteps[0]!.content).not.toContain('<token>')
})

it('卸载:移除技能目录与配置键,保留其他服务器与用户配置', () => {
const home = makeHome()
const claudeJson = join(home, '.claude.json')
writeFileSync(claudeJson, JSON.stringify({
keep: 1,
mcpServers: { github: { url: 'x' }, everroom: { url: 'y' } },
}), 'utf8')
const installer = new AgentAccessInstaller(home)
installer.apply(planFixture('claude-code'))

const outcome = installer.uninstall('claude-code')
expect(outcome.removed.some((path) => path.includes('everroom-agent'))).toBe(true)
const config = JSON.parse(readFileSync(claudeJson, 'utf8')) as {
keep: number
mcpServers: Record<string, unknown>
}
expect(config.keep).toBe(1)
expect(config.mcpServers.everroom).toBeUndefined()
expect(config.mcpServers.github).toBeDefined()
})

it('宿主无技能机制(cherry-studio)时不写任何文件,只给片段', () => {
const home = makeHome()
const installer = new AgentAccessInstaller(home)
const outcome = installer.apply(planFixture('cherry-studio'))
expect(outcome.writtenFiles).toHaveLength(0)
expect(outcome.manualSteps).toHaveLength(1)
expect(outcome.notes.some((note) => note.includes('token'))).toBe(true)
mkdirSync(join(home, 'unused'), { recursive: true })
})
})
199 changes: 199 additions & 0 deletions apps/desktop/src/main/agent-access/installer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { homedir } from 'node:os'
import type { AgentAccessInstallPlanDto } from '../gateway/agent-access-gateway-bridge'

/**
* 安装代写落盘(PRD 7.1.2「安装技能与 MCP/CLI 连接」):把网关生成的安装内容
* 写进目标工具。原则:
* - 只增改 everroom 自己的键/目录,绝不触碰用户其他配置;写入前先备份。
* - JSON 配置(~/.claude.json、~/.cursor/mcp.json)合并 mcpServers.everroom;
* 解析失败立即中止不写(宁可让用户手动贴片段)。
* - TOML(~/.codex/config.toml)不做自动合并——返回片段由用户粘贴,避免破坏
* 手写注释与结构。
*/

export interface InstallOutcome {
writtenFiles: string[]
backups: string[]
/** 需要用户手动粘贴的配置片段(标题 + 内容)。 */
manualSteps: Array<{ title: string; content: string }>
notes: string[]
}

export interface UninstallOutcome {
removed: string[]
backups: string[]
}

export class AgentAccessInstaller {
constructor(private readonly homeDirectory: string = homedir()) {}

private tilde(absolute: string): string {
return absolute.startsWith(this.homeDirectory)
? absolute.replace(this.homeDirectory, '~')
: absolute
}

private backupFile(path: string, backups: string[]): void {
if (!existsSync(path)) return
const backupPath = `${path}.everroom-backup-${Date.now()}`
copyFileSync(path, backupPath)
backups.push(this.tilde(backupPath))
}

private writeSkillFiles(
plan: AgentAccessInstallPlanDto,
written: string[],
): string | null {
if (!plan.skillRoot) return null
const root = plan.skillRoot.replace(/^~(?=\/)/, this.homeDirectory)
for (const file of plan.skillFiles) {
const target = join(root, file.path)
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, file.content, 'utf8')
written.push(this.tilde(target))
}
return root
}

private readJsonObject(path: string): Record<string, unknown> | null {
if (!existsSync(path)) return {}
try {
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null
} catch {
return null
}
}

private mergeMcpServer(input: {
configPath: string
server: Record<string, unknown>
written: string[]
backups: string[]
errors: string[]
}): void {
const { configPath, server, written, backups, errors } = input
const config = this.readJsonObject(configPath)
if (config === null) {
errors.push(`${this.tilde(configPath)} 不是有效 JSON,已跳过(请手动粘贴配置片段)`)
return
}
const servers = (config.mcpServers && typeof config.mcpServers === 'object'
? config.mcpServers as Record<string, unknown>
: {}) as Record<string, unknown>
servers.everroom = server
config.mcpServers = servers
mkdirSync(dirname(configPath), { recursive: true })
this.backupFile(configPath, backups)
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
written.push(this.tilde(configPath))
}

private removeMcpServer(input: {
configPath: string
removed: string[]
backups: string[]
}): void {
const { configPath, removed, backups } = input
const config = this.readJsonObject(configPath)
if (config === null) return
const servers = config.mcpServers as Record<string, unknown> | undefined
if (!servers || !('everroom' in servers)) return
delete servers.everroom
this.backupFile(configPath, backups)
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
removed.push(this.tilde(configPath))
}

private claudeConfig(): string {
return join(this.homeDirectory, '.claude.json')
}

private cursorConfig(): string {
return join(this.homeDirectory, '.cursor', 'mcp.json')
}

private serverConfigOf(plan: AgentAccessInstallPlanDto): Record<string, unknown> {
// Cursor/通用 JSON 形态:{ url, headers };Claude Code 加 type: http。
return plan.target === 'claude-code'
? { type: 'http', url: plan.url, headers: plan.headers }
: { url: plan.url, headers: plan.headers }
}

apply(plan: AgentAccessInstallPlanDto): InstallOutcome {
const writtenFiles: string[] = []
const backups: string[] = []
const manualSteps: Array<{ title: string; content: string }> = []
const errors: string[] = []

const skillRoot = this.writeSkillFiles(plan, writtenFiles)

if (plan.target === 'claude-code') {
this.mergeMcpServer({
configPath: this.claudeConfig(),
server: this.serverConfigOf(plan),
written: writtenFiles,
backups,
errors,
})
} else if (plan.target === 'cursor') {
this.mergeMcpServer({
configPath: this.cursorConfig(),
server: this.serverConfigOf(plan),
written: writtenFiles,
backups,
errors,
})
} else if (plan.target === 'codex') {
manualSteps.push({
title: '把以下内容并入 ~/.codex/config.toml(token 已替换为实际值)',
content: plan.mcpConfig.replace(
/Authorization = "Bearer <token>"/,
`Authorization = ${JSON.stringify(plan.headers.Authorization ?? '')}`,
),
})
} else {
manualSteps.push({ title: '按宿主支持的方式添加 MCP 服务器', content: plan.mcpConfig })
}

return {
writtenFiles,
backups,
manualSteps,
notes: [
...plan.notes,
...(skillRoot ? [`技能已写入 ${this.tilde(skillRoot)}`] : []),
...errors,
'配置文件包含连接 token,请勿提交到版本库或分享给他人。',
],
}
}

uninstall(target: string): UninstallOutcome {
const removed: string[] = []
const backups: string[] = []

const skillRootsByTarget: Record<string, string | undefined> = {
'claude-code': join(this.homeDirectory, '.claude', 'skills', 'everroom-agent'),
cursor: join(this.homeDirectory, '.cursor', 'skills', 'everroom-agent'),
codex: join(this.homeDirectory, '.codex', 'skills', 'everroom-agent'),
}
const skillRoot = skillRootsByTarget[target]
if (skillRoot && existsSync(skillRoot)) {
rmSync(skillRoot, { recursive: true, force: true })
removed.push(this.tilde(skillRoot))
}

if (target === 'claude-code') {
this.removeMcpServer({ configPath: this.claudeConfig(), removed, backups })
} else if (target === 'cursor') {
this.removeMcpServer({ configPath: this.cursorConfig(), removed, backups })
}

return { removed, backups }
}
}
50 changes: 50 additions & 0 deletions apps/desktop/src/main/agent-access/settings-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer } from 'node:net'
import { afterEach, describe, expect, it } from 'vitest'
import { AgentAccessSettingsStore, isValidFixedPort } from './settings-store'

const dirs: string[] = []

function makeStore(): AgentAccessSettingsStore {
const dir = mkdtempSync(join(tmpdir(), 'agent-access-settings-'))
dirs.push(dir)
return new AgentAccessSettingsStore(dir)
}

afterEach(() => {
while (dirs.length > 0) rmSync(dirs.pop()!, { recursive: true, force: true })
})

describe('AgentAccessSettingsStore', () => {
it('默认随机端口;roundtrip 保留合法固定端口', () => {
const store = makeStore()
expect(store.read()).toEqual({ fixedPort: null })
store.write({ fixedPort: 8790 })
expect(store.read()).toEqual({ fixedPort: 8790 })
expect(store.portProvider()).toBe(8790)
})

it('非法端口与损坏文件都回退默认,不抛错', () => {
const store = makeStore()
expect(isValidFixedPort(80)).toBe(false)
expect(isValidFixedPort(70_000)).toBe(false)
expect(isValidFixedPort(8790)).toBe(true)
store.write({ fixedPort: 8790 })
writeFileSync(join(dirs[dirs.length - 1]!, 'agent-access-settings.json'), 'not-json', 'utf8')
expect(store.read()).toEqual({ fixedPort: null })
})

it('isPortFree:空闲端口为真,被占用端口为假', async () => {
const blocker = createServer()
await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve))
const address = blocker.address() as { port: number }
try {
expect(await AgentAccessSettingsStore.isPortFree(address.port)).toBe(false)
expect(await AgentAccessSettingsStore.isPortFree(address.port + 1)).toBe(true)
} finally {
blocker.close()
}
})
})
Loading
Loading