Skip to content

Commit ccdafeb

Browse files
authored
feat(agent-adapter): Phase 3-4 migration (UFO-132)
Closes UFO-132
1 parent fd3a293 commit ccdafeb

39 files changed

Lines changed: 1592 additions & 350 deletions

docs/en/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
* [API Provider Presets](advanced/api-providers.md)
2323
* [Templates and Output Styles](advanced/templates.md)
2424
* [Internationalization and Language](advanced/i18n.md)
25+
* [Community Agent Adapters](advanced/community-adapters.md)
2526
* [Troubleshooting](advanced/troubleshooting.md)
2627

2728
## CLI Commands
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Community Agent Adapters
2+
3+
ZCF uses an adapter-based architecture to support different coding agent CLIs. Built-in adapters are shipped with the package (`claude-code`, `codex`, `opencode`), but you can also write and load your own adapter without modifying ZCF.
4+
5+
## Adapter Interface
6+
7+
A community adapter must implement the `AgentAdapter` interface exported by `zcf`:
8+
9+
```ts
10+
import type { AgentAdapter, AgentConfigFile, AgentContext, AgentSkillSpec, InstallOptions, UninstallOptions, UpdateOptions } from 'zcf'
11+
12+
export default {
13+
id: 'my-agent',
14+
displayName: 'My Agent',
15+
aliases: ['ma'],
16+
homeDir: '/home/user/.my-agent',
17+
templateDir: 'templates/my-agent',
18+
configFiles: [
19+
{ id: 'settings', path: '/home/user/.my-agent/settings.json', format: 'json', mergeStrategy: 'merge' },
20+
],
21+
skillSpec: {
22+
skillsDir: '/home/user/.my-agent/skills',
23+
manifestName: 'SKILL.md',
24+
scopes: ['global', 'project'],
25+
},
26+
27+
async isInstalled() { /* ... */ },
28+
async install(options: InstallOptions, ctx: AgentContext) { /* ... */ },
29+
async update(options: UpdateOptions, ctx: AgentContext) { /* ... */ },
30+
async uninstall(options: UninstallOptions, ctx: AgentContext) { /* ... */ },
31+
async backup(file: AgentConfigFile) { /* ... */ },
32+
} satisfies AgentAdapter
33+
```
34+
35+
Key methods:
36+
37+
- `isInstalled` — detect whether the target CLI is available.
38+
- `install` — render templates, write configuration, install skills.
39+
- `update` — refresh templates and skills without a full reinstall.
40+
- `uninstall` — remove ZCF-managed files and skills.
41+
- `backup` — create a timestamped backup before overwriting a config file.
42+
43+
Use the `TemplateEngine` class from `zcf/core/template-engine` to render templates and skills consistently.
44+
45+
## Loading from a Local TOML Manifest
46+
47+
Place a TOML manifest in `~/.ufomiao/zcf/adapters/<id>.toml`:
48+
49+
```toml
50+
id = "my-agent"
51+
displayName = "My Agent"
52+
aliases = ["ma"]
53+
main = "my-agent/adapter.mjs"
54+
```
55+
56+
The `main` path is relative to `~/.ufomiao/zcf/adapters/`. The referenced module must export a valid `AgentAdapter` as its default export. You can also use a factory export:
57+
58+
```toml
59+
id = "my-agent"
60+
displayName = "My Agent"
61+
main = "my-agent/adapter.mjs"
62+
export = "createAdapter"
63+
```
64+
65+
```ts
66+
// my-agent/adapter.mjs
67+
export function createAdapter(manifest) {
68+
return {
69+
id: manifest.id,
70+
// ...
71+
}
72+
}
73+
```
74+
75+
ZCF loads external adapters on startup. A broken adapter is logged and skipped so it cannot break the CLI.
76+
77+
## Loading from an NPM Package
78+
79+
Reference an NPM package in the manifest:
80+
81+
```toml
82+
id = "my-agent"
83+
displayName = "My Agent"
84+
package = "zcf-adapter-my-agent"
85+
export = "adapter"
86+
```
87+
88+
The package must be resolvable by Node (install it globally or in a parent project). The adapter is loaded via dynamic import.
89+
90+
## Best Practices
91+
92+
1. **Preserve user customizations** — use `mergeStrategy: 'merge'` for JSON/TOML files and back up before writing.
93+
2. **Scope skills** — put user-facing skills under `templates/<agent>/skills/user/` and project-internal skills under `templates/<agent>/skills/zcf/`.
94+
3. **Manual-only skills** — if a skill should only be triggered by the user, add `disable-model-invocation: true` to its `SKILL.md` frontmatter.
95+
4. **Use the template engine**`renderConfigFile`, `renderCommon`, and `renderSkill` handle merge strategies, backups, and i18n-friendly rendering.
96+
5. **Test coverage** — adapters must have unit tests covering installation, update, uninstall, and edge cases. Maintain ≥80% coverage.
97+
98+
## Example `SKILL.md`
99+
100+
```markdown
101+
---
102+
name: my-skill
103+
description: A custom skill for my agent
104+
version: 1.0.0
105+
namespace: user
106+
disable-model-invocation: true
107+
---
108+
109+
# My Skill
110+
111+
Instructions for the agent when this skill is invoked.
112+
```
113+
114+
## Troubleshooting
115+
116+
- **Adapter not appearing in `zcf --help`**: check the manifest ID/aliases are unique and the module exports a valid adapter.
117+
- **`isInstalled` always false**: ensure the executable is on the user's `PATH`.
118+
- **Template not rendered**: verify `templateDir` points to a directory containing the expected files and `renderCommon` is called for shared resources.

docs/zh-CN/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
* [API 提供商预设](advanced/api-providers.md)
2323
* [模板与输出风格](advanced/templates.md)
2424
* [国际化与语言](advanced/i18n.md)
25+
* [社区 Agent Adapter](advanced/community-adapters.md)
2526
* [故障排除](advanced/troubleshooting.md)
2627

2728
## CLI 命令
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# 社区 Agent Adapter
2+
3+
ZCF 采用 adapter 架构来支持不同的 coding agent CLI。内置 adapter(`claude-code``codex``opencode`)随包发布,但你也可以编写并加载自定义 adapter,无需修改 ZCF 源码。
4+
5+
## Adapter 接口
6+
7+
社区 adapter 必须实现 `zcf` 导出的 `AgentAdapter` 接口:
8+
9+
```ts
10+
import type { AgentAdapter, AgentConfigFile, AgentContext, AgentSkillSpec, InstallOptions, UninstallOptions, UpdateOptions } from 'zcf'
11+
12+
export default {
13+
id: 'my-agent',
14+
displayName: 'My Agent',
15+
aliases: ['ma'],
16+
homeDir: '/home/user/.my-agent',
17+
templateDir: 'templates/my-agent',
18+
configFiles: [
19+
{ id: 'settings', path: '/home/user/.my-agent/settings.json', format: 'json', mergeStrategy: 'merge' },
20+
],
21+
skillSpec: {
22+
skillsDir: '/home/user/.my-agent/skills',
23+
manifestName: 'SKILL.md',
24+
scopes: ['global', 'project'],
25+
},
26+
27+
async isInstalled() { /* ... */ },
28+
async install(options: InstallOptions, ctx: AgentContext) { /* ... */ },
29+
async update(options: UpdateOptions, ctx: AgentContext) { /* ... */ },
30+
async uninstall(options: UninstallOptions, ctx: AgentContext) { /* ... */ },
31+
async backup(file: AgentConfigFile) { /* ... */ },
32+
} satisfies AgentAdapter
33+
```
34+
35+
核心方法:
36+
37+
- `isInstalled` — 检测目标 CLI 是否已安装。
38+
- `install` — 渲染模板、写入配置、安装 skill。
39+
- `update` — 刷新模板与 skill,不执行完整重装。
40+
- `uninstall` — 移除 ZCF 管理的文件与 skill。
41+
- `backup` — 在覆盖配置文件前创建带时间戳的备份。
42+
43+
使用 `zcf/core/template-engine` 中的 `TemplateEngine` 类可保持一致地渲染模板与 skill。
44+
45+
## 通过本地 TOML Manifest 加载
46+
47+
`~/.ufomiao/zcf/adapters/<id>.toml` 放置 TOML 清单:
48+
49+
```toml
50+
id = "my-agent"
51+
displayName = "My Agent"
52+
aliases = ["ma"]
53+
main = "my-agent/adapter.mjs"
54+
```
55+
56+
`main` 路径相对于 `~/.ufomiao/zcf/adapters/`。被引用的模块必须以默认导出形式提供有效的 `AgentAdapter`。也支持工厂导出:
57+
58+
```toml
59+
id = "my-agent"
60+
displayName = "My Agent"
61+
main = "my-agent/adapter.mjs"
62+
export = "createAdapter"
63+
```
64+
65+
```ts
66+
// my-agent/adapter.mjs
67+
export function createAdapter(manifest) {
68+
return {
69+
id: manifest.id,
70+
// ...
71+
}
72+
}
73+
```
74+
75+
ZCF 在启动时加载外部 adapter。加载失败的 adapter 会被记录并跳过,不会影响整个 CLI。
76+
77+
## 通过 NPM 包加载
78+
79+
在清单中引用 NPM 包:
80+
81+
```toml
82+
id = "my-agent"
83+
displayName = "My Agent"
84+
package = "zcf-adapter-my-agent"
85+
export = "adapter"
86+
```
87+
88+
该包需要能被 Node 解析(全局安装或安装在父项目中)。adapter 通过动态导入加载。
89+
90+
## 最佳实践
91+
92+
1. **保留用户自定义内容** — JSON/TOML 文件使用 `mergeStrategy: 'merge'`,写入前进行备份。
93+
2. **划分 skill 作用域** — 面向用户的 skill 放在 `templates/<agent>/skills/user/`,项目内部 skill 放在 `templates/<agent>/skills/zcf/`
94+
3. **手动触发 skill** — 仅希望用户手动触发的 skill,在 `SKILL.md` frontmatter 中加上 `disable-model-invocation: true`
95+
4. **复用模板引擎**`renderConfigFile``renderCommon``renderSkill` 已处理合并策略、备份与 i18n 友好渲染。
96+
5. **测试覆盖** — adapter 必须包含安装、更新、卸载及边界用例的单元测试,保持 ≥80% 覆盖率。
97+
98+
## `SKILL.md` 示例
99+
100+
```markdown
101+
---
102+
name: my-skill
103+
description: A custom skill for my agent
104+
version: 1.0.0
105+
namespace: user
106+
disable-model-invocation: true
107+
---
108+
109+
# My Skill
110+
111+
Instructions for the agent when this skill is invoked.
112+
```
113+
114+
## 故障排查
115+
116+
- **adapter 未出现在 `zcf --help`**:检查 manifest 的 ID/aliases 是否唯一,模块是否导出有效 adapter。
117+
- **`isInstalled` 始终返回 false**:确保可执行文件在用户 `PATH` 中。
118+
- **模板未渲染**:确认 `templateDir` 指向包含预期文件的目录,并调用 `renderCommon` 渲染共享资源。

src/adapters/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { claudeCodeAdapter } from './claude-code'
22
import { codexAdapter } from './codex'
3+
import { loadExternalAdapters } from './loader'
34
import { opencodeAdapter } from './opencode'
45
import { listAgentIds, registerAgent } from './registry'
56

67
export * from './adapter-interface'
8+
export * from './loader'
79
export * from './registry'
810
export { claudeCodeAdapter, codexAdapter, opencodeAdapter }
911

@@ -15,7 +17,7 @@ export { claudeCodeAdapter, codexAdapter, opencodeAdapter }
1517
* already registered, keeping setup idempotent in long-running or test
1618
* environments.
1719
*/
18-
export function registerAllAgents(): void {
20+
export async function registerAllAgents(): Promise<void> {
1921
if (!listAgentIds().includes(claudeCodeAdapter.id)) {
2022
registerAgent(claudeCodeAdapter)
2123
}
@@ -25,4 +27,16 @@ export function registerAllAgents(): void {
2527
if (!listAgentIds().includes(opencodeAdapter.id)) {
2628
registerAgent(opencodeAdapter)
2729
}
30+
31+
try {
32+
const external = await loadExternalAdapters()
33+
for (const adapter of external) {
34+
if (!listAgentIds().includes(adapter.id)) {
35+
registerAgent(adapter)
36+
}
37+
}
38+
}
39+
catch {
40+
// External adapters are best-effort; do not fail startup.
41+
}
2842
}

src/adapters/loader.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { AgentAdapter, AgentId } from './adapter-interface'
2+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
3+
import { pathToFileURL } from 'node:url'
4+
import { join } from 'pathe'
5+
import { ZCF_CONFIG_DIR } from '../constants'
6+
import { parseToml } from '../utils/toml-edit'
7+
8+
export interface ExternalAdapterManifest {
9+
id: AgentId
10+
displayName: string
11+
aliases?: string[]
12+
/** Absolute or relative path to the adapter implementation module */
13+
main?: string
14+
/** NPM package name that exports the adapter */
15+
package?: string
16+
/** Exported member to read from the module. Defaults to "default" or "adapter". */
17+
export?: string
18+
}
19+
20+
const EXTERNAL_ADAPTERS_DIR = join(ZCF_CONFIG_DIR, 'adapters')
21+
22+
function isValidManifest(obj: unknown): obj is ExternalAdapterManifest {
23+
if (!obj || typeof obj !== 'object')
24+
return false
25+
const o = obj as Record<string, unknown>
26+
return typeof o.id === 'string' && typeof o.displayName === 'string'
27+
}
28+
29+
async function resolveAdapterModule(manifest: ExternalAdapterManifest, manifestDir: string): Promise<AgentAdapter> {
30+
let moduleUrl: string | undefined
31+
32+
if (manifest.package) {
33+
moduleUrl = manifest.package
34+
}
35+
else if (manifest.main) {
36+
const mainPath = manifest.main.startsWith('/')
37+
? manifest.main
38+
: join(manifestDir, manifest.main)
39+
moduleUrl = pathToFileURL(mainPath).href
40+
}
41+
42+
if (!moduleUrl) {
43+
throw new Error(`External adapter "${manifest.id}" must specify "main" or "package".`)
44+
}
45+
46+
const mod = await import(moduleUrl)
47+
const exportName = manifest.export || 'default'
48+
const exported = exportName === 'default' ? mod.default : mod[exportName]
49+
const adapter: AgentAdapter = typeof exported === 'function' ? exported(manifest) : exported
50+
51+
if (!adapter || typeof adapter.install !== 'function' || typeof adapter.isInstalled !== 'function') {
52+
throw new Error(`External adapter "${manifest.id}" does not export a valid AgentAdapter.`)
53+
}
54+
55+
return adapter
56+
}
57+
58+
/**
59+
* Load external adapter manifests from `~/.ufomiao/zcf/adapters/*.toml` and
60+
* from NPM packages referenced by those manifests.
61+
*
62+
* This is intentionally lenient: a broken adapter is logged and skipped so
63+
* that one bad community package cannot break the whole CLI.
64+
*/
65+
export async function loadExternalAdapters(adaptersDir: string = EXTERNAL_ADAPTERS_DIR): Promise<AgentAdapter[]> {
66+
if (!existsSync(adaptersDir)) {
67+
return []
68+
}
69+
70+
const entries = readdirSync(adaptersDir)
71+
const adapters: AgentAdapter[] = []
72+
73+
for (const entry of entries) {
74+
if (!entry.endsWith('.toml'))
75+
continue
76+
77+
const manifestPath = join(adaptersDir, entry)
78+
try {
79+
const content = readFileSync(manifestPath, 'utf-8')
80+
const manifest = parseToml<ExternalAdapterManifest>(content)
81+
82+
if (!isValidManifest(manifest)) {
83+
console.warn(`[zcf] Skipping invalid adapter manifest: ${manifestPath}`)
84+
continue
85+
}
86+
87+
const adapter = await resolveAdapterModule(manifest, adaptersDir)
88+
adapters.push(adapter)
89+
}
90+
catch (error) {
91+
console.warn(`[zcf] Failed to load external adapter from ${manifestPath}: ${(error as Error).message}`)
92+
}
93+
}
94+
95+
return adapters
96+
}

0 commit comments

Comments
 (0)