@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
@@ -42,6 +42,7 @@ function asRouteDefaults(value) {
42
42
  };
43
43
  }
44
44
  return {
45
+ ...(typeof value.projectRoot === "string" && value.projectRoot.trim().length > 0 ? { projectRoot: value.projectRoot } : {}),
45
46
  ...(typeof value.provider === "string" && value.provider.trim().length > 0 ? { provider: value.provider } : {}),
46
47
  ...(typeof value.sandbox === "string" && value.sandbox.trim().length > 0 ? { sandbox: value.sandbox } : {}),
47
48
  tools: value.tools === "readonly" ? "readonly" : "full",
@@ -54,12 +55,12 @@ function asRoutes(value) {
54
55
  }
55
56
  const normalized = {};
56
57
  for (const [routeId, route] of Object.entries(value)) {
57
- if (!isRecord(route) || typeof route.projectRoot !== "string" || route.projectRoot.trim().length === 0) {
58
+ if (!isRecord(route)) {
58
59
  continue;
59
60
  }
60
61
  normalized[routeId] = {
61
62
  ...route,
62
- projectRoot: route.projectRoot,
63
+ ...(typeof route.projectRoot === "string" && route.projectRoot.trim().length > 0 ? { projectRoot: route.projectRoot } : {}),
63
64
  ...(route.tools === "readonly" ? { tools: "readonly" } : {}),
64
65
  ...(route.mentions === "optional" ? { mentions: "optional" } : {}),
65
66
  ...(typeof route.provider === "string" && route.provider.trim().length > 0 ? { provider: route.provider } : {}),
@@ -69,6 +70,15 @@ function asRoutes(value) {
69
70
  }
70
71
  return normalized;
71
72
  }
73
+ function asDefaultBinding(value) {
74
+ if (!isRecord(value) || typeof value.route !== "string" || value.route.trim().length === 0) {
75
+ return undefined;
76
+ }
77
+ return {
78
+ ...value,
79
+ route: value.route,
80
+ };
81
+ }
72
82
  function asBindings(value) {
73
83
  if (!isRecord(value)) {
74
84
  return {};
@@ -109,13 +119,14 @@ export function ensureGatewayConfigShape(config) {
109
119
  const normalizedSandboxesDefault = typeof config.sandboxes?.default === "string" && config.sandboxes.default.trim().length > 0
110
120
  ? config.sandboxes.default
111
121
  : "host.builtin";
112
- const routeDefaults = asRouteDefaults(config.routes?.defaults);
122
+ const routeDefaults = asRouteDefaults(config.routes?.default);
113
123
  if (!routeDefaults.provider && normalizedProvidersDefault) {
114
124
  routeDefaults.provider = normalizedProvidersDefault;
115
125
  }
116
126
  if (!routeDefaults.sandbox && normalizedSandboxesDefault) {
117
127
  routeDefaults.sandbox = normalizedSandboxesDefault;
118
128
  }
129
+ const defaultBinding = asDefaultBinding(config.bindings?.default);
119
130
  return {
120
131
  ...config,
121
132
  extensions: {
@@ -138,11 +149,12 @@ export function ensureGatewayConfigShape(config) {
138
149
  },
139
150
  routes: {
140
151
  ...(isRecord(config.routes) ? config.routes : {}),
141
- defaults: routeDefaults,
152
+ default: routeDefaults,
142
153
  items: asRoutes(config.routes?.items),
143
154
  },
144
155
  bindings: {
145
156
  ...(isRecord(config.bindings) ? config.bindings : {}),
157
+ ...(defaultBinding ? { default: defaultBinding } : {}),
146
158
  items: asBindings(config.bindings?.items),
147
159
  },
148
160
  data: {
@@ -283,11 +295,11 @@ export function setDefaultProviderIfMissingOrInvalid(config) {
283
295
  const defaultProvider = next.providers.default;
284
296
  if (defaultProvider && items[defaultProvider]) {
285
297
  config.providers = next.providers;
286
- if (!next.routes.defaults.provider) {
298
+ if (!next.routes.default.provider) {
287
299
  config.routes = {
288
300
  ...next.routes,
289
301
  defaults: {
290
- ...next.routes.defaults,
302
+ ...next.routes.default,
291
303
  provider: defaultProvider,
292
304
  },
293
305
  };
@@ -306,8 +318,8 @@ export function setDefaultProviderIfMissingOrInvalid(config) {
306
318
  };
307
319
  config.routes = {
308
320
  ...next.routes,
309
- defaults: {
310
- ...next.routes.defaults,
321
+ default: {
322
+ ...next.routes.default,
311
323
  provider: candidates[0],
312
324
  },
313
325
  };
@@ -315,7 +327,7 @@ export function setDefaultProviderIfMissingOrInvalid(config) {
315
327
  export function upsertRoute(config, routeId, profile) {
316
328
  const next = ensureGatewayConfigShape(config);
317
329
  next.routes.items[routeId] = {
318
- projectRoot: profile.projectRoot,
330
+ ...(typeof profile.projectRoot === "string" && profile.projectRoot.trim().length > 0 ? { projectRoot: profile.projectRoot } : {}),
319
331
  ...(profile.tools ? { tools: profile.tools } : {}),
320
332
  ...(profile.mentions ? { mentions: profile.mentions } : {}),
321
333
  ...(profile.provider ? { provider: profile.provider } : {}),
@@ -335,6 +347,18 @@ export function upsertBinding(config, bindingId, binding) {
335
347
  items: next.bindings.items,
336
348
  };
337
349
  }
350
+ export function setDefaultBinding(config, binding) {
351
+ const next = ensureGatewayConfigShape(config);
352
+ const normalizedBinding = binding ? structuredClone(binding) : undefined;
353
+ config.bindings = {
354
+ ...next.bindings,
355
+ ...(normalizedBinding ? { default: normalizedBinding } : {}),
356
+ items: next.bindings.items,
357
+ };
358
+ if (!normalizedBinding) {
359
+ delete config.bindings.default;
360
+ }
361
+ }
338
362
  export function listContributionIds(config) {
339
363
  const next = ensureGatewayConfigShape(config);
340
364
  return {
@@ -1,6 +1,6 @@
1
1
  import { cancel, confirm, isCancel, note, password, select, text, } from "@clack/prompts";
2
2
  import JSON5 from "json5";
3
- import { ensureGatewayConfigShape, setDefaultProviderIfMissingOrInvalid, upsertBinding, upsertConnectorInstance, upsertProviderInstance, upsertRoute, } from "./config-mutators.js";
3
+ import { ensureGatewayConfigShape, setDefaultBinding, setDefaultProviderIfMissingOrInvalid, upsertBinding, upsertConnectorInstance, upsertProviderInstance, upsertRoute, } from "./config-mutators.js";
4
4
  import { DEFAULT_DISCORD_BOT_NAME, DISCORD_CONNECTOR_CONTRIBUTION_ID, } from "./discord-config.js";
5
5
  import { promptConfigFromSchema } from "./schema-prompts.js";
6
6
  export const CONFIGURE_SECTION_VALUES = ["provider", "connector", "route", "binding", "sandbox", "data"];
@@ -147,7 +147,7 @@ async function configureProviderSection(config, context) {
147
147
  throw new Error("Configure cancelled.");
148
148
  }
149
149
  next.providers.default = String(defaultProvider);
150
- next.routes.defaults.provider = String(defaultProvider);
150
+ next.routes.default.provider = String(defaultProvider);
151
151
  }
152
152
  Object.assign(config, next);
153
153
  }
@@ -229,11 +229,32 @@ async function configureRouteSection(config) {
229
229
  }
230
230
  const routeId = String(targetRoute) === "__new" ? await requiredText("New route ID", "main") : String(targetRoute);
231
231
  const existing = routeItems[routeId];
232
- const projectRoot = await requiredText("projectRoot", existing?.projectRoot ?? process.cwd());
232
+ const defaultProjectRoot = next.routes.default.projectRoot;
233
+ let projectRoot = existing?.projectRoot;
234
+ if (defaultProjectRoot) {
235
+ const projectRootMode = await select({
236
+ message: "projectRoot",
237
+ options: [
238
+ { value: "__default", label: `Use route default (${defaultProjectRoot})` },
239
+ { value: "__custom", label: "Set explicit projectRoot" },
240
+ ],
241
+ initialValue: existing?.projectRoot ? "__custom" : "__default",
242
+ });
243
+ if (isCancel(projectRootMode)) {
244
+ cancel("Configure cancelled.");
245
+ throw new Error("Configure cancelled.");
246
+ }
247
+ projectRoot = projectRootMode === "__custom"
248
+ ? await requiredText("projectRoot", existing?.projectRoot ?? defaultProjectRoot)
249
+ : undefined;
250
+ }
251
+ else {
252
+ projectRoot = await requiredText("projectRoot", existing?.projectRoot ?? process.cwd());
253
+ }
233
254
  const tools = await select({
234
255
  message: "tools",
235
256
  options: [
236
- { value: "__default", label: `Use route default (${next.routes.defaults.tools ?? "full"})` },
257
+ { value: "__default", label: `Use route default (${next.routes.default.tools ?? "full"})` },
237
258
  { value: "full", label: "full" },
238
259
  { value: "readonly", label: "readonly" },
239
260
  ],
@@ -246,7 +267,7 @@ async function configureRouteSection(config) {
246
267
  const mentions = await select({
247
268
  message: "mentions",
248
269
  options: [
249
- { value: "__default", label: `Use route default (${next.routes.defaults.mentions ?? "required"})` },
270
+ { value: "__default", label: `Use route default (${next.routes.default.mentions ?? "required"})` },
250
271
  { value: "required", label: "required" },
251
272
  { value: "optional", label: "optional" },
252
273
  ],
@@ -261,7 +282,7 @@ async function configureRouteSection(config) {
261
282
  ? await select({
262
283
  message: "provider",
263
284
  options: [
264
- { value: "__default", label: `Use route default (${(next.routes.defaults.provider ?? next.providers.default) || "(unset)"})` },
285
+ { value: "__default", label: `Use route default (${(next.routes.default.provider ?? next.providers.default) || "(unset)"})` },
265
286
  ...providerIds.map((id) => ({ value: id, label: id })),
266
287
  ],
267
288
  initialValue: existing?.provider ?? "__default",
@@ -275,7 +296,7 @@ async function configureRouteSection(config) {
275
296
  const sandboxValue = await select({
276
297
  message: "sandbox",
277
298
  options: [
278
- { value: "__default", label: `Use route default (${next.routes.defaults.sandbox ?? next.sandboxes.default})` },
299
+ { value: "__default", label: `Use route default (${next.routes.default.sandbox ?? next.sandboxes.default})` },
279
300
  ...sandboxIds.map((id) => ({ value: id, label: id })),
280
301
  ],
281
302
  initialValue: existing?.sandbox ?? "__default",
@@ -286,7 +307,7 @@ async function configureRouteSection(config) {
286
307
  }
287
308
  const systemPromptFile = await optionalText("systemPromptFile (optional)", existing?.systemPromptFile ?? "");
288
309
  upsertRoute(next, routeId, {
289
- projectRoot,
310
+ ...(projectRoot ? { projectRoot } : {}),
290
311
  ...(tools !== "__default" ? { tools: String(tools) } : {}),
291
312
  ...(mentions !== "__default" ? { mentions: String(mentions) } : {}),
292
313
  ...(providerValue !== "__default" ? { provider: String(providerValue) } : {}),
@@ -306,19 +327,39 @@ async function configureBindingSection(config) {
306
327
  throw new Error("No routes found. Configure routes first.");
307
328
  }
308
329
  const targetBinding = bindingChoices.length === 0
309
- ? "__new"
330
+ ? (next.bindings.default ? "__default" : "__new")
310
331
  : await select({
311
332
  message: "Select binding",
312
333
  options: [
334
+ { value: "__default", label: next.bindings.default ? "Edit default direct-message binding" : "Create default direct-message binding" },
313
335
  ...bindingChoices.map((id) => ({ value: id, label: id })),
314
336
  { value: "__new", label: "Create new binding" },
315
337
  ],
316
- initialValue: bindingChoices[0],
338
+ initialValue: next.bindings.default ? "__default" : bindingChoices[0],
317
339
  });
318
340
  if (isCancel(targetBinding)) {
319
341
  cancel("Configure cancelled.");
320
342
  throw new Error("Configure cancelled.");
321
343
  }
344
+ const routeIds = Object.keys(next.routes.items).sort((a, b) => a.localeCompare(b));
345
+ if (String(targetBinding) === "__default") {
346
+ const defaultRouteId = await select({
347
+ message: "Default direct-message route",
348
+ options: routeIds.map((id) => ({ value: id, label: id })),
349
+ initialValue: next.bindings.default?.route && routeIds.includes(next.bindings.default.route)
350
+ ? next.bindings.default.route
351
+ : routeIds[0],
352
+ });
353
+ if (isCancel(defaultRouteId)) {
354
+ cancel("Configure cancelled.");
355
+ throw new Error("Configure cancelled.");
356
+ }
357
+ setDefaultBinding(next, {
358
+ route: String(defaultRouteId),
359
+ });
360
+ Object.assign(config, next);
361
+ return;
362
+ }
322
363
  const bindingId = String(targetBinding) === "__new"
323
364
  ? await requiredText("New binding ID", "discord.main.main")
324
365
  : String(targetBinding);
@@ -346,7 +387,6 @@ async function configureBindingSection(config) {
346
387
  throw new Error("Configure cancelled.");
347
388
  }
348
389
  const sourceId = await requiredText("source.id", existing?.source.id);
349
- const routeIds = Object.keys(next.routes.items).sort((a, b) => a.localeCompare(b));
350
390
  const routeId = await select({
351
391
  message: "route",
352
392
  options: routeIds.map((id) => ({ value: id, label: id })),
@@ -386,7 +426,7 @@ async function configureSandboxSection(config) {
386
426
  throw new Error("Configure cancelled.");
387
427
  }
388
428
  next.sandboxes.default = String(defaultSandbox);
389
- next.routes.defaults.sandbox = String(defaultSandbox);
429
+ next.routes.default.sandbox = String(defaultSandbox);
390
430
  Object.assign(config, next);
391
431
  }
392
432
  async function configureDataSection(config) {
@@ -1,25 +1,26 @@
1
- import { DEFAULT_DISCORD_CONNECTOR_INSTANCE_ID, DISCORD_CONNECTOR_CONTRIBUTION_ID, } from "./discord-config.js";
1
+ import { DEFAULT_DISCORD_BOT_NAME, DEFAULT_DISCORD_CONNECTOR_INSTANCE_ID, DISCORD_CONNECTOR_CONTRIBUTION_ID, } from "./discord-config.js";
2
+ export const DEFAULT_INIT_ROUTE_ID = "main";
3
+ export const DEFAULT_INIT_PROJECT_ROOT = "./REPLACE_WITH_PROJECT_ROOT";
2
4
  const PROVIDER_CATALOG = {
3
5
  "provider.pi": {
4
6
  id: "provider.pi",
5
7
  label: "Pi provider",
6
- extensionPackage: "@dobby.ai/provider-pi",
8
+ package: "@dobby.ai/provider-pi",
7
9
  instanceId: "pi.main",
8
10
  contributionId: "provider.pi",
9
- config: {
10
- provider: "custom-openai",
11
- model: "example-model",
12
- thinkingLevel: "off",
13
- modelsFile: "./models.custom.json",
11
+ defaultConfig: {
12
+ model: "REPLACE_WITH_PROVIDER_MODEL_ID",
13
+ baseUrl: "REPLACE_WITH_PROVIDER_BASE_URL",
14
+ apiKey: "REPLACE_WITH_PROVIDER_API_KEY_OR_ENV",
14
15
  },
15
16
  },
16
17
  "provider.claude-cli": {
17
18
  id: "provider.claude-cli",
18
19
  label: "Claude CLI provider",
19
- extensionPackage: "@dobby.ai/provider-claude-cli",
20
+ package: "@dobby.ai/provider-claude-cli",
20
21
  instanceId: "claude-cli.main",
21
22
  contributionId: "provider.claude-cli",
22
- config: {
23
+ defaultConfig: {
23
24
  model: "claude-sonnet-4-5",
24
25
  maxTurns: 20,
25
26
  command: "claude",
@@ -34,11 +35,52 @@ const CONNECTOR_CATALOG = {
34
35
  "connector.discord": {
35
36
  id: "connector.discord",
36
37
  label: "Discord connector",
37
- extensionPackage: "@dobby.ai/connector-discord",
38
+ package: "@dobby.ai/connector-discord",
38
39
  instanceId: DEFAULT_DISCORD_CONNECTOR_INSTANCE_ID,
39
40
  contributionId: DISCORD_CONNECTOR_CONTRIBUTION_ID,
41
+ defaultConfig: {
42
+ botName: DEFAULT_DISCORD_BOT_NAME,
43
+ botToken: "REPLACE_WITH_DISCORD_BOT_TOKEN",
44
+ reconnectStaleMs: 60_000,
45
+ reconnectCheckIntervalMs: 10_000,
46
+ },
47
+ bindingTemplate: {
48
+ sourceType: "channel",
49
+ sourceId: "YOUR_DISCORD_CHANNEL_ID",
50
+ },
51
+ },
52
+ "connector.feishu": {
53
+ id: "connector.feishu",
54
+ label: "Feishu connector",
55
+ package: "@dobby.ai/connector-feishu",
56
+ instanceId: "feishu.main",
57
+ contributionId: "connector.feishu",
58
+ defaultConfig: {
59
+ appId: "REPLACE_WITH_FEISHU_APP_ID",
60
+ appSecret: "REPLACE_WITH_FEISHU_APP_SECRET",
61
+ domain: "feishu",
62
+ messageFormat: "card_markdown",
63
+ replyMode: "direct",
64
+ downloadAttachments: true,
65
+ },
66
+ bindingTemplate: {
67
+ sourceType: "chat",
68
+ sourceId: "YOUR_FEISHU_CHAT_ID",
69
+ },
40
70
  },
41
71
  };
72
+ function dedupeChoiceIds(choiceIds) {
73
+ const dedupedChoiceIds = [];
74
+ const seenChoiceIds = new Set();
75
+ for (const choiceId of choiceIds) {
76
+ if (seenChoiceIds.has(choiceId)) {
77
+ continue;
78
+ }
79
+ seenChoiceIds.add(choiceId);
80
+ dedupedChoiceIds.push(choiceId);
81
+ }
82
+ return dedupedChoiceIds;
83
+ }
42
84
  export function listInitProviderChoices() {
43
85
  return Object.values(PROVIDER_CATALOG);
44
86
  }
@@ -51,65 +93,66 @@ export function isInitProviderChoiceId(value) {
51
93
  export function isInitConnectorChoiceId(value) {
52
94
  return Object.prototype.hasOwnProperty.call(CONNECTOR_CATALOG, value);
53
95
  }
54
- export function createInitSelectionConfig(providerChoiceIds, connectorChoiceId, context) {
55
- const dedupedProviderChoiceIds = [];
56
- const seenProviderChoiceIds = new Set();
57
- for (const providerChoiceId of providerChoiceIds) {
58
- if (!seenProviderChoiceIds.has(providerChoiceId)) {
59
- seenProviderChoiceIds.add(providerChoiceId);
60
- dedupedProviderChoiceIds.push(providerChoiceId);
61
- }
62
- }
96
+ export function createInitSelectionConfig(providerChoiceIds, connectorChoiceIds, context) {
97
+ const dedupedProviderChoiceIds = dedupeChoiceIds(providerChoiceIds);
63
98
  if (dedupedProviderChoiceIds.length === 0) {
64
99
  throw new Error("At least one provider choice is required");
65
100
  }
101
+ const dedupedConnectorChoiceIds = dedupeChoiceIds(connectorChoiceIds);
102
+ if (dedupedConnectorChoiceIds.length === 0) {
103
+ throw new Error("At least one connector choice is required");
104
+ }
66
105
  if (!dedupedProviderChoiceIds.includes(context.routeProviderChoiceId)) {
67
106
  throw new Error(`route provider choice '${context.routeProviderChoiceId}' must be one of selected providers: ${dedupedProviderChoiceIds.join(", ")}`);
68
107
  }
69
108
  const providerChoices = dedupedProviderChoiceIds.map((providerChoiceId) => PROVIDER_CATALOG[providerChoiceId]);
109
+ const connectorChoices = dedupedConnectorChoiceIds.map((connectorChoiceId) => CONNECTOR_CATALOG[connectorChoiceId]);
70
110
  const primaryProviderChoice = PROVIDER_CATALOG[context.routeProviderChoiceId];
71
- const connectorChoice = CONNECTOR_CATALOG[connectorChoiceId];
72
111
  return {
73
112
  providerChoiceIds: dedupedProviderChoiceIds,
74
113
  routeProviderChoiceId: primaryProviderChoice.id,
75
- providerChoiceId: primaryProviderChoice.id,
76
- connectorChoiceId,
114
+ connectorChoiceIds: dedupedConnectorChoiceIds,
77
115
  extensionPackages: [
78
- ...new Set([...providerChoices.map((item) => item.extensionPackage), connectorChoice.extensionPackage]),
116
+ ...new Set([
117
+ ...providerChoices.map((item) => item.package),
118
+ ...connectorChoices.map((item) => item.package),
119
+ ]),
79
120
  ],
80
121
  providerInstances: providerChoices.map((providerChoice) => ({
81
122
  choiceId: providerChoice.id,
82
123
  instanceId: providerChoice.instanceId,
83
124
  contributionId: providerChoice.contributionId,
84
- config: structuredClone(providerChoice.config),
125
+ config: structuredClone(providerChoice.defaultConfig),
126
+ })),
127
+ connectorInstances: connectorChoices.map((connectorChoice) => ({
128
+ choiceId: connectorChoice.id,
129
+ instanceId: connectorChoice.instanceId,
130
+ contributionId: connectorChoice.contributionId,
131
+ config: structuredClone(connectorChoice.defaultConfig),
85
132
  })),
86
133
  providerInstanceId: primaryProviderChoice.instanceId,
87
- providerContributionId: primaryProviderChoice.contributionId,
88
- providerConfig: structuredClone(primaryProviderChoice.config),
89
- connectorInstanceId: connectorChoice.instanceId,
90
- connectorContributionId: connectorChoice.contributionId,
91
- connectorConfig: {
92
- botName: context.botName,
93
- botToken: context.botToken,
94
- reconnectStaleMs: 60_000,
95
- reconnectCheckIntervalMs: 10_000,
96
- },
97
- routeProfile: {
98
- projectRoot: context.projectRoot,
134
+ routeId: DEFAULT_INIT_ROUTE_ID,
135
+ routeDefaults: {
136
+ projectRoot: context.defaultProjectRoot ?? DEFAULT_INIT_PROJECT_ROOT,
99
137
  tools: "full",
100
- systemPromptFile: "",
101
- mentions: context.allowAllMessages ? "optional" : "required",
138
+ mentions: "required",
102
139
  provider: primaryProviderChoice.instanceId,
103
140
  sandbox: "host.builtin",
104
141
  },
105
- bindingId: `${connectorChoice.instanceId}.${context.routeId}`,
106
- bindingConfig: {
107
- connector: connectorChoice.instanceId,
108
- source: {
109
- type: "channel",
110
- id: context.channelId,
111
- },
112
- route: context.routeId,
142
+ routeProfile: {},
143
+ defaultBinding: {
144
+ route: DEFAULT_INIT_ROUTE_ID,
113
145
  },
146
+ bindings: connectorChoices.map((connectorChoice) => ({
147
+ id: `${connectorChoice.instanceId}.${DEFAULT_INIT_ROUTE_ID}`,
148
+ config: {
149
+ connector: connectorChoice.instanceId,
150
+ source: {
151
+ type: connectorChoice.bindingTemplate.sourceType,
152
+ id: connectorChoice.bindingTemplate.sourceId,
153
+ },
154
+ route: DEFAULT_INIT_ROUTE_ID,
155
+ },
156
+ })),
114
157
  };
115
158
  }
@@ -0,0 +1,85 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import { readdir } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { findDobbyRepoRoot } from "../../shared/dobby-repo.js";
5
+ function isExplicitInstallSpec(value) {
6
+ return value.startsWith("file:")
7
+ || value.startsWith("git+")
8
+ || value.startsWith("http://")
9
+ || value.startsWith("https://")
10
+ || value.startsWith("./")
11
+ || value.startsWith("../")
12
+ || value.startsWith("/");
13
+ }
14
+ async function listRepoLocalExtensionPackages(repoRoot) {
15
+ const pluginsRoot = resolve(repoRoot, "plugins");
16
+ const entries = await readdir(pluginsRoot, { withFileTypes: true });
17
+ const packages = new Map();
18
+ for (const entry of entries) {
19
+ if (!entry.isDirectory() || entry.name === "plugin-sdk") {
20
+ continue;
21
+ }
22
+ const packageDir = resolve(pluginsRoot, entry.name);
23
+ const packageJsonPath = resolve(packageDir, "package.json");
24
+ const manifestPath = resolve(packageDir, "dobby.manifest.json");
25
+ try {
26
+ await access(packageJsonPath);
27
+ await access(manifestPath);
28
+ const raw = await readFile(packageJsonPath, "utf-8");
29
+ const parsed = JSON.parse(raw);
30
+ if (typeof parsed.name !== "string" || parsed.name.trim().length === 0) {
31
+ continue;
32
+ }
33
+ packages.set(parsed.name, {
34
+ packageName: parsed.name,
35
+ packageDir,
36
+ });
37
+ }
38
+ catch {
39
+ continue;
40
+ }
41
+ }
42
+ return packages;
43
+ }
44
+ async function assertLocalExtensionBuildReady(localPackage) {
45
+ const manifestPath = resolve(localPackage.packageDir, "dobby.manifest.json");
46
+ const rawManifest = await readFile(manifestPath, "utf-8");
47
+ const parsed = JSON.parse(rawManifest);
48
+ for (const contribution of parsed.contributions ?? []) {
49
+ if (typeof contribution.entry !== "string" || contribution.entry.trim().length === 0) {
50
+ continue;
51
+ }
52
+ const entryPath = resolve(localPackage.packageDir, contribution.entry);
53
+ try {
54
+ await access(entryPath);
55
+ }
56
+ catch {
57
+ const contributionId = typeof contribution.id === "string" ? contribution.id : "unknown";
58
+ throw new Error(`Local extension '${localPackage.packageName}' is not built for contribution '${contributionId}'. `
59
+ + `Missing '${entryPath}'. Run 'npm run build --prefix ${localPackage.packageDir}' first.`);
60
+ }
61
+ }
62
+ }
63
+ export async function resolveExtensionInstallSpecs(packageSpecs, cwd = process.cwd()) {
64
+ const repoRoot = findDobbyRepoRoot(cwd);
65
+ if (!repoRoot) {
66
+ return packageSpecs;
67
+ }
68
+ const repoPackages = await listRepoLocalExtensionPackages(repoRoot);
69
+ const resolvedSpecs = [];
70
+ for (const rawSpec of packageSpecs) {
71
+ const packageSpec = rawSpec.trim();
72
+ if (packageSpec.length === 0 || isExplicitInstallSpec(packageSpec)) {
73
+ resolvedSpecs.push(packageSpec);
74
+ continue;
75
+ }
76
+ const localPackage = repoPackages.get(packageSpec);
77
+ if (!localPackage) {
78
+ resolvedSpecs.push(packageSpec);
79
+ continue;
80
+ }
81
+ await assertLocalExtensionBuildReady(localPackage);
82
+ resolvedSpecs.push(`file:${localPackage.packageDir}`);
83
+ }
84
+ return resolvedSpecs;
85
+ }
@@ -1,4 +1,4 @@
1
- import { cancel, confirm, isCancel, multiselect, note, select, text, } from "@clack/prompts";
1
+ import { cancel, confirm, isCancel, multiselect, note, password, select, text, } from "@clack/prompts";
2
2
  import JSON5 from "json5";
3
3
  function isRecord(value) {
4
4
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -61,11 +61,14 @@ function shouldPromptInMinimalMode(field) {
61
61
  if (!field.hasDefault && field.required) {
62
62
  return true;
63
63
  }
64
- if (!field.hasDefault && field.existingValue === undefined) {
64
+ if (field.existingValue !== undefined) {
65
65
  return true;
66
66
  }
67
67
  return false;
68
68
  }
69
+ function isSensitiveStringField(key) {
70
+ return /(token|secret|api[-_]?key)$/i.test(key);
71
+ }
69
72
  async function promptNumberField(params) {
70
73
  while (true) {
71
74
  const result = await text({
@@ -232,6 +235,27 @@ async function promptFieldValue(params) {
232
235
  existingValue,
233
236
  });
234
237
  }
238
+ if (isSensitiveStringField(key)) {
239
+ while (true) {
240
+ const result = await password({
241
+ message,
242
+ mask: "*",
243
+ });
244
+ if (isCancel(result)) {
245
+ cancel("Configuration cancelled.");
246
+ throw new Error("Configuration cancelled.");
247
+ }
248
+ const raw = String(result ?? "").trim();
249
+ if (raw.length === 0) {
250
+ if (required && existingValue === undefined) {
251
+ await note("This field is required.", "Validation");
252
+ continue;
253
+ }
254
+ return existingValue;
255
+ }
256
+ return raw;
257
+ }
258
+ }
235
259
  while (true) {
236
260
  const result = await text({
237
261
  message,
@@ -17,8 +17,8 @@ test("resolveConfigPath detects local dobby repository config path", async () =>
17
17
  await mkdir(resolve(repoRoot, "config"), { recursive: true });
18
18
  await mkdir(resolve(repoRoot, "scripts"), { recursive: true });
19
19
  await mkdir(resolve(repoRoot, "src", "cli"), { recursive: true });
20
- await writeFile(resolve(repoRoot, "package.json"), JSON.stringify({ name: "dobby" }), "utf-8");
21
- await writeFile(resolve(repoRoot, "config", "gateway.json"), "{}", "utf-8");
20
+ await writeFile(resolve(repoRoot, "package.json"), JSON.stringify({ name: "@dobby.ai/dobby" }), "utf-8");
21
+ await writeFile(resolve(repoRoot, "config", "gateway.example.json"), "{}\n", "utf-8");
22
22
  await writeFile(resolve(repoRoot, "scripts", "local-extensions.mjs"), "#!/usr/bin/env node\n", "utf-8");
23
23
  assert.equal(resolveConfigPath({
24
24
  cwd: resolve(repoRoot, "src", "cli"),
@@ -29,8 +29,8 @@ test("resolveConfigPath prioritizes DOBBY_CONFIG_PATH over repository detection"
29
29
  const repoRoot = await mkdtemp(resolve(tmpdir(), "dobby-config-path-env-priority-"));
30
30
  await mkdir(resolve(repoRoot, "config"), { recursive: true });
31
31
  await mkdir(resolve(repoRoot, "scripts"), { recursive: true });
32
- await writeFile(resolve(repoRoot, "package.json"), JSON.stringify({ name: "dobby" }), "utf-8");
33
- await writeFile(resolve(repoRoot, "config", "gateway.json"), "{}", "utf-8");
32
+ await writeFile(resolve(repoRoot, "package.json"), JSON.stringify({ name: "@dobby.ai/dobby" }), "utf-8");
33
+ await writeFile(resolve(repoRoot, "config", "gateway.example.json"), "{}\n", "utf-8");
34
34
  await writeFile(resolve(repoRoot, "scripts", "local-extensions.mjs"), "#!/usr/bin/env node\n", "utf-8");
35
35
  const customPath = resolve(tmpdir(), "dobby-custom-gateway.json");
36
36
  assert.equal(resolveConfigPath({
@@ -53,7 +53,7 @@ test("resolveDataRootDir uses repo root for repo-local config/gateway.json", asy
53
53
  const repoRoot = await mkdtemp(resolve(tmpdir(), "dobby-data-root-repo-"));
54
54
  await mkdir(resolve(repoRoot, "config"), { recursive: true });
55
55
  await mkdir(resolve(repoRoot, "scripts"), { recursive: true });
56
- await writeFile(resolve(repoRoot, "package.json"), JSON.stringify({ name: "dobby" }), "utf-8");
56
+ await writeFile(resolve(repoRoot, "package.json"), JSON.stringify({ name: "@dobby.ai/dobby" }), "utf-8");
57
57
  await writeFile(resolve(repoRoot, "config", "gateway.json"), "{}", "utf-8");
58
58
  await writeFile(resolve(repoRoot, "scripts", "local-extensions.mjs"), "#!/usr/bin/env node\n", "utf-8");
59
59
  assert.equal(resolveDataRootDir(resolve(repoRoot, "config", "gateway.json"), {