@dobby.ai/dobby 0.1.0 → 0.1.1

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.
Files changed (76) hide show
  1. package/.env.example +0 -1
  2. package/AGENTS.md +7 -7
  3. package/README.md +64 -32
  4. package/config/gateway.example.json +10 -6
  5. package/dist/plugins/connector-discord/src/mapper.js +75 -0
  6. package/dist/src/cli/commands/doctor.js +81 -2
  7. package/dist/src/cli/commands/extension.js +3 -1
  8. package/dist/src/cli/commands/init.js +43 -173
  9. package/dist/src/cli/commands/topology.js +38 -14
  10. package/dist/src/cli/program.js +15 -131
  11. package/dist/src/cli/shared/config-io.js +3 -31
  12. package/dist/src/cli/shared/config-mutators.js +33 -9
  13. package/dist/src/cli/shared/configure-sections.js +52 -12
  14. package/dist/src/cli/shared/init-catalog.js +89 -46
  15. package/dist/src/cli/shared/local-extension-specs.js +85 -0
  16. package/dist/src/cli/shared/schema-prompts.js +26 -2
  17. package/dist/src/cli/tests/config-io.test.js +5 -5
  18. package/dist/src/cli/tests/discord-mapper.test.js +90 -0
  19. package/dist/src/cli/tests/doctor.test.js +145 -0
  20. package/dist/src/cli/tests/init-catalog.test.js +108 -61
  21. package/dist/src/cli/tests/program-options.test.js +14 -28
  22. package/dist/src/cli/tests/routing-config.test.js +59 -4
  23. package/dist/src/core/gateway.js +3 -1
  24. package/dist/src/core/routing.js +53 -38
  25. package/dist/src/main.js +0 -0
  26. package/dist/src/shared/dobby-repo.js +40 -0
  27. package/docs/RUNBOOK.md +28 -27
  28. package/package.json +3 -2
  29. package/plugins/connector-discord/package-lock.json +2 -2
  30. package/plugins/connector-discord/package.json +1 -1
  31. package/plugins/connector-discord/src/connector.ts +0 -5
  32. package/plugins/connector-discord/src/mapper.ts +3 -4
  33. package/plugins/connector-feishu/package-lock.json +2 -2
  34. package/plugins/connector-feishu/package.json +1 -1
  35. package/plugins/plugin-sdk/package-lock.json +2 -2
  36. package/plugins/plugin-sdk/package.json +1 -1
  37. package/plugins/provider-claude/package-lock.json +2 -2
  38. package/plugins/provider-claude/package.json +1 -1
  39. package/plugins/provider-claude-cli/package-lock.json +2 -2
  40. package/plugins/provider-claude-cli/package.json +1 -1
  41. package/plugins/provider-pi/package-lock.json +2 -2
  42. package/plugins/provider-pi/package.json +1 -1
  43. package/plugins/provider-pi/src/contribution.ts +139 -9
  44. package/src/cli/commands/doctor.ts +103 -2
  45. package/src/cli/commands/extension.ts +3 -1
  46. package/src/cli/commands/init.ts +45 -230
  47. package/src/cli/commands/topology.ts +48 -16
  48. package/src/cli/program.ts +16 -167
  49. package/src/cli/shared/config-io.ts +3 -35
  50. package/src/cli/shared/config-mutators.ts +39 -9
  51. package/src/cli/shared/config-types.ts +10 -2
  52. package/src/cli/shared/configure-sections.ts +55 -11
  53. package/src/cli/shared/init-catalog.ts +126 -66
  54. package/src/cli/shared/local-extension-specs.ts +108 -0
  55. package/src/cli/shared/schema-prompts.ts +30 -1
  56. package/src/cli/tests/config-io.test.ts +5 -5
  57. package/src/cli/tests/discord-mapper.test.ts +128 -0
  58. package/src/cli/tests/doctor.test.ts +149 -0
  59. package/src/cli/tests/init-catalog.test.ts +112 -64
  60. package/src/cli/tests/program-options.test.ts +14 -32
  61. package/src/cli/tests/routing-config.test.ts +76 -4
  62. package/src/core/gateway.ts +3 -1
  63. package/src/core/routing.ts +70 -45
  64. package/src/core/types.ts +8 -2
  65. package/src/shared/dobby-repo.ts +48 -0
  66. package/config/models.custom.example.json +0 -27
  67. package/dist/src/agent/tests/event-forwarder.test.js +0 -113
  68. package/dist/src/cli/shared/config-path.js +0 -207
  69. package/dist/src/cli/shared/init-models-file.js +0 -65
  70. package/dist/src/cli/shared/presets.js +0 -86
  71. package/dist/src/cli/tests/config-path.test.js +0 -21
  72. package/dist/src/cli/tests/discord-config.test.js +0 -23
  73. package/dist/src/cli/tests/presets.test.js +0 -41
  74. package/dist/src/cli/tests/routing-legacy.test.js +0 -191
  75. package/dist/src/core/tests/gateway-update-strategy.test.js +0 -167
  76. package/src/cli/shared/init-models-file.ts +0 -77
package/.env.example CHANGED
@@ -1,4 +1,3 @@
1
- DISCORD_BOT_TOKEN=replace_me
2
1
  ANTHROPIC_AUTH_TOKEN=replace_me
3
2
  ANTHROPIC_API_KEY=replace_me
4
3
  ANTHROPIC_BASE_URL=
package/AGENTS.md CHANGED
@@ -68,7 +68,7 @@ npm run plugins:setup:local
68
68
  - `src/cli/commands/start.ts`
69
69
  - 启动网关、创建数据目录、加载扩展、实例化 provider / connector / sandbox、启动 cron 服务、接管优雅退出
70
70
  - `src/cli/commands/init.ts`
71
- - 首次初始化向导;会先安装所需扩展,再根据扩展 `configSchema` 交互生成配置
71
+ - 首次初始化向导;会安装所需扩展并生成带占位符的 starter 配置模板
72
72
  - `src/cli/commands/config*.ts`
73
73
  - `config show|list|edit|schema *` 与 `configure`
74
74
  - `src/cli/commands/topology.ts`
@@ -111,8 +111,8 @@ npm run plugins:setup:local
111
111
 
112
112
  - `providers.default` 必须存在于 `providers.items`
113
113
  - `sandboxes.default` 若存在且不是 `host.builtin`,必须存在于 `sandboxes.items`
114
- - `routes.defaults.provider` 若未设置,运行时回落到 `providers.default`
115
- - `routes.defaults.sandbox` 若未设置,运行时回落到 `sandboxes.default ?? host.builtin`
114
+ - `routes.default.provider` 若未设置,运行时回落到 `providers.default`
115
+ - `routes.default.sandbox` 若未设置,运行时回落到 `sandboxes.default ?? host.builtin`
116
116
  - 每条 route 在加载后都会补全 `provider`、`sandbox`、`tools`、`mentions`
117
117
  - `bindings.items[*].connector` 必须指向存在的 `connectors.items`
118
118
  - `bindings.items[*].route` 必须指向存在的 `routes.items`
@@ -133,8 +133,8 @@ npm run plugins:setup:local
133
133
  - `connectorId + platform + accountId + chatId + messageId`
134
134
  - 线程路由规则:
135
135
  - Discord 线程消息使用父频道 ID 查 `bindings.items`
136
- - Discord connector 当前只处理已绑定的 guild channel
137
- - DM connector 侧被直接忽略,虽然核心类型保留了 `isDirectMessage`
136
+ - Discord guild channel 仍按显式 binding 匹配
137
+ - DM 会携带 `isDirectMessage` 进入 gateway,并可通过 `bindings.default` 回落到默认 route
138
138
  - mention 策略:
139
139
  - `mentions="required"` 时,群聊消息必须 @bot 才会进入 runtime
140
140
  - 控制命令:
@@ -159,7 +159,7 @@ npm run plugins:setup:local
159
159
  - 运行时加载来源只有 `<data.rootDir>/extensions/node_modules`
160
160
  - 宿主不会从自身依赖树、`plugins/*` 源码目录或 `dist` 外路径 fallback
161
161
  - `configSchema` 是可选的
162
- - `init`、`configure`、`config edit` 会优先按 schema 交互提问
162
+ - `configure`、`config edit` 会优先按 schema 交互提问
163
163
  - `applyAndValidateContributionSchemas` 会用 Ajv 套默认值并验证实例配置
164
164
 
165
165
  当前仓库内的扩展源码与 contribution:
@@ -172,7 +172,7 @@ npm run plugins:setup:local
172
172
 
173
173
  注意:
174
174
 
175
- - `dobby init` 当前只内建选择 `provider.pi`、`provider.claude-cli` 和 `connector.discord`
175
+ - `dobby init` 当前只内建选择 `provider.pi`、`provider.claude-cli`、`connector.discord` 和 `connector.feishu`
176
176
  - `provider.claude` 与 sandbox 扩展需要手工安装 / 启用 / 配置
177
177
 
178
178
  ## 7. Cron / 计划任务约束
package/README.md CHANGED
@@ -17,14 +17,14 @@ Discord-first 本地 Agent Gateway。宿主只负责 CLI、网关主流程、扩
17
17
 
18
18
  - connector source -> binding -> route -> provider / sandbox
19
19
  - Discord 频道 / 线程接入;线程消息继续按父频道命中 binding
20
- - Feishu 长连接消息接入(self-built app,手工安装/配置)
20
+ - Feishu 长连接消息接入(self-built app
21
21
  - Feishu 出站支持普通文本和 Markdown 卡片;默认群内直发,不走 reply thread
22
22
  - conversation 级 runtime 复用与串行化
23
23
  - 扩展 store 安装、启用、列举与 schema 驱动配置
24
24
  - Discord 流式回复、typing、附件下载与图片输入
25
25
  - cron 调度:一次性、固定间隔、cron expression
26
- - 交互式初始化:`dobby init`
27
- - 交互式配置:`dobby configure` / `dobby config edit`
26
+ - 交互式初始化:`dobby init`(支持多 provider / 多 connector starter)
27
+ - 配置检查与 schema inspect:`dobby config show|list|schema`
28
28
  - 诊断与保守修复:`dobby doctor [--fix]`
29
29
 
30
30
  ## 架构概览
@@ -76,7 +76,7 @@ npm install
76
76
  npm run build
77
77
  ```
78
78
 
79
- 3. 初始化最小可运行配置
79
+ 3. 初始化模板配置
80
80
 
81
81
  ```bash
82
82
  npm run start -- init
@@ -84,21 +84,41 @@ npm run start -- init
84
84
 
85
85
  `init` 会做这些事情:
86
86
 
87
- - 交互选择 provider 和 connector
87
+ - 交互选择 provider 和 connector(均可多选)
88
88
  - 自动安装所选扩展到运行时 extension store
89
- - 优先使用扩展暴露的 `configSchema` 生成配置
89
+ - 写入一份带占位符的 `gateway.json` 模板
90
+ - 把 `routes.default.projectRoot` 设为当前工作目录
91
+ - 为 direct message 生成 `bindings.default`,回落到默认 route
92
+ - 为每个所选 connector 生成一个默认 binding 到同一条 route
90
93
  - 生成 `gateway.json`
91
- - 选择 `provider.pi` 且缺少 `models.custom.json` 时,自动创建该文件
94
+ - `provider.pi` 默认写入最小 inline 配置,不再依赖 `models.custom.json`
92
95
 
93
- 说明:当前 `init` 仍只内建 Discord connector。Feishu connector 需要通过 `extension install` + `config edit/configure` 手工启用。
96
+ 说明:当前 `init` 内建这些 starter 选择:
94
97
 
95
- 4. 运行诊断
98
+ - provider:`provider.pi`、`provider.claude-cli`
99
+ - connector:`connector.discord`、`connector.feishu`
100
+
101
+ 4. 编辑 `gateway.json`
102
+
103
+ 把 `REPLACE_WITH_*` / `YOUR_*` 占位值替换成你的真实配置,例如:
104
+
105
+ - `connectors.items[*]` 中的 token / appId / appSecret
106
+ - `bindings.items[*].source.id`
107
+ - `routes.items[*].projectRoot`(如需覆盖默认 project root)
108
+
109
+ 5. 运行诊断
96
110
 
97
111
  ```bash
98
112
  npm run start -- doctor
99
113
  ```
100
114
 
101
- 5. 启动网关
115
+ `doctor` 会同时检查:
116
+
117
+ - 配置结构 / 引用关系
118
+ - 缺失的扩展安装
119
+ - `REPLACE_WITH_*` / `YOUR_*` 这类 init 占位值是否还未替换
120
+
121
+ 6. 启动网关
102
122
 
103
123
  ```bash
104
124
  npm run start --
@@ -107,7 +127,9 @@ npm run start --
107
127
  说明:
108
128
 
109
129
  - `dobby` 无子命令时,默认等价于 `dobby start`
130
+ - `dobby --version` 可直接查看当前 CLI 版本
110
131
  - 在仓库内直接运行时,CLI 会自动使用 `./config/gateway.json`
132
+ - 在仓库内执行 `init` / `extension install` 时,会优先安装 `plugins/*` 的本地构建产物
111
133
  - 也可以通过环境变量覆盖配置路径:
112
134
 
113
135
  ```bash
@@ -152,33 +174,23 @@ cron 配置路径优先级:
152
174
  顶层命令:
153
175
 
154
176
  ```bash
177
+ dobby --version
155
178
  dobby start
156
179
  dobby init
157
- dobby configure
158
180
  dobby doctor [--fix]
159
181
  ```
160
182
 
161
- 配置与拓扑:
183
+ 配置检查:
162
184
 
163
185
  ```bash
164
186
  dobby config show [section] [--json]
165
187
  dobby config list [section] [--json]
166
- dobby config edit [--section provider|connector|route|binding]
167
188
  dobby config schema list [--json]
168
189
  dobby config schema show <contributionId> [--json]
169
-
170
- dobby bot list [--json]
171
- dobby bot set <connectorId> [--name <name>] [--token <token>]
172
-
173
- dobby binding list [--connector <id>] [--json]
174
- dobby binding set <bindingId> --connector <id> --source-type channel|chat --source-id <id> --route <id>
175
- dobby binding remove <bindingId>
176
-
177
- dobby route list [--json]
178
- dobby route set <routeId> [--project-root <path>] [--tools full|readonly] [--provider <id>] [--sandbox <id>] [--mentions required|optional]
179
- dobby route remove <routeId> [--cascade-bindings]
180
190
  ```
181
191
 
192
+ 配置变更建议直接编辑 `gateway.json`,再通过 `dobby doctor` 或 `dobby start` 做校验。
193
+
182
194
  扩展管理:
183
195
 
184
196
  ```bash
@@ -221,15 +233,17 @@ dobby cron remove <jobId>
221
233
  - 默认 provider instance ID
222
234
  - `providers.items[*].type` / `connectors.items[*].type` / `sandboxes.items[*].type`
223
235
  - 指向某个 contribution,实例配置直接内联在对象里
224
- - `routes.defaults`
225
- - 统一提供 route 默认的 `provider`、`sandbox`、`tools`、`mentions`
236
+ - `routes.default`
237
+ - 统一提供 route 默认的 `projectRoot`、`provider`、`sandbox`、`tools`、`mentions`
226
238
  - `routes.items[*]`
227
- - route 是可复用的执行 profile,定义 `projectRoot`,并按需覆盖 `provider`、`sandbox`、`tools`、`mentions`、`systemPromptFile`
239
+ - route 是可复用的执行 profile,可继承默认 `projectRoot`,并按需覆盖 `provider`、`sandbox`、`tools`、`mentions`、`systemPromptFile`
240
+ - `bindings.default`
241
+ - direct message 未命中显式 binding 时使用的默认 route fallback
228
242
  - `bindings.items[*]`
229
243
  - `(connector, source.type, source.id) -> route` 的入口绑定
230
244
  - `sandboxes.default`
231
245
  - 未指定时默认使用 `host.builtin`
232
- - 未匹配 binding 的入站消息会被直接忽略,不存在 default route fallback
246
+ - 未匹配 binding 的入站消息会被直接忽略;仅 direct message 可回落到 `bindings.default`
233
247
 
234
248
  当前代码还保留但未真正生效的字段:
235
249
 
@@ -239,7 +253,24 @@ dobby cron remove <jobId>
239
253
 
240
254
  - gateway:[`config/gateway.example.json`](config/gateway.example.json)
241
255
  - cron:[`config/cron.example.json`](config/cron.example.json)
242
- - provider.pi 自定义模型:[`config/models.custom.example.json`](config/models.custom.example.json)
256
+
257
+ `provider.pi` 现在使用 inline custom provider 配置。最小常用字段是:
258
+
259
+ - `model`
260
+ - `baseUrl`
261
+ - `apiKey`
262
+
263
+ 这些字段默认自动补齐:
264
+
265
+ - `provider = "custom-openai"`
266
+ - `api = "openai-completions"`
267
+ - `authHeader = false`
268
+ - `thinkingLevel = "off"`
269
+ - `models = [{ id: model }]`
270
+
271
+ 只有在你需要多模型元数据或覆盖能力参数时,才需要手工展开 `models`。
272
+
273
+ `apiKey` 支持直接写 literal,也支持写环境变量名,由 `pi` 的 `AuthStorage` / `ModelRegistry` 按上游规则解析。
243
274
 
244
275
  ## 扩展包与 contribution
245
276
 
@@ -255,7 +286,7 @@ dobby cron remove <jobId>
255
286
  `dobby init` 当前只内建这些 starter 选择:
256
287
 
257
288
  - provider:`provider.pi`、`provider.claude-cli`
258
- - connector:`connector.discord`
289
+ - connector:`connector.discord`、`connector.feishu`
259
290
 
260
291
  `provider.claude` 与 sandbox 相关扩展需要手工安装和配置,例如:
261
292
 
@@ -298,7 +329,8 @@ npm run start -- cron add daily-report \
298
329
 
299
330
  ## Discord 连接器的当前行为
300
331
 
301
- - 只处理已绑定的 guild channel,DM 目前禁用
332
+ - guild channel 仍按显式 binding 匹配
333
+ - DM 可通过 `bindings.default` 回落到默认 route
302
334
  - 线程消息使用父频道 ID 做 binding 查找
303
335
  - 会自动下载附件到本地
304
336
  - 图片会作为 image input 传给 provider
@@ -372,7 +404,7 @@ npm run plugins:build
372
404
 
373
405
  - `npm run dev:local` 与 `npm run start:local` 会尝试读取 `.env`
374
406
  - 普通 `npm run start -- ...` 不会自动载入 `.env`
375
- - 配置编辑流优先使用扩展 `configSchema`,无 schema 时退回 JSON 输入
407
+ - `dobby init` 生成的是模板配置;运行前先替换 `gateway.json` 中的 placeholder
376
408
 
377
409
  ## 相关文档
378
410
 
@@ -24,10 +24,9 @@
24
24
  "items": {
25
25
  "pi.main": {
26
26
  "type": "provider.pi",
27
- "provider": "custom-openai",
28
- "model": "example-model",
29
- "thinkingLevel": "off",
30
- "modelsFile": "./models.custom.json"
27
+ "model": "REPLACE_WITH_PROVIDER_MODEL_ID",
28
+ "baseUrl": "REPLACE_WITH_PROVIDER_BASE_URL",
29
+ "apiKey": "REPLACE_WITH_PROVIDER_API_KEY_OR_ENV"
31
30
  }
32
31
  }
33
32
  },
@@ -47,19 +46,24 @@
47
46
  "items": {}
48
47
  },
49
48
  "routes": {
50
- "defaults": {
49
+ "default": {
50
+ "projectRoot": "/Users/you/workspace/dobby",
51
51
  "provider": "pi.main",
52
52
  "sandbox": "host.builtin",
53
53
  "tools": "full",
54
54
  "mentions": "required"
55
55
  },
56
56
  "items": {
57
+ "main": {},
57
58
  "projectA": {
58
59
  "projectRoot": "/Users/you/workspace/project-a"
59
60
  }
60
61
  }
61
62
  },
62
63
  "bindings": {
64
+ "default": {
65
+ "route": "main"
66
+ },
63
67
  "items": {
64
68
  "discord.main.projectA": {
65
69
  "connector": "discord.main",
@@ -125,4 +129,4 @@
125
129
  "rootDir": "./data",
126
130
  "dedupTtlMs": 604800000
127
131
  }
128
- }
132
+ }
@@ -0,0 +1,75 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ function stripBotMention(text, botUserId) {
4
+ const mentionRegex = new RegExp(`<@!?${botUserId}>`, "g");
5
+ return text.replace(mentionRegex, "").trim();
6
+ }
7
+ function sanitizeFileName(value) {
8
+ return value.replaceAll(/[^a-zA-Z0-9._-]/g, "_");
9
+ }
10
+ async function downloadAttachment(url, targetPath) {
11
+ const response = await fetch(url);
12
+ if (!response.ok) {
13
+ throw new Error(`Failed to download attachment from ${url}: ${response.status}`);
14
+ }
15
+ const data = await response.arrayBuffer();
16
+ await mkdir(dirname(targetPath), { recursive: true });
17
+ await writeFile(targetPath, Buffer.from(data));
18
+ }
19
+ function mapAttachmentBase(messageAttachment) {
20
+ return {
21
+ id: messageAttachment.id,
22
+ size: messageAttachment.size,
23
+ remoteUrl: messageAttachment.url,
24
+ ...(messageAttachment.name ? { fileName: messageAttachment.name } : {}),
25
+ ...(messageAttachment.contentType ? { mimeType: messageAttachment.contentType } : {}),
26
+ };
27
+ }
28
+ export async function mapDiscordMessage(message, connectorId, botUserId, sourceId, attachmentsRoot, logger) {
29
+ if (message.author.bot)
30
+ return null;
31
+ const isDirectMessage = message.guildId == null;
32
+ const mentionedBot = message.mentions.users.has(botUserId);
33
+ const chatId = message.channelId;
34
+ const threadId = message.channel.isThread() ? message.channelId : undefined;
35
+ const cleanedText = stripBotMention(message.content ?? "", botUserId);
36
+ const attachments = [];
37
+ for (const attachment of message.attachments.values()) {
38
+ const base = mapAttachmentBase(attachment);
39
+ const attachmentDir = join(attachmentsRoot, sourceId, message.id);
40
+ const fileName = sanitizeFileName(attachment.name ?? attachment.id);
41
+ const localPath = join(attachmentDir, fileName);
42
+ try {
43
+ await downloadAttachment(attachment.url, localPath);
44
+ attachments.push({
45
+ ...base,
46
+ localPath,
47
+ });
48
+ }
49
+ catch (error) {
50
+ logger.warn({ err: error, attachmentUrl: attachment.url }, "Failed to download Discord attachment; keeping metadata only");
51
+ attachments.push(base);
52
+ }
53
+ }
54
+ return {
55
+ connectorId,
56
+ platform: "discord",
57
+ accountId: botUserId,
58
+ source: {
59
+ type: "channel",
60
+ id: sourceId,
61
+ },
62
+ chatId,
63
+ messageId: message.id,
64
+ userId: message.author.id,
65
+ userName: message.author.username,
66
+ text: cleanedText,
67
+ attachments,
68
+ timestampMs: message.createdTimestamp,
69
+ raw: message.toJSON(),
70
+ isDirectMessage,
71
+ mentionedBot,
72
+ ...(message.guildId ? { guildId: message.guildId } : {}),
73
+ ...(threadId ? { threadId } : {}),
74
+ };
75
+ }
@@ -7,6 +7,33 @@ import { ensureGatewayConfigShape, setDefaultProviderIfMissingOrInvalid, } from
7
7
  import { DISCORD_CONNECTOR_CONTRIBUTION_ID } from "../shared/discord-config.js";
8
8
  import { readRawConfig, resolveConfigPath, resolveDataRootDir, writeConfigWithValidation } from "../shared/config-io.js";
9
9
  import { createLogger } from "../shared/runtime.js";
10
+ function isPlaceholderValue(value) {
11
+ if (typeof value !== "string") {
12
+ return false;
13
+ }
14
+ const normalized = value.trim().toUpperCase();
15
+ return normalized.includes("REPLACE_WITH_") || normalized.includes("YOUR_");
16
+ }
17
+ function isCredentialLikeKey(key) {
18
+ return /(?:token|secret|api[-_]?key|appid|appsecret)/i.test(key);
19
+ }
20
+ function walkPlaceholders(value, path) {
21
+ if (isPlaceholderValue(value)) {
22
+ return [{ path, value }];
23
+ }
24
+ if (Array.isArray(value)) {
25
+ return value.flatMap((item, index) => walkPlaceholders(item, `${path}[${index}]`));
26
+ }
27
+ if (!value || typeof value !== "object") {
28
+ return [];
29
+ }
30
+ return Object.entries(value).flatMap(([key, nested]) => walkPlaceholders(nested, `${path}.${key}`));
31
+ }
32
+ function lastPathSegment(path) {
33
+ const withoutIndexes = path.replaceAll(/\[\d+\]/g, "");
34
+ const segments = withoutIndexes.split(".");
35
+ return segments[segments.length - 1] ?? withoutIndexes;
36
+ }
10
37
  function expandHome(value) {
11
38
  if (value === "~") {
12
39
  return homedir();
@@ -76,6 +103,15 @@ export async function runDoctorCommand(options) {
76
103
  message: `providers.items['${instanceId}'] references missing contribution '${instance.type}'`,
77
104
  });
78
105
  }
106
+ for (const hit of walkPlaceholders(instance, `providers.items['${instanceId}']`)) {
107
+ if (hit.path.endsWith(".type")) {
108
+ continue;
109
+ }
110
+ issues.push({
111
+ level: isCredentialLikeKey(lastPathSegment(hit.path)) ? "error" : "warning",
112
+ message: `${hit.path} still uses placeholder value '${hit.value}'`,
113
+ });
114
+ }
79
115
  }
80
116
  for (const [instanceId, instance] of Object.entries(normalized.connectors.items)) {
81
117
  if (!availableContributionIds.has(instance.type)) {
@@ -84,6 +120,15 @@ export async function runDoctorCommand(options) {
84
120
  message: `connectors.items['${instanceId}'] references missing contribution '${instance.type}'`,
85
121
  });
86
122
  }
123
+ for (const hit of walkPlaceholders(instance, `connectors.items['${instanceId}']`)) {
124
+ if (hit.path.endsWith(".type")) {
125
+ continue;
126
+ }
127
+ issues.push({
128
+ level: isCredentialLikeKey(lastPathSegment(hit.path)) ? "error" : "warning",
129
+ message: `${hit.path} still uses placeholder value '${hit.value}'`,
130
+ });
131
+ }
87
132
  if (instance.type === DISCORD_CONNECTOR_CONTRIBUTION_ID) {
88
133
  const botName = typeof instance.botName === "string" ? instance.botName.trim() : "";
89
134
  const botToken = typeof instance.botToken === "string" ? instance.botToken.trim() : "";
@@ -109,18 +154,46 @@ export async function runDoctorCommand(options) {
109
154
  });
110
155
  }
111
156
  }
157
+ if (normalized.routes.default.projectRoot && isPlaceholderValue(normalized.routes.default.projectRoot)) {
158
+ issues.push({
159
+ level: "warning",
160
+ message: `routes.default.projectRoot still uses placeholder value '${normalized.routes.default.projectRoot}'`,
161
+ });
162
+ }
112
163
  for (const [routeId, route] of Object.entries(normalized.routes.items)) {
164
+ const effectiveProjectRoot = route.projectRoot ?? normalized.routes.default.projectRoot;
165
+ const projectRootSource = route.projectRoot ? `routes.items['${routeId}'].projectRoot` : "routes.default.projectRoot";
166
+ if (!effectiveProjectRoot) {
167
+ issues.push({
168
+ level: "error",
169
+ message: `routes.items['${routeId}'].projectRoot is required when routes.default.projectRoot is not set`,
170
+ });
171
+ continue;
172
+ }
173
+ if (isPlaceholderValue(effectiveProjectRoot)) {
174
+ issues.push({
175
+ level: "warning",
176
+ message: `${projectRootSource} still uses placeholder value '${effectiveProjectRoot}'`,
177
+ });
178
+ continue;
179
+ }
113
180
  try {
114
- const projectRootPath = resolveRouteProjectRoot(configPath, route.projectRoot);
181
+ const projectRootPath = resolveRouteProjectRoot(configPath, effectiveProjectRoot);
115
182
  await access(projectRootPath);
116
183
  }
117
184
  catch {
118
185
  issues.push({
119
186
  level: "warning",
120
- message: `routes.items['${routeId}'].projectRoot does not exist: ${route.projectRoot}`,
187
+ message: `${projectRootSource} does not exist: ${effectiveProjectRoot}`,
121
188
  });
122
189
  }
123
190
  }
191
+ if (normalized.bindings.default && !normalized.routes.items[normalized.bindings.default.route]) {
192
+ issues.push({
193
+ level: "error",
194
+ message: `bindings.default.route references unknown route '${normalized.bindings.default.route}'`,
195
+ });
196
+ }
124
197
  const seenBindingSources = new Map();
125
198
  for (const [bindingId, binding] of Object.entries(normalized.bindings.items)) {
126
199
  if (!normalized.connectors.items[binding.connector]) {
@@ -135,6 +208,12 @@ export async function runDoctorCommand(options) {
135
208
  message: `bindings.items['${bindingId}'].route references unknown route '${binding.route}'`,
136
209
  });
137
210
  }
211
+ if (isPlaceholderValue(binding.source.id)) {
212
+ issues.push({
213
+ level: "warning",
214
+ message: `bindings.items['${bindingId}'].source.id still uses placeholder value '${binding.source.id}'`,
215
+ });
216
+ }
138
217
  const bindingKey = `${binding.connector}:${binding.source.type}:${binding.source.id}`;
139
218
  const existingBindingId = seenBindingSources.get(bindingKey);
140
219
  if (existingBindingId) {
@@ -3,6 +3,7 @@ import { loadGatewayConfig } from "../../core/routing.js";
3
3
  import { ExtensionStoreManager } from "../../extension/manager.js";
4
4
  import { applyContributionTemplates, buildContributionTemplates, ensureGatewayConfigShape, listContributionIds, setDefaultProviderIfMissingOrInvalid, upsertAllowListPackage, } from "../shared/config-mutators.js";
5
5
  import { readRawConfig, requireRawConfig, resolveConfigPath, resolveDataRootDir, writeConfigWithValidation } from "../shared/config-io.js";
6
+ import { resolveExtensionInstallSpecs } from "../shared/local-extension-specs.js";
6
7
  import { createLogger } from "../shared/runtime.js";
7
8
  /**
8
9
  * Resolves extension store directory from normalized gateway config.
@@ -25,7 +26,8 @@ export async function runExtensionInstallCommand(options) {
25
26
  const logger = createLogger();
26
27
  const rawConfig = (await readRawConfig(configPath)) ?? {};
27
28
  const manager = new ExtensionStoreManager(logger, extensionStoreDirFromRaw(configPath, rawConfig));
28
- const installed = await manager.install(options.spec);
29
+ const [resolvedSpec] = await resolveExtensionInstallSpecs([options.spec]);
30
+ const installed = await manager.install(resolvedSpec ?? options.spec);
29
31
  if (!options.enable) {
30
32
  const templates = buildContributionTemplates(installed.manifest.contributions);
31
33
  if (options.json) {