cardbuddy 0.12.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,6 +95,58 @@ cardbuddy --preview # 等价短参 -p
95
95
 
96
96
  ---
97
97
 
98
+ ## MCP:把 `card_*` 工具挂给外部 AI agent
99
+
100
+ ```bash
101
+ cardbuddy --mcp # stdio JSON-RPC 2.0;不启 HTTP 服务、不占端口
102
+ ```
103
+
104
+ 编辑器内置的网页 AI 助手走 `POST /__ai/chat`;`--mcp` 把**同一套** 16 个 `card_*` 工具
105
+ (实现同为 `mcp/tools.mjs` + `mcp/client.mjs`,不存在第二份契约逻辑)以 stdio 暴露给
106
+ Codex / Claude 这类外部 agent。工具清单与用法见 `skills/dsl-card-ai/SKILL.md`。
107
+
108
+ agent 侧配置(`cwd` 必须是卡片工程根,读写才落在 `src/cards/`):
109
+
110
+ ```jsonc
111
+ {
112
+ "mcpServers": {
113
+ "dsl-card": {
114
+ "command": "<工程绝对路径>/node_modules/.bin/cardbuddy",
115
+ "args": ["--mcp"]
116
+ }
117
+ }
118
+ }
119
+ ```
120
+
121
+ > **stdout 是协议信道。** 经 `npm run` 调用必须加 `--silent`:`npm run mcp` 会把
122
+ > `> 包名 脚本名` 横幅打到 stdout,多一行客户端就解析失败;直接调
123
+ > `node_modules/.bin/cardbuddy --mcp` 无此问题。诊断一律走 stderr 且默认静默,
124
+ > `CARDBUDDY_MCP_VERBOSE=1` 打开(会打出注入后的 `root=` / `apiBase=`)。
125
+
126
+ `cardbuddy init` 生成的工程已带 `npm run mcp` 脚本。回归见 `tools/tests/cardbuddy-mcp.test.mjs`。
127
+
128
+ ### 工具对本地服务的依赖分档
129
+
130
+ `--mcp` 自己不启服务。启动时自动注入 `CARD_ROOT=<工程根>` 与
131
+ `CARD_API_BASE=http(s)://127.0.0.1:<端口>`(端口取 `FINMALL_CARD_PORT` / `DSL_DEV_PORT`,默认
132
+ 8787;已有同名环境变量时不覆盖),工具据此决定走 HTTP 还是本地降级:
133
+
134
+ | 档 | 工具 |
135
+ | --- | --- |
136
+ | 纯本地,从不需要服务 | `card_route_input` `card_scaffold` `card_add_component` |
137
+ | 优先服务,连不上自动降级为直接读写盘(`validate` 跑 Python 校验器) | `card_list` `card_read` `card_write` `card_validate` `card_history` `card_rollback` |
138
+ | 优先服务,降级到包内独立离线渲染器 | `card_screenshot` |
139
+ | **必须**另起一个 `cardbuddy` | `card_render_status` `card_node_models` `card_import_meta` |
140
+ | **必须**另起服务 **且**已登录 FinMall | `card_sync` `card_save` `card_slice_upload` |
141
+
142
+ > **别直接跑包内 `dist/vendored/mcp/server.mjs`。** `mcp/client.mjs` 在**模块顶层**就 resolve
143
+ > `CARD_ROOT` / `CARD_API_BASE`(不是每次调用时读),绕过 CLI 包装层时前者会落到包内快照目录
144
+ > (`card_list` 返回 0 张卡)、后者回退 `devApiBase()` 的 5180(消费者工程没有 Vite 把 `/__*`
145
+ > 代理到 8787,`card_render_status` 直接 `dev-server-required`)。仓库内跑源码时入口回退
146
+ > `<repo>/mcp/server.mjs`,无需先 build。
147
+
148
+ ---
149
+
98
150
  ## 配置(环境变量 / `.env`)
99
151
 
100
152
  在仓库根目录的 `.env` 中配置,或直接用环境变量:
package/dist/cli.js CHANGED
@@ -17,9 +17,14 @@ import { installLogMirror } from './core.js'
17
17
  import { loadConfig } from './config.js'
18
18
  import { runInit } from './init.js'
19
19
 
20
+ const args = process.argv.slice(2)
21
+ const mcpMode = args.includes('--mcp')
22
+
20
23
  // 尽早镜像 stdout/stderr:让启动期日志(配置解析、端口占用提示等)也进入
21
24
  // 编辑器底部「服务日志」面板的环形缓冲。幂等,CARDBUDDY_STREAM_LOGS=0 时自动跳过。
22
- installLogMirror()
25
+ // --mcp 下整个跳过:该模式不启 HTTP 服务,/__logs 没有消费者,让镜像去逐帧拦截
26
+ // JSON-RPC 报文纯属白做功;协议信道的纯净由「这条路径上不打日志」保证(见 src/mcp.js)。
27
+ if (!mcpMode) installLogMirror()
23
28
 
24
29
  // 被 dev.mjs 拉起时让出来源前缀(由启动器带背景色 chip 标注),避免双重来源标签。
25
30
  const srcTag =
@@ -40,12 +45,11 @@ for (const p of ['.env', '.env.cb', '.env.cb.local']) {
40
45
  }
41
46
  }
42
47
 
43
- const args = process.argv.slice(2)
44
48
  if (args.includes('--help') || args.includes('-h')) {
45
49
  console.log(`cardbuddy — CardBuddy 本地开发服务
46
50
 
47
51
  用法:
48
- cardbuddy [--preview] [--help]
52
+ cardbuddy [--preview] [--mcp] [--help]
49
53
  cardbuddy init [目标目录] [--force] [--dry-run] [--no-skills] [--no-sample]
50
54
 
51
55
  命令:
@@ -61,6 +65,13 @@ if (args.includes('--help') || args.includes('-h')) {
61
65
  选项:
62
66
  --preview, -p 同时托管打包后的编辑器前端(本地预览),替代线上地址,并自动打开浏览器
63
67
  旧名 --offline / -o 仍可用,已废弃
68
+ --mcp 以 stdio 起 MCP server(JSON-RPC 2.0),把 16 个 card_* 工具挂给 Codex / Claude
69
+ 等外部 agent;不启 HTTP 服务、不占端口。stdout 只走协议(经 npm run 调用要加
70
+ --silent,否则横幅污染协议流),诊断走 stderr(CARDBUDDY_MCP_VERBOSE=1 打开)。
71
+ 本命令自身不起服务,工具按依赖分档:route_input / scaffold / add_component 纯本地;
72
+ list / read / write / validate / history / rollback 降级为直接读写盘;screenshot
73
+ 降级到包内离线渲染器;render_status / node_models / import_meta 必须另起一个
74
+ cardbuddy;sync / save / slice_upload 还必须已登录 FinMall。
64
75
  --help, -h 显示帮助
65
76
 
66
77
  环境变量:
@@ -117,26 +128,32 @@ if (subCommand === 'init') {
117
128
  }
118
129
  }
119
130
 
120
- const previewFlag = args.includes('--preview') || args.includes('-p')
121
- const legacyOfflineFlag = args.includes('--offline') || args.includes('-o')
122
- if (legacyOfflineFlag) {
123
- console.warn(
124
- srcTag + chalk.yellow('⚠ --offline 已废弃,请改用 --preview(本次仍按 preview 模式启动)')
125
- )
131
+ if (mcpMode) {
132
+ // stdio MCP server:不碰端口 / TLS / Vite,由 server.mjs 常驻监听 stdin 直到对端关闭。
133
+ const { startMcpServer } = await import('./mcp.js')
134
+ await startMcpServer(loadConfig())
135
+ } else {
136
+ const previewFlag = args.includes('--preview') || args.includes('-p')
137
+ const legacyOfflineFlag = args.includes('--offline') || args.includes('-o')
138
+ if (legacyOfflineFlag) {
139
+ console.warn(
140
+ srcTag + chalk.yellow('⚠ --offline 已废弃,请改用 --preview(本次仍按 preview 模式启动)')
141
+ )
142
+ }
143
+ // 只在显式给了 flag 时才覆盖配置:无条件传 { preview: false } 会把 DSL_DEV_PREVIEW=1 顶成
144
+ // false —— 旧实现正因如此,环境变量开关一直是失效的。
145
+ startServer(previewFlag || legacyOfflineFlag ? { preview: true } : {})
146
+ .then(({ shutdown }) => {
147
+ const stop = async () => {
148
+ console.log('\n' + srcTag + chalk.cyan('正在关闭…'))
149
+ await shutdown().catch(() => {})
150
+ process.exit(0)
151
+ }
152
+ process.on('SIGINT', stop)
153
+ process.on('SIGTERM', stop)
154
+ })
155
+ .catch((e) => {
156
+ console.error(srcTag + chalk.red('启动失败:') + e)
157
+ process.exit(1)
158
+ })
126
159
  }
127
- // 只在显式给了 flag 时才覆盖配置:无条件传 { preview: false } 会把 DSL_DEV_PREVIEW=1 顶成
128
- // false —— 旧实现正因如此,环境变量开关一直是失效的。
129
- startServer(previewFlag || legacyOfflineFlag ? { preview: true } : {})
130
- .then(({ shutdown }) => {
131
- const stop = async () => {
132
- console.log('\n' + srcTag + chalk.cyan('正在关闭…'))
133
- await shutdown().catch(() => {})
134
- process.exit(0)
135
- }
136
- process.on('SIGINT', stop)
137
- process.on('SIGTERM', stop)
138
- })
139
- .catch((e) => {
140
- console.error(srcTag + chalk.red('启动失败:') + e)
141
- process.exit(1)
142
- })
package/dist/config.js CHANGED
@@ -94,6 +94,12 @@ export function loadConfig(env = process.env) {
94
94
  vendoredRuntimeDir: path.join(vendorRoot, 'card-editor'),
95
95
  // 仓库内开发回退:从仓库根运行时(cwd=仓库根)能直接命中 card-editor/,无需先 build。
96
96
  repoEditorDir: path.resolve(process.cwd(), 'card-editor'),
97
+ // MCP stdio server 入口(`cardbuddy --mcp`):发布态命中包内快照 vendored/mcp/server.mjs;
98
+ // 仓库内跑源码时 src/vendored 不存在,回退到 <repo>/mcp/server.mjs —— src 与 dist 同深度,
99
+ // 故 __dirname 相对路径在两种情形下都对(不像 root 那样依赖 cwd)。
100
+ mcpServerEntry: fs.existsSync(path.join(vendorRoot, 'mcp', 'server.mjs'))
101
+ ? path.join(vendorRoot, 'mcp', 'server.mjs')
102
+ : path.resolve(__dirname, '../../../mcp/server.mjs'),
97
103
  // 包内技能快照(build 阶段从仓库 skills/ 全量内联,排除 .local / node_modules / __pycache__):
98
104
  // 既是运行时的只读回退,也是 `cardbuddy init` 释放到项目根的源。
99
105
  vendoredSkillsDir,
package/dist/init.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * 释放后既跑得快也写得了(FinMall 凭据落在项目内,不进 node_modules)
10
10
  * tools/link-skills.mjs 把 skills/ 链到 Codex / Claude / WorkBuddy 等 AI 工具的项目级技能目录
11
11
  * .env.cb 共享配置模板(端口 / 远端基址 / AI 网关占位;密钥写 .env.cb.local)
12
- * package.json 声明 cardbuddy 依赖与 dev / preview / link:skills 脚本
12
+ * package.json 声明 cardbuddy 依赖与 dev / preview / mcp / link:skills 脚本
13
13
  * .gitignore 私密层与产物不入库
14
14
  * README.md 上手说明
15
15
  *
@@ -188,6 +188,43 @@ skills/ AI 协作技能库(校验 / 同步 / 建卡管线脚本
188
188
  npm run link:skills # 交互式选择 Codex / Claude / WorkBuddy 等,建立项目级技能软链
189
189
  \`\`\`
190
190
 
191
+ ## 把卡片工具挂给外部 AI agent(MCP,可选)
192
+
193
+ 编辑器内置的网页 AI 助手走 \`POST /__ai/chat\`。若要让 Codex / Claude 这类**外部** agent
194
+ 直接操作卡片,用随包提供的 MCP stdio server(16 个 \`card_*\` 工具,清单见
195
+ \`skills/dsl-card-ai/SKILL.md\`):
196
+
197
+ \`\`\`jsonc
198
+ // agent 的 MCP 配置;cwd 必须是本工程根,卡片读写才落在 src/cards/
199
+ {
200
+ "mcpServers": {
201
+ "dsl-card": {
202
+ "command": "<本工程绝对路径>/node_modules/.bin/cardbuddy",
203
+ "args": ["--mcp"]
204
+ }
205
+ }
206
+ }
207
+ \`\`\`
208
+
209
+ 也可用 \`npm run --silent mcp\`。**\`--silent\` 不能省**:\`npm run\` 会把
210
+ \`> 包名 脚本名\` 横幅打到 **stdout**,而 stdout 是 JSON-RPC 协议信道,多一行就让客户端解析失败。
211
+
212
+ \`--mcp\` 自身不启服务、不占端口,工具按对本地服务的依赖分档:
213
+
214
+ | 档 | 工具 |
215
+ | --- | --- |
216
+ | 纯本地,从不需要服务 | \`card_route_input\` \`card_scaffold\` \`card_add_component\` |
217
+ | 优先服务,连不上自动降级为直接读写盘(\`validate\` 跑 Python 校验器) | \`card_list\` \`card_read\` \`card_write\` \`card_validate\` \`card_history\` \`card_rollback\` |
218
+ | 优先服务,降级到包内独立离线渲染器 | \`card_screenshot\` |
219
+ | **必须**另起一个 \`npm run dev\` | \`card_render_status\` \`card_node_models\` \`card_import_meta\` |
220
+ | **必须**另起服务 **且**已登录 FinMall | \`card_sync\` \`card_save\` \`card_slice_upload\` |
221
+
222
+ \`--mcp\` 会自动把 \`CARD_ROOT\` 指到本工程根、\`CARD_API_BASE\` 指到 \`.env.cb\` 的
223
+ \`FINMALL_CARD_PORT\`(默认 8787)。**别直接跑包内 \`vendored/mcp/server.mjs\`**:那样
224
+ \`CARD_ROOT\` 会落到 node_modules 里的快照目录(\`card_list\` 返回 0 张卡),端口也回退到
225
+ 5180(\`card_render_status\` 直接报 \`dev-server-required\`)。排查时可设
226
+ \`CARDBUDDY_MCP_VERBOSE=1\` 让启动信息打到 stderr。
227
+
191
228
  ## FinMall 远端能力(可选)
192
229
 
193
230
  同步 / 保存 / 切图上传需要已登录的 FinMall 会话与 \`playwright\`
@@ -208,6 +245,10 @@ function projectPackageJson(projectName, version) {
208
245
  dev: 'cardbuddy',
209
246
  start: 'cardbuddy',
210
247
  preview: 'cardbuddy --preview',
248
+ // 给外部 agent(Codex / Claude)挂 card_* 工具的 stdio MCP server。
249
+ // 注意:MCP 客户端要走 `npm run --silent mcp` 或直接调 node_modules/.bin/cardbuddy --mcp,
250
+ // 因为 `npm run` 的横幅会打到 stdout、污染 JSON-RPC 协议流(详见生成的 README)。
251
+ mcp: 'cardbuddy --mcp',
211
252
  'link:skills': 'node tools/link-skills.mjs',
212
253
  },
213
254
  devDependencies: {
@@ -382,7 +423,7 @@ export async function runInit({
382
423
  const pkgState = writeFileSafe(path.join(target, 'package.json'), projectPackageJson(projectName, version), opts)
383
424
  pkgState === 'skipped'
384
425
  ? dim('package.json 已存在(跳过,--force 可覆盖)')
385
- : ok(`${dryRun ? '将生成' : '已生成'} package.json(cardbuddy ^${version} + dev / preview / link:skills 脚本)`)
426
+ : ok(`${dryRun ? '将生成' : '已生成'} package.json(cardbuddy ^${version} + dev / preview / mcp / link:skills 脚本)`)
386
427
 
387
428
  // 6) tools/link-skills.mjs(把 skills/ 链给 AI 工具)
388
429
  const linkSrc = resolveLinkSkillsSource(config)
package/dist/mcp.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `cardbuddy --mcp` —— 以 stdio 起 MCP server(JSON-RPC 2.0,newline-delimited)。
3
+ *
4
+ * 为什么要有它:包内快照早就带了 mcp/tools.mjs + client.mjs(AI 工具调度的实现真身),
5
+ * 却没带 server.mjs 这个 stdio 入口 —— 于是消费者工程只能用编辑器内置的网页 AI 助手
6
+ * (/__ai/chat),没法把同一套 card_* 工具挂给 Codex / Claude 这类外部 agent。而随包释放的
7
+ * dsl-card-ai 技能整篇是以「只调 dsl-card MCP 工具、绝不重实现契约逻辑」为前提写的,
8
+ * 缺了入口那份技能在消费者工程里就落不了地。
9
+ *
10
+ * 两个必须守住的前提:
11
+ * 1. **stdout 是协议信道**,多一行非 JSON 就让客户端解析失败(`npm run` 的 `> 包名 脚本名`
12
+ * 横幅正是典型污染源,故推荐 `.bin/cardbuddy --mcp` 或 `npm run --silent mcp`)。这条路径
13
+ * 上因此一律不打日志:cli.js 跳过 installLogMirror()(该镜像服务于编辑器「服务日志」面板,
14
+ * MCP 模式没有 HTTP 服务、/__logs 无消费者),启动诊断只走 stderr 且默认静默。
15
+ * 2. **CARD_ROOT / CARD_API_BASE 必须在 import 之前落定**。mcp/client.mjs 在模块顶层
16
+ * 就读这两个变量(不是每次调用时读),晚一步注入就无效:
17
+ * - CARD_ROOT 不给,client 会把「mcp/ 的上一级」当工程根 —— 发布态那是包内快照目录,
18
+ * 根本找不到 src/cards;
19
+ * - CARD_API_BASE 不给,client 回退 devApiBase() 即 5180(仓库里由 Vite 把 /__* 代理到
20
+ * 8787),而消费者工程通常只有 cardbuddy 的 8787、没有 Vite,于是连不上,
21
+ * 且 render_status / sync / save 这三个不支持离线降级的工具直接不可用。
22
+ *
23
+ * 本地服务没起也能用一部分:client.mjs 对 list / read / write / validate 有「直接读盘 + 跑
24
+ * Python 校验器」的离线降级;render_status / sync / save 依赖服务与 FinMall 登录态,不降级。
25
+ */
26
+ import fs from 'node:fs'
27
+ import { pathToFileURL } from 'node:url'
28
+
29
+ /** 启动信息只在对 stderr 可见时才打(交互式调试);被 MCP 客户端接管时保持静默。 */
30
+ function diag(line) {
31
+ if (process.stderr.isTTY || process.env.CARDBUDDY_MCP_VERBOSE === '1') {
32
+ process.stderr.write(`[cardbuddy --mcp] ${line}\n`)
33
+ }
34
+ }
35
+
36
+ /**
37
+ * @param {ReturnType<import('./config.js').loadConfig>} config
38
+ * @returns {Promise<void>} 解析后 server.mjs 自行常驻监听 stdin,本函数不返回直到进程结束
39
+ */
40
+ export async function startMcpServer(config) {
41
+ const entry = config.mcpServerEntry
42
+ if (!entry || !fs.existsSync(entry)) {
43
+ // 走 stderr + 非 0 退出:MCP 客户端会把它显示成 server 启动失败,而不是静默挂着
44
+ process.stderr.write(`[cardbuddy --mcp] 找不到 MCP server 入口:${entry || '(未解析)'}\n`)
45
+ process.exit(1)
46
+ }
47
+ if (!process.env.CARD_ROOT) process.env.CARD_ROOT = config.root
48
+ if (!process.env.CARD_API_BASE) {
49
+ process.env.CARD_API_BASE = `${config.https ? 'https' : 'http'}://127.0.0.1:${config.port}`
50
+ }
51
+ diag(`root=${process.env.CARD_ROOT}`)
52
+ diag(`apiBase=${process.env.CARD_API_BASE}(本地服务未起时 list/read/write/validate 自动降级为直接读盘)`)
53
+ await import(pathToFileURL(entry).href)
54
+ }
@@ -0,0 +1,84 @@
1
+ import readline from 'node:readline'
2
+ import { TOOLS, dispatch } from './tools.mjs'
3
+
4
+ /**
5
+ * 无依赖的 MCP stdio server(JSON-RPC 2.0,newline-delimited)。
6
+ *
7
+ * 支持的方法:
8
+ * - initialize → 返回 protocolVersion / capabilities / serverInfo
9
+ * - ping → 返回 {}
10
+ * - tools/list → 返回 TOOLS
11
+ * - tools/call → 分发到 client 函数,结果转成 MCP content
12
+ * - notifications/* → 不回复
13
+ *
14
+ * 设计:零第三方依赖(项目未装 @modelcontextprotocol/sdk),手写最小可用实现;
15
+ * 仅用 Node 22 内置 fetch / readline。截图 png 以 image content 块返回,供多模态直接阅读。
16
+ */
17
+
18
+ const rl = readline.createInterface({ input: process.stdin, terminal: false })
19
+
20
+ function send(obj) {
21
+ process.stdout.write(JSON.stringify(obj) + '\n')
22
+ }
23
+
24
+ /** 把 client 返回结果转成 MCP tools/call 的 result(content + isError) */
25
+ function resultToContent(name, args, result) {
26
+ // 截图 png:base64 直接转 image 块
27
+ if (name === 'card_screenshot' && args.format !== 'json' && result && result.base64 && !result.error) {
28
+ return { content: [{ type: 'image', data: result.base64, mimeType: result.mime || 'image/png' }], isError: false }
29
+ }
30
+ const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2)
31
+ const isError = !!(result && typeof result === 'object' && result.error)
32
+ return { content: [{ type: 'text', text }], isError }
33
+ }
34
+
35
+ rl.on('line', async (line) => {
36
+ const t = line.trim()
37
+ if (!t) return
38
+ let msg
39
+ try {
40
+ msg = JSON.parse(t)
41
+ } catch {
42
+ return
43
+ }
44
+ const { id, method, params } = msg
45
+ try {
46
+ if (method === 'initialize') {
47
+ send({
48
+ jsonrpc: '2.0',
49
+ id,
50
+ result: {
51
+ protocolVersion: params?.protocolVersion || '2024-11-05',
52
+ capabilities: { tools: {} },
53
+ serverInfo: { name: 'dsl-card-mcp', version: '1.0.0' },
54
+ },
55
+ })
56
+ } else if (method === 'ping') {
57
+ if (id != null) send({ jsonrpc: '2.0', id, result: {} })
58
+ } else if (method === 'tools/list') {
59
+ send({ jsonrpc: '2.0', id, result: { tools: TOOLS } })
60
+ } else if (method === 'tools/call') {
61
+ const name = params?.name
62
+ const args = params?.arguments || {}
63
+ let result
64
+ try {
65
+ result = await dispatch(name, args)
66
+ } catch (e) {
67
+ result = { error: 'dispatch-failed', message: String((e && e.message) || e) }
68
+ }
69
+ send({ jsonrpc: '2.0', id, result: resultToContent(name, args, result) })
70
+ } else if (method && method.startsWith('notifications/')) {
71
+ // 通知类(如 notifications/initialized)不回复
72
+ } else {
73
+ if (id != null) {
74
+ send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } })
75
+ }
76
+ }
77
+ } catch (e) {
78
+ if (id != null) {
79
+ send({ jsonrpc: '2.0', id, error: { code: -32603, message: String((e && e.message) || e) } })
80
+ }
81
+ }
82
+ })
83
+
84
+ process.stdin.on('end', () => process.exit(0))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cardbuddy",
3
- "version": "0.12.2",
3
+ "version": "0.13.0",
4
4
  "description": "Local dev service for the CardBuddy card editor — decouples card CRUD (sync/save/validate/render-status/screenshot) from the static public editor, so the editor can be deployed to any static host while each developer runs a local service against their own card source.",
5
5
  "type": "module",
6
6
  "bin": {