/** Reconfigure the user's YCode expert packages through the production expert API. Never reads WorkBuddy. */ import fs from 'node:fs/promises' import path from 'node:path' import { createHash, randomUUID } from 'node:crypto' import { pathToFileURL, fileURLToPath } from 'node:url' import { build } from 'esbuild' const project = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') export const definitions = JSON.parse(await fs.readFile(path.join(project, 'resources/agents/general-development.json'), 'utf8')) const hash = value => createHash('sha256').update(value).digest('hex') export function packageFiles(agent) { const manifest = { name: agent.id, plugin: agent.id, agentName: agent.agentName, version: '2.1.0', expertType: 'agent', description: agent.description, displayName: agent.displayName, profession: agent.profession, displayDescription: agent.displayDescription, categoryId: '02-Engineering', author: { name: 'YCode' }, agents: [`./agents/${agent.agentName}.md`], skills: [], tags: agent.tags, defaultInitPrompt: agent.quickPrompts[0], quickPrompts: agent.quickPrompts, avatar: agent.avatar, ycode: { generalDevelopmentRole: agent.role, definitionVersion: definitions.version } } const prompt = [`# ${agent.profession.zh}`, '', ...definitions.commonInstructions, '', '## 工作职责', '', ...agent.instructions].join('\n\n') return { '.codebuddy-plugin/plugin.json': JSON.stringify(manifest, null, 2) + '\n', [`agents/${agent.agentName}.md`]: `---\nname: ${agent.agentName}\ndescription: ${JSON.stringify(agent.description)}\nmaxTurns: 50\nskills: []\n---\n\n${prompt}\n`, 'README.md': `# ${agent.profession.zh}\n\n${agent.displayDescription.zh}\n\n这是用户定制的通用开发角色,职责、提示词和快捷提问来自 YCode 的 general-development.json。\n角色配置不授予额外工具权限。\n` } } async function ordinaryDirectory(target) { const absolute = path.resolve(target) let current = path.parse(absolute).root for (const part of path.relative(current, absolute).split(path.sep)) { current = path.join(current, part) const stat = await fs.lstat(current) if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error('Ordinary directory required: ' + current) } } async function inventory(root) { const items = [] async function walk(directory) { await ordinaryDirectory(directory) for (const entry of await fs.readdir(directory, { withFileTypes: true })) { const file = path.join(directory, entry.name) if (entry.isSymbolicLink()) throw Error('Links are not supported: ' + file) if (entry.isDirectory()) await walk(file) else if (entry.isFile()) items.push([path.relative(root, file), hash(await fs.readFile(file))]) else throw Error('Ordinary file required: ' + file) } } await walk(root) return items.sort(([left], [right]) => left.localeCompare(right)) } async function avatarResource(agent) { if (!/^[a-z-]+$/.test(agent.role) || agent.avatar !== `./avatars/${agent.role}.png`) throw Error('Invalid general role avatar: ' + agent.id) const bytes = await fs.readFile(path.join(project, 'resources/agents', agent.avatar)) if (bytes.length > 500 * 1024 || bytes.length < 24 || bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a' || bytes.readUInt32BE(16) !== 256 || bytes.readUInt32BE(20) !== 256) throw Error('A 256px PNG avatar below 500KiB is required: ' + agent.id) return { bytes, url: 'data:image/png;base64,' + bytes.toString('base64'), sha256: hash(bytes) } } async function installAvatar(packageDirectory, agent, avatar) { await ordinaryDirectory(packageDirectory) const directory = path.join(packageDirectory, 'avatars') await fs.mkdir(directory, { recursive: true }) await ordinaryDirectory(directory) const target = path.join(directory, `${agent.role}.png`) try { const stat = await fs.lstat(target); if (!stat.isFile() || stat.isSymbolicLink()) throw Error('Avatar must be an ordinary file: ' + target) } catch (error) { if (error.code !== 'ENOENT') throw error } const temporary = target + '.' + randomUUID() + '.tmp' await fs.writeFile(temporary, avatar.bytes, { flag: 'wx' }) try { await fs.rename(temporary, target) } finally { await fs.rm(temporary, { force: true }) } if (hash(await fs.readFile(target)) !== avatar.sha256) throw Error('Installed avatar differs: ' + agent.id) } async function loadService(userData, directory) { const modulePath = path.join(directory, 'expert-service.mjs') await build({ stdin: { contents: "export * from './src/main/capabilities/experts'", resolveDir: project, loader: 'ts' }, outfile: modulePath, bundle: true, platform: 'node', format: 'esm', packages: 'external', logLevel: 'warning', plugins: [{ name: 'explicit-ycode-data', setup(builder) { builder.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'explicit-ycode-data' })) builder.onLoad({ filter: /.*/, namespace: 'explicit-ycode-data' }, () => ({ contents: `export const app={getPath:()=>${JSON.stringify(userData)}};export const safeStorage={isEncryptionAvailable:()=>false}` })) } }] }) return import(pathToFileURL(modulePath).href) } export async function configure(userData, { checkOnly = false } = {}) { userData = path.resolve(userData) if (path.basename(userData).toLowerCase() !== 'ycode') throw Error('An explicit YCode user-data folder is required') await ordinaryDirectory(userData) const reports = await fs.mkdtemp(path.join(project, '.tmp/general-development-agents-')) const service = await loadService(userData, reports) const marketRoot = path.join(userData, 'plugins/marketplaces/my-experts') const packageRoot = path.join(marketRoot, 'plugins') const marketFile = path.join(marketRoot, '.codebuddy-plugin/marketplace.json') const beforeMarket = JSON.parse(await fs.readFile(marketFile, 'utf8')) const ids = new Set(definitions.agents.map(agent => agent.id)) const unrelated = beforeMarket.plugins.filter(entry => !ids.has(entry.name)) const plans = [] for (const agent of definitions.agents) { const files = packageFiles(agent), validation = service.validateExpertFiles(files, agent.id) const avatar = await avatarResource(agent) if (validation.errors.length) throw Error(validation.errors.join('\n')) let previous try { previous = await service.readExpert(agent.id) } catch (error) { if (error.code !== 'ENOENT') throw error } if (previous && previous.expert.agentName !== agent.agentName) throw Error('Agent identity changed: ' + agent.id) const alreadyConfigured = previous && Object.entries(files).every(([name, content]) => previous.files[name] === content) && Object.keys(previous.files).every(name => name in files || name === '.created-by-session') && previous.expert.avatar === avatar.url plans.push({ agent, files, avatar, previous, alreadyConfigured, warnings: validation.warnings }) } let backup const changed = plans.filter(plan => !plan.alreadyConfigured) if (checkOnly && changed.length) throw Error('Installed definitions differ: ' + changed.map(plan => plan.agent.role).join(', ')) if (changed.length) { backup = path.join(userData, 'capabilities/expert-backups', 'general-development-' + randomUUID()) const backupRelative = path.relative(userData, backup) if (path.isAbsolute(backupRelative) || backupRelative.startsWith('..') || !backupRelative) throw Error('Backup path escaped YCode') await fs.mkdir(backup, { recursive: true }) await ordinaryDirectory(backup) await fs.writeFile(path.join(backup, 'marketplace.before.json'), JSON.stringify(beforeMarket, null, 2), { flag: 'wx' }) const snapshots = [] for (const { agent, previous } of changed) { if (!previous) { snapshots.push({ id: agent.id, existed: false }); continue } const source = path.join(packageRoot, agent.id), target = path.join(backup, agent.id) const before = await inventory(source) await fs.cp(source, target, { recursive: true, errorOnExist: true, force: false }) if (JSON.stringify(before) !== JSON.stringify(await inventory(target)) || JSON.stringify(before) !== JSON.stringify(await inventory(source))) throw Error('Expert changed while backing up: ' + agent.id) snapshots.push({ id: agent.id, existed: true, files: before }) } await fs.writeFile(path.join(backup, 'backup.json'), JSON.stringify({ source: userData, snapshots }, null, 2), { flag: 'wx' }) const completed = [] for (const { agent, files, avatar, previous } of changed) { const owner = 'general-development-' + randomUUID() const draft = await service.expertPackageOperation(owner, { id: agent.id, action: 'read', refresh: true }) if (JSON.stringify(draft.files) !== JSON.stringify(previous?.files ?? {})) throw Error('Expert changed after backup: ' + agent.id) const edits = Object.fromEntries(Object.keys(draft.files).filter(name => !(name in files) && !['.created-by-session', '.pending-preview-session'].includes(name)).map(name => [name, null])) await service.expertPackageOperation(owner, { id: agent.id, action: 'write', files: { ...edits, ...files } }) const validation = await service.expertPackageOperation(owner, { id: agent.id, action: 'validate' }) if (!validation.valid) throw Error(validation.errors.join('\n')) const result = await service.expertPackageOperation(owner, { id: agent.id, action: 'register' }) if (!result.success) throw Error('Expert registration failed: ' + agent.id) // The text-only editing API preserves binaries but cannot add them; install our verified PNG separately. await installAvatar(path.join(packageRoot, agent.id), agent, avatar) completed.push(agent.id) await fs.writeFile(path.join(backup, 'applied.json'), JSON.stringify({ completed }, null, 2)) } } const checks = [] const listed = await service.listExperts() for (const { agent, files, avatar } of plans) { const document = await service.readExpert(agent.id) const binding = await service.activateExpert(agent.id) const turn = await service.expertTurnContext(agent.id) if (!listed.some(item => item.id === agent.id && !item.error) || binding.profession !== agent.profession.zh || binding.name !== agent.displayName.zh || turn.display.profession !== agent.profession.zh) throw Error('Role activation mismatch: ' + agent.id) for (const [name, content] of Object.entries(files)) if (document.files[name] !== content) throw Error('Role text mismatch: ' + name) if (document.expert.skills?.length || turn.context.includes('')) throw Error('Specialist skill remains loaded: ' + agent.id) if (Object.keys(document.files).some(name => !(name in files) && name !== '.created-by-session')) throw Error('Unexpected specialist text remains: ' + agent.id) if (!turn.context.includes(document.prompt) || !agent.instructions.every(instruction => turn.context.includes(instruction))) throw Error('Role instructions did not reach turn context') if (document.expert.avatar !== avatar.url || binding.avatarUrl !== avatar.url) throw Error('Avatar did not reach the list/detail/activation data: ' + agent.id) checks.push({ id: agent.id, role: agent.role, profession: binding.profession, activated: true, filesMatched: Object.keys(files).length, startupSkills: 0, avatarBytes: avatar.bytes.length, avatarSha256: avatar.sha256 }) } const afterMarket = JSON.parse(await fs.readFile(marketFile, 'utf8')) if (JSON.stringify(unrelated) !== JSON.stringify(afterMarket.plugins.filter(entry => !ids.has(entry.name)))) throw Error('Unrelated expert catalog entries changed') const report = { result: 'PASS', checkOnly, userData, backup, changed: changed.map(plan => plan.agent.id), checks, warnings: plans.flatMap(plan => plan.warnings.map(warning => ({ id: plan.agent.id, warning }))), messagesSent: 0 } await fs.writeFile(path.join(reports, 'report.json'), JSON.stringify(report, null, 2)) return { ...report, reportDirectory: reports } } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const index = process.argv.indexOf('--user-data') if (index < 0 || !process.argv[index + 1]) throw Error('Pass --user-data ') const report = await configure(process.argv[index + 1], { checkOnly: process.argv.includes('--check') }) console.log(JSON.stringify(report, null, 2)) }