@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
@@ -1,15 +1,11 @@
1
1
  import {
2
2
  cancel,
3
- confirm,
4
3
  intro,
5
4
  isCancel,
6
5
  multiselect,
7
- note,
8
6
  outro,
9
- password,
10
7
  select,
11
8
  spinner,
12
- text,
13
9
  } from "@clack/prompts";
14
10
  import { ExtensionStoreManager } from "../../extension/manager.js";
15
11
  import {
@@ -20,8 +16,7 @@ import {
20
16
  upsertProviderInstance,
21
17
  upsertRoute,
22
18
  } from "../shared/config-mutators.js";
23
- import { DEFAULT_DISCORD_BOT_NAME } from "../shared/discord-config.js";
24
- import { applyAndValidateContributionSchemas, loadContributionSchemaCatalog } from "../shared/config-schema.js";
19
+ import { applyAndValidateContributionSchemas } from "../shared/config-schema.js";
25
20
  import {
26
21
  readRawConfig,
27
22
  resolveConfigPath,
@@ -37,53 +32,17 @@ import {
37
32
  type InitConnectorChoiceId,
38
33
  type InitProviderChoiceId,
39
34
  } from "../shared/init-catalog.js";
40
- import { ensureProviderPiModelsFile } from "../shared/init-models-file.js";
35
+ import { resolveExtensionInstallSpecs } from "../shared/local-extension-specs.js";
41
36
  import { createLogger } from "../shared/runtime.js";
42
- import { promptConfigFromSchema } from "../shared/schema-prompts.js";
43
37
 
44
38
  interface InitInput {
45
39
  providerChoiceIds: InitProviderChoiceId[];
46
40
  routeProviderChoiceId: InitProviderChoiceId;
47
- connectorChoiceId: InitConnectorChoiceId;
48
- projectRoot: string;
49
- channelId: string;
50
- routeId: string;
51
- botName: string;
52
- botToken: string;
53
- allowAllMessages: boolean;
41
+ connectorChoiceIds: InitConnectorChoiceId[];
54
42
  }
55
43
 
56
44
  /**
57
- * Repeatedly prompts for non-empty text input and aborts cleanly on cancel.
58
- */
59
- async function promptRequiredText(params: {
60
- message: string;
61
- placeholder?: string;
62
- initialValue?: string;
63
- }): Promise<string> {
64
- while (true) {
65
- const promptOptions = {
66
- message: params.message,
67
- ...(params.placeholder !== undefined ? { placeholder: params.placeholder } : {}),
68
- ...(params.initialValue !== undefined ? { initialValue: params.initialValue } : {}),
69
- };
70
- const result = await text(promptOptions);
71
- if (isCancel(result)) {
72
- cancel("Initialization cancelled.");
73
- throw new Error("Initialization cancelled.");
74
- }
75
-
76
- const value = String(result ?? "").trim();
77
- if (value.length > 0) {
78
- return value;
79
- }
80
-
81
- await note("This field is required.", "Validation");
82
- }
83
- }
84
-
85
- /**
86
- * Collects init inputs from interactive prompts.
45
+ * Collects high-level starter choices only; config values are written as templates.
87
46
  */
88
47
  async function collectInitInput(): Promise<InitInput> {
89
48
  intro("dobby init");
@@ -102,6 +61,7 @@ async function collectInitInput(): Promise<InitInput> {
102
61
  cancel("Initialization cancelled.");
103
62
  throw new Error("Initialization cancelled.");
104
63
  }
64
+
105
65
  const providerChoiceIds = (providerChoiceResult as unknown[]).map((value) => String(value));
106
66
  if (providerChoiceIds.length === 0) {
107
67
  throw new Error("At least one provider must be selected");
@@ -110,12 +70,12 @@ async function collectInitInput(): Promise<InitInput> {
110
70
  const invalidChoice = providerChoiceIds.find((providerChoiceId) => !isInitProviderChoiceId(providerChoiceId));
111
71
  throw new Error(`Unsupported provider choice '${invalidChoice}'`);
112
72
  }
113
- const providerChoicesById = new Map(providerChoices.map((choice) => [choice.id, choice]));
114
73
 
74
+ const providerChoicesById = new Map(providerChoices.map((choice) => [choice.id, choice]));
115
75
  let routeProviderChoiceId = providerChoiceIds[0] as InitProviderChoiceId;
116
76
  if (providerChoiceIds.length > 1) {
117
77
  const routeProviderChoiceResult = await select({
118
- message: "Choose provider for the default route",
78
+ message: "Choose default provider",
119
79
  options: providerChoiceIds.map((providerChoiceId) => ({
120
80
  value: providerChoiceId,
121
81
  label: providerChoicesById.get(providerChoiceId as InitProviderChoiceId)?.label ?? providerChoiceId,
@@ -135,115 +95,63 @@ async function collectInitInput(): Promise<InitInput> {
135
95
  }
136
96
 
137
97
  const connectorChoices = listInitConnectorChoices();
138
- const connectorChoiceResult = await select({
139
- message: "Choose connector",
98
+ const connectorChoiceResult = await multiselect({
99
+ message: "Choose connector(s) (space to select multiple)",
140
100
  options: connectorChoices.map((item) => ({
141
101
  value: item.id,
142
102
  label: item.label,
143
103
  })),
144
- initialValue: "connector.discord",
104
+ initialValues: ["connector.discord"],
105
+ required: true,
145
106
  });
146
107
  if (isCancel(connectorChoiceResult)) {
147
108
  cancel("Initialization cancelled.");
148
109
  throw new Error("Initialization cancelled.");
149
110
  }
150
- const connectorChoiceId = String(connectorChoiceResult);
151
- if (!isInitConnectorChoiceId(connectorChoiceId)) {
152
- throw new Error(`Unsupported connector choice '${connectorChoiceId}'`);
153
- }
154
-
155
- const projectRoot = await promptRequiredText({
156
- message: "Project root",
157
- initialValue: process.cwd(),
158
- });
159
-
160
- const channelId = await promptRequiredText({
161
- message: "Discord channel ID",
162
- placeholder: "1234567890",
163
- });
164
-
165
- const routeIdResult = await text({
166
- message: "Route ID",
167
- initialValue: "main",
168
- });
169
- if (isCancel(routeIdResult)) {
170
- cancel("Initialization cancelled.");
171
- throw new Error("Initialization cancelled.");
172
- }
173
111
 
174
- const botNameResult = await text({
175
- message: "Discord bot name",
176
- initialValue: DEFAULT_DISCORD_BOT_NAME,
177
- });
178
- if (isCancel(botNameResult)) {
179
- cancel("Initialization cancelled.");
180
- throw new Error("Initialization cancelled.");
112
+ const connectorChoiceIds = (connectorChoiceResult as unknown[]).map((value) => String(value));
113
+ if (connectorChoiceIds.length === 0) {
114
+ throw new Error("At least one connector must be selected");
181
115
  }
182
-
183
- const botTokenResult = await password({
184
- message: "Discord bot token",
185
- mask: "*",
186
- validate: (value) => (value.trim().length > 0 ? undefined : "Token is required"),
187
- });
188
- if (isCancel(botTokenResult)) {
189
- cancel("Initialization cancelled.");
190
- throw new Error("Initialization cancelled.");
191
- }
192
-
193
- const allowAllMessagesResult = await confirm({
194
- message: "Allow all group messages (not mention-only)?",
195
- initialValue: false,
196
- });
197
- if (isCancel(allowAllMessagesResult)) {
198
- cancel("Initialization cancelled.");
199
- throw new Error("Initialization cancelled.");
116
+ if (!connectorChoiceIds.every((connectorChoiceId) => isInitConnectorChoiceId(connectorChoiceId))) {
117
+ const invalidChoice = connectorChoiceIds.find((connectorChoiceId) => !isInitConnectorChoiceId(connectorChoiceId));
118
+ throw new Error(`Unsupported connector choice '${invalidChoice}'`);
200
119
  }
201
120
 
202
121
  return {
203
122
  providerChoiceIds: providerChoiceIds as InitProviderChoiceId[],
204
123
  routeProviderChoiceId,
205
- connectorChoiceId,
206
- projectRoot,
207
- channelId,
208
- routeId: String(routeIdResult ?? "").trim() || "main",
209
- botName: String(botNameResult ?? "").trim() || DEFAULT_DISCORD_BOT_NAME,
210
- botToken: String(botTokenResult ?? "").trim(),
211
- allowAllMessages: allowAllMessagesResult === true,
124
+ connectorChoiceIds: connectorChoiceIds as InitConnectorChoiceId[],
212
125
  };
213
126
  }
214
127
 
215
128
  /**
216
- * Executes first-time initialization: install required extensions, write config, then validate.
129
+ * Executes first-time initialization by installing starter extensions and writing template config.
217
130
  */
218
131
  export async function runInitCommand(): Promise<void> {
219
132
  const configPath = resolveConfigPath();
220
133
  const existingConfig = await readRawConfig(configPath);
221
134
  if (existingConfig) {
222
135
  throw new Error(
223
- `Config '${configPath}' already exists. Use 'dobby config edit' or 'dobby configure' to update existing values.`,
136
+ `Config '${configPath}' already exists. Edit the file directly to update existing values.`,
224
137
  );
225
138
  }
226
139
 
227
140
  const input = await collectInitInput();
228
- const selected = createInitSelectionConfig(input.providerChoiceIds, input.connectorChoiceId, {
229
- routeId: input.routeId,
230
- projectRoot: input.projectRoot,
231
- allowAllMessages: input.allowAllMessages,
232
- botName: input.botName,
233
- botToken: input.botToken,
234
- channelId: input.channelId,
141
+ const selected = createInitSelectionConfig(input.providerChoiceIds, input.connectorChoiceIds, {
235
142
  routeProviderChoiceId: input.routeProviderChoiceId,
143
+ defaultProjectRoot: process.cwd(),
236
144
  });
237
145
 
238
146
  const next = ensureGatewayConfigShape({});
239
-
240
147
  const rootDir = resolveDataRootDir(configPath, next);
241
148
  const manager = new ExtensionStoreManager(createLogger(), `${rootDir}/extensions`);
149
+ const extensionInstallSpecs = await resolveExtensionInstallSpecs(selected.extensionPackages);
242
150
 
243
151
  const installSpinner = spinner();
244
152
  installSpinner.start(`Installing required extensions (${selected.extensionPackages.length} packages)`);
245
153
  try {
246
- const installedPackages = await manager.installMany(selected.extensionPackages);
154
+ const installedPackages = await manager.installMany(extensionInstallSpecs);
247
155
  for (const installed of installedPackages) {
248
156
  upsertAllowListPackage(next, installed.packageName, true);
249
157
  }
@@ -253,79 +161,13 @@ export async function runInitCommand(): Promise<void> {
253
161
  throw error;
254
162
  }
255
163
 
256
- const catalog = await loadContributionSchemaCatalog(configPath, next);
257
- const schemaByContributionId = new Map(
258
- catalog
259
- .filter((item) => item.configSchema)
260
- .map((item) => [item.contributionId, item.configSchema!] as const),
261
- );
262
- const schemaStateByContributionId = new Map(
263
- catalog.map((item) => [item.contributionId, item.configSchema ? "with_schema" : "without_schema"] as const),
264
- );
265
- const warnedSchemaFallback = new Set<string>();
266
- const noteSchemaFallback = async (contributionId: string): Promise<"without_schema" | "not_loaded"> => {
267
- if (warnedSchemaFallback.has(contributionId)) {
268
- const existingState = schemaStateByContributionId.get(contributionId);
269
- return existingState === "without_schema" ? "without_schema" : "not_loaded";
270
- }
271
- warnedSchemaFallback.add(contributionId);
272
-
273
- const state = schemaStateByContributionId.get(contributionId);
274
- if (state === "without_schema") {
275
- await note(
276
- `Contribution '${contributionId}' is loaded but does not expose configSchema. Falling back to built-in defaults/JSON.`,
277
- "Schema",
278
- );
279
- return "without_schema";
280
- }
281
-
282
- await note(
283
- `No loaded schema for contribution '${contributionId}'. The extension may be disabled or not installed.`,
284
- "Schema",
285
- );
286
- return "not_loaded";
287
- };
288
-
289
- const resolveFallbackConfig = async (
290
- kind: "provider" | "connector",
291
- instanceId: string,
292
- contributionId: string,
293
- fallbackConfig: Record<string, unknown>,
294
- ): Promise<Record<string, unknown>> => {
295
- const state = await noteSchemaFallback(contributionId);
296
- if (state === "not_loaded") {
297
- throw new Error(
298
- `Cannot initialize ${kind} '${instanceId}' because schema for contribution '${contributionId}' is not loaded. ` +
299
- `Ensure the extension is installed and enabled, then retry.`,
300
- );
301
- }
302
- return fallbackConfig;
303
- };
304
-
305
164
  for (const provider of selected.providerInstances) {
306
- const schema = schemaByContributionId.get(provider.contributionId);
307
- const providerConfig = schema
308
- ? await promptConfigFromSchema(schema, provider.config, {
309
- title: `Provider '${provider.instanceId}' (${provider.contributionId})`,
310
- })
311
- : await resolveFallbackConfig("provider", provider.instanceId, provider.contributionId, provider.config);
312
- upsertProviderInstance(next, provider.instanceId, provider.contributionId, providerConfig);
165
+ upsertProviderInstance(next, provider.instanceId, provider.contributionId, provider.config);
313
166
  }
314
167
 
315
- const connectorSchema = schemaByContributionId.get(selected.connectorContributionId);
316
- const connectorConfig = selected.connectorContributionId === "connector.discord"
317
- ? selected.connectorConfig
318
- : connectorSchema
319
- ? await promptConfigFromSchema(connectorSchema, selected.connectorConfig, {
320
- title: `Connector '${selected.connectorInstanceId}' (${selected.connectorContributionId})`,
321
- })
322
- : await resolveFallbackConfig(
323
- "connector",
324
- selected.connectorInstanceId,
325
- selected.connectorContributionId,
326
- selected.connectorConfig,
327
- );
328
- upsertConnectorInstance(next, selected.connectorInstanceId, selected.connectorContributionId, connectorConfig);
168
+ for (const connector of selected.connectorInstances) {
169
+ upsertConnectorInstance(next, connector.instanceId, connector.contributionId, connector.config);
170
+ }
329
171
 
330
172
  next.providers = {
331
173
  ...next.providers,
@@ -334,34 +176,26 @@ export async function runInitCommand(): Promise<void> {
334
176
  };
335
177
  next.routes = {
336
178
  ...next.routes,
337
- defaults: {
338
- ...next.routes.defaults,
339
- provider: selected.providerInstanceId,
179
+ default: {
180
+ ...next.routes.default,
181
+ ...selected.routeDefaults,
340
182
  },
341
183
  };
342
184
 
343
- upsertRoute(next, input.routeId, {
344
- ...selected.routeProfile,
345
- projectRoot: input.projectRoot,
346
- });
347
- upsertBinding(next, selected.bindingId, selected.bindingConfig);
185
+ upsertRoute(next, selected.routeId, selected.routeProfile);
186
+ if (selected.defaultBinding) {
187
+ next.bindings = {
188
+ ...next.bindings,
189
+ default: selected.defaultBinding,
190
+ items: next.bindings.items,
191
+ };
192
+ }
193
+ for (const binding of selected.bindings) {
194
+ upsertBinding(next, binding.id, binding.config);
195
+ }
348
196
 
349
197
  const validatedConfig = await applyAndValidateContributionSchemas(configPath, next);
350
198
 
351
- const createdModelsFiles: string[] = [];
352
- for (const provider of selected.providerInstances) {
353
- if (provider.contributionId !== "provider.pi") {
354
- continue;
355
- }
356
-
357
- const resolvedProvider = validatedConfig.providers?.items?.[provider.instanceId];
358
- const { type: _type, ...providerConfig } = resolvedProvider ?? {};
359
- const ensured = await ensureProviderPiModelsFile(configPath, Object.keys(providerConfig).length > 0 ? providerConfig : provider.config);
360
- if (ensured.created) {
361
- createdModelsFiles.push(ensured.path);
362
- }
363
- }
364
-
365
199
  await writeConfigWithValidation(configPath, validatedConfig, {
366
200
  validate: true,
367
201
  createBackup: false,
@@ -370,27 +204,8 @@ export async function runInitCommand(): Promise<void> {
370
204
  outro("Initialization completed.");
371
205
 
372
206
  console.log(`Config written: ${configPath}`);
373
- if (createdModelsFiles.length > 0) {
374
- console.log("Generated model files:");
375
- for (const path of createdModelsFiles) {
376
- console.log(`- ${path}`);
377
- }
378
- }
379
207
  console.log("Next steps:");
380
- console.log("1. dobby start");
381
-
382
- const showHint = await confirm({
383
- message: "Show quick validation commands?",
384
- initialValue: true,
385
- });
386
-
387
- if (!isCancel(showHint) && showHint) {
388
- await note(
389
- [
390
- "dobby extension list",
391
- "dobby doctor",
392
- ].join("\n"),
393
- "Validation",
394
- );
395
- }
208
+ console.log("1. Edit gateway.json and replace all REPLACE_WITH_* / YOUR_* placeholders");
209
+ console.log("2. Run 'dobby doctor' to validate the edited config");
210
+ console.log("3. Run 'dobby start' when the placeholders are replaced");
396
211
  }
@@ -18,7 +18,7 @@ interface DiscordConnectorView {
18
18
  interface BindingView {
19
19
  bindingId: string;
20
20
  connectorId: string;
21
- sourceType: "channel" | "chat";
21
+ sourceType: string;
22
22
  sourceId: string;
23
23
  routeId: string;
24
24
  routeExists: boolean;
@@ -35,6 +35,18 @@ interface RouteView {
35
35
  bindings: number;
36
36
  }
37
37
 
38
+ function effectiveRouteProjectRoot(
39
+ normalized: ReturnType<typeof ensureGatewayConfigShape>,
40
+ routeId: string,
41
+ ): string | undefined {
42
+ const route = normalized.routes.items[routeId];
43
+ if (!route) {
44
+ return undefined;
45
+ }
46
+
47
+ return route.projectRoot ?? normalized.routes.default.projectRoot;
48
+ }
49
+
38
50
  function listDiscordConnectors(rawConfig: unknown): DiscordConnectorView[] {
39
51
  const normalized = ensureGatewayConfigShape(rawConfig as RawGatewayConfig);
40
52
  const items: DiscordConnectorView[] = [];
@@ -82,12 +94,11 @@ function getDiscordConnectorOrThrow(
82
94
 
83
95
  function listBindings(rawConfig: unknown, connectorFilter?: string): BindingView[] {
84
96
  const normalized = ensureGatewayConfigShape(rawConfig as RawGatewayConfig);
85
- const routes = normalized.routes.items;
86
-
87
- return Object.entries(normalized.bindings.items)
97
+ const bindings: BindingView[] = Object.entries(normalized.bindings.items)
88
98
  .filter(([, binding]) => !connectorFilter || binding.connector === connectorFilter)
89
99
  .map(([bindingId, binding]) => {
90
- const route = routes[binding.route];
100
+ const route = normalized.routes.items[binding.route];
101
+ const projectRoot = route ? effectiveRouteProjectRoot(normalized, binding.route) : undefined;
91
102
  return {
92
103
  bindingId,
93
104
  connectorId: binding.connector,
@@ -95,15 +106,30 @@ function listBindings(rawConfig: unknown, connectorFilter?: string): BindingView
95
106
  sourceId: binding.source.id,
96
107
  routeId: binding.route,
97
108
  routeExists: Boolean(route),
98
- ...(route ? { projectRoot: route.projectRoot } : {}),
109
+ ...(projectRoot ? { projectRoot } : {}),
99
110
  };
100
- })
101
- .sort((a, b) => a.bindingId.localeCompare(b.bindingId));
111
+ });
112
+
113
+ if (!connectorFilter && normalized.bindings.default) {
114
+ const projectRoot = effectiveRouteProjectRoot(normalized, normalized.bindings.default.route);
115
+ bindings.push({
116
+ bindingId: "bindings.default",
117
+ connectorId: "*",
118
+ sourceType: "direct_message",
119
+ sourceId: "*",
120
+ routeId: normalized.bindings.default.route,
121
+ routeExists: Boolean(normalized.routes.items[normalized.bindings.default.route]),
122
+ ...(projectRoot ? { projectRoot } : {}),
123
+ });
124
+ }
125
+
126
+ return bindings.sort((a, b) => a.bindingId.localeCompare(b.bindingId));
102
127
  }
103
128
 
104
129
  function buildRouteBindingCounts(rawConfig: unknown): Map<string, number> {
130
+ const normalized = ensureGatewayConfigShape(rawConfig as RawGatewayConfig);
105
131
  const counts = new Map<string, number>();
106
- for (const binding of listBindings(rawConfig)) {
132
+ for (const binding of listBindings(normalized)) {
107
133
  counts.set(binding.routeId, (counts.get(binding.routeId) ?? 0) + 1);
108
134
  }
109
135
  return counts;
@@ -116,7 +142,7 @@ function listRoutes(rawConfig: unknown): RouteView[] {
116
142
  return Object.entries(normalized.routes.items)
117
143
  .map(([routeId, route]): RouteView => ({
118
144
  routeId,
119
- projectRoot: route.projectRoot,
145
+ projectRoot: effectiveRouteProjectRoot(normalized, routeId) ?? "(unset)",
120
146
  tools: route.tools === "readonly" ? "readonly" : "full",
121
147
  mentions: route.mentions === "optional" ? "optional" : "required",
122
148
  ...(route.provider ? { provider: route.provider } : {}),
@@ -319,7 +345,7 @@ export async function runRouteSetCommand(options: {
319
345
  const existing = normalized.routes.items[options.routeId];
320
346
 
321
347
  const projectRoot = options.projectRoot?.trim() || existing?.projectRoot;
322
- if (!projectRoot) {
348
+ if (!projectRoot && !normalized.routes.default.projectRoot) {
323
349
  throw new Error("--project-root is required when creating a new route");
324
350
  }
325
351
 
@@ -339,7 +365,7 @@ export async function runRouteSetCommand(options: {
339
365
  }
340
366
 
341
367
  upsertRoute(normalized, options.routeId, {
342
- projectRoot,
368
+ ...(projectRoot ? { projectRoot } : {}),
343
369
  ...(toolsRaw ? { tools: toolsRaw } : {}),
344
370
  ...((options.mentions ?? existing?.mentions) ? { mentions: (options.mentions ?? existing?.mentions)! } : {}),
345
371
  ...(provider ? { provider } : {}),
@@ -363,18 +389,24 @@ export async function runRouteRemoveCommand(options: {
363
389
  throw new Error(`Route '${options.routeId}' not found`);
364
390
  }
365
391
 
366
- const bindingRefs = listBindings(normalized).filter((binding) => binding.routeId === options.routeId);
367
- if (bindingRefs.length > 0 && !options.cascadeBindings) {
392
+ const bindingRefs = listBindings(normalized).filter(
393
+ (binding) => binding.routeId === options.routeId && binding.bindingId !== "bindings.default",
394
+ );
395
+ const hasDefaultBindingRef = normalized.bindings.default?.route === options.routeId;
396
+ if ((bindingRefs.length > 0 || hasDefaultBindingRef) && !options.cascadeBindings) {
368
397
  const refList = bindingRefs.map((binding) => binding.bindingId).join(", ");
369
398
  throw new Error(
370
- `Route '${options.routeId}' is referenced by bindings (${refList}). Re-run with --cascade-bindings to remove these bindings automatically.`,
399
+ `Route '${options.routeId}' is referenced by bindings (${[refList, hasDefaultBindingRef ? "bindings.default" : ""].filter(Boolean).join(", ")}). Re-run with --cascade-bindings to remove these bindings automatically.`,
371
400
  );
372
401
  }
373
402
 
374
- if (bindingRefs.length > 0 && options.cascadeBindings) {
403
+ if (options.cascadeBindings) {
375
404
  for (const binding of bindingRefs) {
376
405
  delete normalized.bindings.items[binding.bindingId];
377
406
  }
407
+ if (hasDefaultBindingRef) {
408
+ delete normalized.bindings.default;
409
+ }
378
410
  }
379
411
 
380
412
  delete normalized.routes.items[options.routeId];