wave-agent-sdk 1.0.4 → 1.0.6

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 (194) hide show
  1. package/dist/agent.d.ts +23 -1
  2. package/dist/agent.js +34 -4
  3. package/dist/managers/aiManager.d.ts +36 -0
  4. package/dist/managers/aiManager.js +240 -8
  5. package/dist/managers/messageManager.d.ts +1 -3
  6. package/dist/managers/messageManager.js +19 -9
  7. package/dist/managers/slashCommandManager.js +28 -0
  8. package/dist/managers/subagentManager.d.ts +0 -2
  9. package/dist/services/session.js +2 -9
  10. package/dist/telemetry/instrumentation.d.ts +1 -1
  11. package/dist/telemetry/instrumentation.js +57 -28
  12. package/dist/tools/bashTool.js +16 -6
  13. package/dist/tools/grepTool.js +11 -3
  14. package/dist/types/index.d.ts +3 -0
  15. package/dist/types/messaging.d.ts +2 -0
  16. package/dist/utils/containerSetup.js +0 -3
  17. package/dist/utils/messageOperations.d.ts +10 -1
  18. package/dist/utils/messageOperations.js +19 -1
  19. package/dist/utils/notificationXml.js +5 -0
  20. package/dist/utils/ripgrep.d.ts +4 -4
  21. package/dist/utils/ripgrep.js +4 -27
  22. package/package.json +20 -8
  23. package/bin/rg +0 -79
  24. package/dist/managers/forkedAgentManager.d.ts +0 -50
  25. package/dist/managers/forkedAgentManager.js +0 -130
  26. package/scripts/install_ripgrep.js +0 -111
  27. package/scripts/postinstall.js +0 -38
  28. package/src/agent.ts +0 -1271
  29. package/src/constants/subagents.ts +0 -4
  30. package/src/constants/toolLimits.ts +0 -15
  31. package/src/constants/tools.ts +0 -24
  32. package/src/core/plugin.ts +0 -237
  33. package/src/core/session.ts +0 -9
  34. package/src/index.ts +0 -38
  35. package/src/managers/MemoryRuleManager.ts +0 -198
  36. package/src/managers/aiManager.ts +0 -2429
  37. package/src/managers/backgroundTaskManager.ts +0 -480
  38. package/src/managers/bangManager.ts +0 -119
  39. package/src/managers/cronManager.ts +0 -401
  40. package/src/managers/foregroundTaskManager.ts +0 -30
  41. package/src/managers/forkedAgentManager.ts +0 -216
  42. package/src/managers/hookManager.ts +0 -1089
  43. package/src/managers/liveConfigManager.ts +0 -445
  44. package/src/managers/lspManager.ts +0 -470
  45. package/src/managers/mcpManager.ts +0 -1010
  46. package/src/managers/messageManager.ts +0 -1180
  47. package/src/managers/messageQueue.ts +0 -157
  48. package/src/managers/permissionManager.ts +0 -1140
  49. package/src/managers/planManager.ts +0 -112
  50. package/src/managers/pluginManager.ts +0 -330
  51. package/src/managers/pluginScopeManager.ts +0 -124
  52. package/src/managers/reversionManager.ts +0 -178
  53. package/src/managers/skillManager.ts +0 -578
  54. package/src/managers/slashCommandManager.ts +0 -564
  55. package/src/managers/subagentManager.ts +0 -915
  56. package/src/managers/toolManager.ts +0 -489
  57. package/src/managers/workflowManager.ts +0 -493
  58. package/src/prompts/autoMemory.ts +0 -33
  59. package/src/prompts/autoMemoryExtraction.ts +0 -146
  60. package/src/prompts/index.ts +0 -506
  61. package/src/prompts/planModeReminders.ts +0 -126
  62. package/src/services/GitService.ts +0 -131
  63. package/src/services/MarketplaceService.ts +0 -1052
  64. package/src/services/MemoryRuleService.ts +0 -71
  65. package/src/services/aiService.ts +0 -919
  66. package/src/services/authService.ts +0 -565
  67. package/src/services/autoMemoryService.ts +0 -280
  68. package/src/services/configurationService.ts +0 -1520
  69. package/src/services/fileWatcher.ts +0 -319
  70. package/src/services/hook.ts +0 -348
  71. package/src/services/initializationService.ts +0 -357
  72. package/src/services/interactionService.ts +0 -229
  73. package/src/services/jsonlHandler.ts +0 -295
  74. package/src/services/memory.ts +0 -234
  75. package/src/services/pluginLoader.ts +0 -252
  76. package/src/services/remoteSettingsService.ts +0 -366
  77. package/src/services/reversionService.ts +0 -122
  78. package/src/services/session.ts +0 -922
  79. package/src/services/taskManager.ts +0 -321
  80. package/src/telemetry/events.ts +0 -65
  81. package/src/telemetry/instrumentation.ts +0 -499
  82. package/src/telemetry/sessionTracing.ts +0 -348
  83. package/src/tools/agentTool.ts +0 -322
  84. package/src/tools/askUserQuestion.ts +0 -160
  85. package/src/tools/bashTool.ts +0 -621
  86. package/src/tools/buildTool.ts +0 -61
  87. package/src/tools/cronCreateTool.ts +0 -161
  88. package/src/tools/cronDeleteTool.ts +0 -51
  89. package/src/tools/cronListTool.ts +0 -47
  90. package/src/tools/editTool.ts +0 -313
  91. package/src/tools/enterPlanMode.ts +0 -124
  92. package/src/tools/enterWorktreeTool.ts +0 -190
  93. package/src/tools/exitPlanMode.ts +0 -139
  94. package/src/tools/exitWorktreeTool.ts +0 -236
  95. package/src/tools/globTool.ts +0 -167
  96. package/src/tools/grepTool.ts +0 -399
  97. package/src/tools/lspTool.ts +0 -883
  98. package/src/tools/readTool.ts +0 -426
  99. package/src/tools/skillTool.ts +0 -248
  100. package/src/tools/taskManagementTools.ts +0 -574
  101. package/src/tools/taskStopTool.ts +0 -78
  102. package/src/tools/types.ts +0 -137
  103. package/src/tools/webFetchTool.ts +0 -373
  104. package/src/tools/workflowTool.ts +0 -205
  105. package/src/tools/writeTool.ts +0 -267
  106. package/src/types/agent.ts +0 -128
  107. package/src/types/auth.ts +0 -19
  108. package/src/types/commands.ts +0 -30
  109. package/src/types/config.ts +0 -43
  110. package/src/types/configuration.ts +0 -168
  111. package/src/types/core.ts +0 -105
  112. package/src/types/cron.ts +0 -13
  113. package/src/types/environment.ts +0 -106
  114. package/src/types/fileSearch.ts +0 -4
  115. package/src/types/history.ts +0 -7
  116. package/src/types/hooks.ts +0 -256
  117. package/src/types/index.ts +0 -41
  118. package/src/types/lsp.ts +0 -98
  119. package/src/types/marketplace.ts +0 -71
  120. package/src/types/mcp.ts +0 -43
  121. package/src/types/memoryRule.ts +0 -31
  122. package/src/types/messaging.ts +0 -114
  123. package/src/types/permissions.ts +0 -80
  124. package/src/types/plugins.ts +0 -39
  125. package/src/types/processes.ts +0 -99
  126. package/src/types/reversion.ts +0 -29
  127. package/src/types/session.ts +0 -12
  128. package/src/types/skills.ts +0 -108
  129. package/src/types/tasks.ts +0 -13
  130. package/src/types/telemetry.ts +0 -98
  131. package/src/types/tools.ts +0 -44
  132. package/src/types/workflow.ts +0 -6
  133. package/src/utils/abortUtils.ts +0 -118
  134. package/src/utils/atomicWrite.ts +0 -61
  135. package/src/utils/bashParser.ts +0 -899
  136. package/src/utils/cacheControlUtils.ts +0 -426
  137. package/src/utils/commandArgumentParser.ts +0 -161
  138. package/src/utils/commandPathResolver.ts +0 -88
  139. package/src/utils/configPaths.ts +0 -220
  140. package/src/utils/configValidator.ts +0 -100
  141. package/src/utils/constants.ts +0 -39
  142. package/src/utils/container.ts +0 -92
  143. package/src/utils/containerSetup.ts +0 -371
  144. package/src/utils/convertMessagesForAPI.ts +0 -326
  145. package/src/utils/cronTasks.ts +0 -128
  146. package/src/utils/cronTasksLock.ts +0 -182
  147. package/src/utils/cronToHuman.ts +0 -99
  148. package/src/utils/customCommands.ts +0 -95
  149. package/src/utils/editUtils.ts +0 -20
  150. package/src/utils/fileFormat.ts +0 -40
  151. package/src/utils/fileSearch.ts +0 -151
  152. package/src/utils/fileUtils.ts +0 -223
  153. package/src/utils/gitUtils.ts +0 -315
  154. package/src/utils/globalLogger.ts +0 -128
  155. package/src/utils/groupMessagesByApiRound.ts +0 -121
  156. package/src/utils/hookMatcher.ts +0 -176
  157. package/src/utils/markdownParser.ts +0 -317
  158. package/src/utils/mcpUtils.ts +0 -138
  159. package/src/utils/messageOperations.ts +0 -641
  160. package/src/utils/modelCapabilities.ts +0 -30
  161. package/src/utils/nameGenerator.ts +0 -99
  162. package/src/utils/notificationXml.ts +0 -57
  163. package/src/utils/openaiClient.ts +0 -251
  164. package/src/utils/parseCronExpression.ts +0 -78
  165. package/src/utils/path.ts +0 -72
  166. package/src/utils/pathEncoder.ts +0 -400
  167. package/src/utils/pathSafety.ts +0 -40
  168. package/src/utils/promptHistory.ts +0 -168
  169. package/src/utils/ripgrep.ts +0 -35
  170. package/src/utils/shellResolver.ts +0 -190
  171. package/src/utils/skillParser.ts +0 -246
  172. package/src/utils/stringUtils.ts +0 -156
  173. package/src/utils/subagentParser.ts +0 -279
  174. package/src/utils/taskReminder.ts +0 -96
  175. package/src/utils/tokenCalculation.ts +0 -43
  176. package/src/utils/tokenEstimate.ts +0 -34
  177. package/src/utils/toolResultStorage.ts +0 -117
  178. package/src/utils/worktreeSession.ts +0 -26
  179. package/src/utils/worktreeUtils.ts +0 -786
  180. package/src/workflow/budgetTracker.ts +0 -34
  181. package/src/workflow/concurrencyLimiter.ts +0 -47
  182. package/src/workflow/journal.ts +0 -95
  183. package/src/workflow/progressReporter.ts +0 -141
  184. package/src/workflow/runState.ts +0 -65
  185. package/src/workflow/scriptRuntime.ts +0 -274
  186. package/src/workflow/structuredOutput.ts +0 -123
  187. package/src/workflow/types.ts +0 -101
  188. package/src/workflow/workflowApis.ts +0 -410
  189. package/vendor/ripgrep/linux-aarch64/rg +0 -0
  190. package/vendor/ripgrep/linux-x86_64/rg +0 -0
  191. package/vendor/ripgrep/macos-aarch64/rg +0 -0
  192. package/vendor/ripgrep/macos-x86_64/rg +0 -0
  193. package/vendor/ripgrep/windows-aarch64/rg.exe +0 -0
  194. package/vendor/ripgrep/windows-x86_64/rg.exe +0 -0
@@ -1,1520 +0,0 @@
1
- /**
2
- * Configuration Service
3
- *
4
- * Centralized service for loading, validating, and managing Wave configuration files.
5
- * Replaces distributed configuration logic previously embedded in hook.ts.
6
- */
7
-
8
- import { readFileSync, existsSync, promises as fs } from "fs";
9
- import * as path from "path";
10
- import * as os from "os";
11
- import { isValidHookEvent } from "../types/hooks.js";
12
- import { logger } from "../utils/globalLogger.js";
13
- import type {
14
- ConfigurationLoadResult,
15
- ValidationResult,
16
- ConfigurationPaths,
17
- WaveConfiguration,
18
- Scope,
19
- MarketplaceConfig,
20
- } from "../types/configuration.js";
21
- import {
22
- getAllConfigPaths,
23
- getExistingConfigPaths,
24
- getUserConfigPaths,
25
- getProjectConfigPaths,
26
- } from "../utils/configPaths.js";
27
- import {
28
- type EnvironmentValidationResult,
29
- type MergedEnvironmentContext,
30
- type EnvironmentMergeOptions,
31
- isValidEnvironmentVars,
32
- } from "../types/environment.js";
33
- import {
34
- GatewayConfig,
35
- ModelConfig,
36
- PermissionMode,
37
- AgentOptions,
38
- } from "../types/index.js";
39
- import {
40
- DEFAULT_WAVE_MAX_INPUT_TOKENS,
41
- DEFAULT_WAVE_MAX_OUTPUT_TOKENS,
42
- DEFAULT_SERVER_URL,
43
- } from "../utils/constants.js";
44
- import { ClientOptions } from "openai";
45
- import { parseCustomHeaders } from "../utils/stringUtils.js";
46
- import {
47
- getRemoteSettingsSync,
48
- mergeRemoteSettings,
49
- } from "./remoteSettingsService.js";
50
- import { createAuthAwareFetch } from "./authService.js";
51
- import { ensureWaveRuntimeFilesExcluded } from "../utils/gitUtils.js";
52
- import { atomicWriteFile } from "../utils/atomicWrite.js";
53
-
54
- /**
55
- * Default ConfigurationService implementation
56
- *
57
- * Provides centralized configuration loading, validation, and management.
58
- * Extracted from distributed logic in hook.ts with improved error handling.
59
- */
60
- export class ConfigurationService {
61
- private currentConfiguration: WaveConfiguration | null = null;
62
- private options: AgentOptions = {};
63
- private _configuredEnvKeys = new Set<string>();
64
- // Per-session environment snapshot: settings.json `env` is stored here (NOT
65
- // written to process.env) so multiple sessions in one `wave --stdio` process
66
- // don't cross-pollute. Resolve methods read `this.envSnapshot ?? process.env`.
67
- private envSnapshot: Record<string, string> = {};
68
-
69
- /**
70
- * Set agent options for configuration resolution
71
- */
72
- setOptions(options: AgentOptions): void {
73
- this.options = options;
74
- }
75
-
76
- /**
77
- * Returns a copy of the per-session environment snapshot (settings.json `env`).
78
- * Priority over OS env; does NOT include OS env. For subprocess spawning use
79
- * {@link getMergedEnv} instead.
80
- */
81
- getEnvSnapshot(): Record<string, string> {
82
- return { ...this.envSnapshot };
83
- }
84
-
85
- /**
86
- * Returns OS env merged with the session snapshot (snapshot wins). Use this
87
- * when spawning user-facing subprocesses (bash, hooks, bang, background, MCP)
88
- * so they inherit both OS env and the session's settings env.
89
- */
90
- getMergedEnv(): Record<string, string> {
91
- return Object.fromEntries(
92
- Object.entries({ ...process.env, ...this.envSnapshot }).filter(
93
- ([, v]) => v !== undefined,
94
- ),
95
- ) as Record<string, string>;
96
- }
97
-
98
- // Core loading operations
99
-
100
- /**
101
- * Load and merge configuration with comprehensive validation
102
- */
103
- async loadMergedConfiguration(
104
- workdir: string,
105
- ): Promise<ConfigurationLoadResult> {
106
- try {
107
- // Use the merged configuration function (this loads user and project configs internally)
108
- const mergedConfig = loadMergedWaveConfig(workdir);
109
-
110
- if (!mergedConfig) {
111
- // Still set system environment variables even if no config is found
112
- const env = { WAVE_PROJECT_DIR: workdir };
113
- this.setEnvironmentVars(env);
114
-
115
- return {
116
- configuration: { env },
117
- success: true, // No config is valid
118
- warnings: [
119
- "No configuration files found in user or project directories",
120
- ],
121
- };
122
- }
123
-
124
- // Comprehensive validation
125
- const validation = this.validateConfiguration(mergedConfig);
126
-
127
- if (!validation.isValid) {
128
- return {
129
- configuration: null,
130
- success: false,
131
- error: `Merged configuration validation failed: ${validation.errors.join(", ")}`,
132
- warnings: validation.warnings,
133
- };
134
- }
135
-
136
- // Merge remote settings (highest priority: Remote > Local > Project > User)
137
- const remoteSettings = getRemoteSettingsSync();
138
- const finalConfig = remoteSettings
139
- ? mergeRemoteSettings(mergedConfig, remoteSettings)
140
- : mergedConfig;
141
-
142
- // Success case
143
- this.currentConfiguration = finalConfig;
144
-
145
- // Set environment variables from merged config and inject system variables
146
- const env = {
147
- ...(finalConfig.env || {}),
148
- WAVE_PROJECT_DIR: workdir,
149
- };
150
- this.setEnvironmentVars(env);
151
- finalConfig.env = env;
152
-
153
- return {
154
- configuration: finalConfig,
155
- success: true,
156
- sourcePath: "merged configuration",
157
- warnings: validation.warnings,
158
- };
159
- } catch (error) {
160
- return {
161
- configuration: null,
162
- success: false,
163
- error: `Failed to load merged configuration from ${workdir}: ${(error as Error).message}`,
164
- warnings: [],
165
- };
166
- }
167
- }
168
-
169
- // Validation operations
170
-
171
- /**
172
- * Validate configuration object structure and values
173
- */
174
- validateConfiguration(config: WaveConfiguration): ValidationResult {
175
- const result: ValidationResult = {
176
- isValid: true,
177
- errors: [],
178
- warnings: [],
179
- };
180
-
181
- // Validate basic structure
182
- if (!config || typeof config !== "object") {
183
- result.isValid = false;
184
- result.errors.push("Configuration must be a valid object");
185
- return result;
186
- }
187
-
188
- // Validate hooks if present
189
- if (config.hooks !== undefined) {
190
- if (typeof config.hooks !== "object" || config.hooks === null) {
191
- result.isValid = false;
192
- result.errors.push("Hooks configuration must be an object");
193
- } else {
194
- for (const [event, eventConfigs] of Object.entries(config.hooks)) {
195
- if (!isValidHookEvent(event)) {
196
- result.warnings.push(`Unknown hook event: ${event}`);
197
- continue;
198
- }
199
-
200
- if (!Array.isArray(eventConfigs)) {
201
- result.isValid = false;
202
- result.errors.push(`Hook event '${event}' must be an array`);
203
- continue;
204
- }
205
-
206
- // Validate individual hook configurations
207
- for (let i = 0; i < eventConfigs.length; i++) {
208
- const hookConfig = eventConfigs[i];
209
- if (!hookConfig || typeof hookConfig !== "object") {
210
- result.isValid = false;
211
- result.errors.push(
212
- `Hook configuration ${i} for event '${event}' must be an object`,
213
- );
214
- }
215
- }
216
- }
217
- }
218
- }
219
-
220
- // Validate enabledPlugins if present
221
- if (config.enabledPlugins !== undefined) {
222
- if (
223
- typeof config.enabledPlugins !== "object" ||
224
- config.enabledPlugins === null
225
- ) {
226
- result.isValid = false;
227
- result.errors.push("enabledPlugins configuration must be an object");
228
- } else {
229
- for (const [pluginId, enabled] of Object.entries(
230
- config.enabledPlugins,
231
- )) {
232
- if (typeof enabled !== "boolean") {
233
- result.isValid = false;
234
- result.errors.push(
235
- `Value for plugin '${pluginId}' in enabledPlugins must be a boolean`,
236
- );
237
- }
238
- if (!pluginId.includes("@")) {
239
- result.warnings.push(
240
- `Plugin ID '${pluginId}' in enabledPlugins should follow 'name@marketplace' format`,
241
- );
242
- }
243
- }
244
- }
245
- }
246
-
247
- // Validate environment variables if present
248
- if (config.env !== undefined) {
249
- const envValidation = validateEnvironmentConfig(config.env);
250
- if (!envValidation.isValid) {
251
- result.isValid = false;
252
- result.errors.push(...envValidation.errors);
253
- }
254
- result.warnings.push(...envValidation.warnings);
255
- }
256
-
257
- // Validate permissions if present
258
- if (config.permissions !== undefined) {
259
- if (
260
- typeof config.permissions !== "object" ||
261
- config.permissions === null
262
- ) {
263
- result.isValid = false;
264
- result.errors.push("Permissions configuration must be an object");
265
- } else {
266
- // Validate allow if present
267
- if (config.permissions.allow !== undefined) {
268
- if (!Array.isArray(config.permissions.allow)) {
269
- result.isValid = false;
270
- result.errors.push("Permissions allow must be an array of strings");
271
- } else if (
272
- !config.permissions.allow.every((rule) => typeof rule === "string")
273
- ) {
274
- result.isValid = false;
275
- result.errors.push("All permission allow rules must be strings");
276
- }
277
- }
278
-
279
- // Validate deny if present
280
- if (config.permissions.deny !== undefined) {
281
- if (!Array.isArray(config.permissions.deny)) {
282
- result.isValid = false;
283
- result.errors.push("Permissions deny must be an array of strings");
284
- } else if (
285
- !config.permissions.deny.every((rule) => typeof rule === "string")
286
- ) {
287
- result.isValid = false;
288
- result.errors.push("All permission deny rules must be strings");
289
- }
290
- }
291
-
292
- // Validate permissionMode if present
293
- if (config.permissions.permissionMode !== undefined) {
294
- const validModes: PermissionMode[] = [
295
- "default",
296
- "bypassPermissions",
297
- "acceptEdits",
298
- "plan",
299
- "dontAsk",
300
- ];
301
- if (!validModes.includes(config.permissions.permissionMode)) {
302
- result.isValid = false;
303
- result.errors.push(
304
- `Invalid permissionMode: "${config.permissions.permissionMode}". Must be one of: ${validModes.join(", ")}`,
305
- );
306
- }
307
- }
308
- }
309
- }
310
-
311
- // Validate autoMemoryEnabled if present
312
- if (
313
- config.autoMemoryEnabled !== undefined &&
314
- typeof config.autoMemoryEnabled !== "boolean"
315
- ) {
316
- result.isValid = false;
317
- result.errors.push("autoMemoryEnabled configuration must be a boolean");
318
- }
319
-
320
- // Validate autoMemoryFrequency if present
321
- if (
322
- config.autoMemoryFrequency !== undefined &&
323
- (typeof config.autoMemoryFrequency !== "number" ||
324
- config.autoMemoryFrequency <= 0)
325
- ) {
326
- result.isValid = false;
327
- result.errors.push(
328
- "autoMemoryFrequency configuration must be a positive number",
329
- );
330
- }
331
-
332
- // Validate models if present
333
- if (config.models !== undefined) {
334
- if (typeof config.models !== "object" || config.models === null) {
335
- result.isValid = false;
336
- result.errors.push("models configuration must be an object");
337
- } else {
338
- for (const [modelName, modelConfig] of Object.entries(config.models)) {
339
- if (typeof modelConfig !== "object" || modelConfig === null) {
340
- result.isValid = false;
341
- result.errors.push(
342
- `Configuration for model '${modelName}' must be an object`,
343
- );
344
- }
345
- }
346
- }
347
- }
348
-
349
- // Validate worktree if present
350
- if (config.worktree !== undefined) {
351
- if (typeof config.worktree !== "object" || config.worktree === null) {
352
- result.isValid = false;
353
- result.errors.push("worktree configuration must be an object");
354
- } else if (
355
- config.worktree.baseRef !== undefined &&
356
- config.worktree.baseRef !== "fresh" &&
357
- config.worktree.baseRef !== "head"
358
- ) {
359
- result.isValid = false;
360
- result.errors.push(
361
- `Invalid worktree.baseRef: "${config.worktree.baseRef}". Must be "fresh" or "head".`,
362
- );
363
- }
364
- }
365
-
366
- return result;
367
- }
368
-
369
- /**
370
- * Validate configuration file without loading
371
- */
372
- validateConfigurationFile(filePath: string): ValidationResult {
373
- const result: ValidationResult = {
374
- isValid: true,
375
- errors: [],
376
- warnings: [],
377
- };
378
-
379
- if (!existsSync(filePath)) {
380
- result.isValid = false;
381
- result.errors.push(`Configuration file not found: ${filePath}`);
382
- return result;
383
- }
384
-
385
- try {
386
- const content = readFileSync(filePath, "utf-8");
387
- const config = JSON.parse(content) as WaveConfiguration;
388
-
389
- // Use the main validation method
390
- const configValidation = this.validateConfiguration(config);
391
- result.isValid = configValidation.isValid;
392
- result.errors = configValidation.errors;
393
- result.warnings = configValidation.warnings;
394
- } catch (error) {
395
- result.isValid = false;
396
- if (error instanceof SyntaxError) {
397
- result.errors.push(
398
- `Invalid JSON syntax in ${filePath}: ${error.message}`,
399
- );
400
- } else {
401
- result.errors.push(
402
- `Error reading configuration file ${filePath}: ${(error as Error).message}`,
403
- );
404
- }
405
- }
406
-
407
- return result;
408
- }
409
-
410
- // Utility operations
411
-
412
- /**
413
- * Store environment variables from configuration into the per-session
414
- * snapshot (NOT process.env). Settings `env` shadows OS env for this session
415
- * only — multiple sessions in one stdio process stay isolated.
416
- *
417
- * Exception: `WAVE_SERVER_URL` is also mirrored to `process.env` because the
418
- * process-level singletons (AuthService, remoteSettingsService background
419
- * fetch) need to read it and don't hold a per-session snapshot. Same value
420
- * across sessions ⇒ no cross-pollution. See docs/specs/core/agent-config.md.
421
- */
422
- setEnvironmentVars(env: Record<string, string>): void {
423
- for (const [key, value] of Object.entries(env)) {
424
- if (
425
- process.env[key] !== undefined &&
426
- !this._configuredEnvKeys.has(key) &&
427
- process.env[key] !== value
428
- ) {
429
- logger.warn(`Overriding environment variable: ${key}`);
430
- }
431
- this.envSnapshot[key] = value;
432
- this._configuredEnvKeys.add(key);
433
- // WAVE_SERVER_URL is consumed by process-level singletons (AuthService,
434
- // remoteSettingsService) that can't see the per-session snapshot — mirror
435
- // it to process.env so they read the settings value. Same value across
436
- // sessions ⇒ no last-session-wins cross-pollution.
437
- if (key === "WAVE_SERVER_URL") {
438
- process.env[key] = value;
439
- }
440
- }
441
- }
442
-
443
- // =============================================================================
444
- // Configuration Resolution Methods (merged from configResolver.ts)
445
- // =============================================================================
446
-
447
- /**
448
- * Read SSO token from ~/.wave/auth.json
449
- */
450
- private readSSOToken(): string | undefined {
451
- const homeDir = os.homedir();
452
- const authPath = path.join(homeDir, ".wave", "auth.json");
453
- if (!existsSync(authPath)) {
454
- return undefined;
455
- }
456
- try {
457
- const content = readFileSync(authPath, "utf-8");
458
- const authConfig = JSON.parse(content) as { SSO_TOKEN?: string };
459
- return authConfig.SSO_TOKEN;
460
- } catch {
461
- return undefined;
462
- }
463
- }
464
-
465
- /**
466
- * Resolves gateway configuration from constructor args and environment
467
- * Resolution priority: SSO_TOKEN > options > env (from settings.json) > process.env > error
468
- * @param apiKey - API key override (optional)
469
- * @param baseURL - Base URL override (optional)
470
- * @param defaultHeaders - HTTP headers override (optional)
471
- * @param fetchOptions - Fetch options override (optional)
472
- * @param fetch - Custom fetch implementation override (optional)
473
- * @returns Resolved gateway configuration
474
- * @returns Resolved model configuration (model/fastModel may be undefined if not yet configured)
475
- */
476
- resolveGatewayConfig(
477
- apiKey?: string,
478
- baseURL?: string,
479
- defaultHeaders?: Record<string, string>,
480
- fetchOptions?: ClientOptions["fetchOptions"],
481
- fetch?: ClientOptions["fetch"],
482
- ): GatewayConfig {
483
- // Check for SSO token first - if present and server URL is available, use SSO mode.
484
- // Server URL resolution: options.serverUrl > process.env.WAVE_SERVER_URL > default.
485
- // settings.json `env` WAVE_SERVER_URL is mirrored to process.env by
486
- // setEnvironmentVars (so process-level singletons AuthService / remoteSettings
487
- // can read it), then read here. See docs/specs/core/agent-config.md.
488
- const ssoToken = this.readSSOToken();
489
- const serverUrl =
490
- this.options.serverUrl ||
491
- process.env.WAVE_SERVER_URL ||
492
- DEFAULT_SERVER_URL;
493
- if (ssoToken && serverUrl) {
494
- const baseFetch = fetch ?? this.options.fetch ?? globalThis.fetch;
495
- const authAwareFetch = createAuthAwareFetch(
496
- baseFetch as typeof globalThis.fetch,
497
- ) as ClientOptions["fetch"];
498
- return {
499
- apiKey: ssoToken,
500
- baseURL: `${serverUrl}/api/v1`,
501
- defaultHeaders:
502
- Object.keys(defaultHeaders || {}).length > 0
503
- ? defaultHeaders
504
- : undefined,
505
- fetchOptions: fetchOptions ?? this.options.fetchOptions,
506
- fetch: authAwareFetch,
507
- };
508
- }
509
-
510
- // Resolve API key: override > options > env (settings.json) > process.env
511
- // Note: Explicitly provided empty strings should be treated as invalid, not fall back to env
512
- let resolvedApiKey: string | undefined;
513
- if (apiKey !== undefined) {
514
- resolvedApiKey = apiKey;
515
- } else if (this.options.apiKey !== undefined) {
516
- resolvedApiKey = this.options.apiKey;
517
- } else {
518
- resolvedApiKey =
519
- this.envSnapshot.WAVE_API_KEY ?? process.env.WAVE_API_KEY;
520
- }
521
-
522
- // Resolve base URL: override > options > env (settings.json) > process.env
523
- // Note: Explicitly provided empty strings should be treated as invalid, not fall back to env
524
- let resolvedBaseURL: string | undefined;
525
- if (baseURL !== undefined) {
526
- resolvedBaseURL = baseURL;
527
- } else if (this.options.baseURL !== undefined) {
528
- resolvedBaseURL = this.options.baseURL;
529
- } else {
530
- resolvedBaseURL =
531
- this.envSnapshot.WAVE_BASE_URL ?? process.env.WAVE_BASE_URL;
532
- }
533
-
534
- // Fallback to process.env if still not resolved (for dynamic updates in tests)
535
- if (resolvedApiKey === undefined) {
536
- resolvedApiKey =
537
- this.envSnapshot.WAVE_API_KEY ?? process.env.WAVE_API_KEY;
538
- }
539
- if (!resolvedBaseURL) {
540
- resolvedBaseURL =
541
- this.envSnapshot.WAVE_BASE_URL ?? process.env.WAVE_BASE_URL;
542
- }
543
-
544
- // Treat empty string as not provided
545
- if (resolvedBaseURL?.trim() === "") {
546
- resolvedBaseURL = undefined;
547
- }
548
-
549
- // Resolve custom headers from environment: env (settings.json) > process.env
550
- const envCustomHeaders =
551
- this.envSnapshot.WAVE_CUSTOM_HEADERS ??
552
- process.env.WAVE_CUSTOM_HEADERS ??
553
- "";
554
- const parsedEnvHeaders = parseCustomHeaders(envCustomHeaders);
555
-
556
- // Merge headers: env headers < options < override
557
- const resolvedHeaders = {
558
- ...parsedEnvHeaders,
559
- ...this.options.defaultHeaders,
560
- ...defaultHeaders,
561
- };
562
-
563
- return {
564
- apiKey: resolvedApiKey,
565
- baseURL: resolvedBaseURL,
566
- defaultHeaders:
567
- Object.keys(resolvedHeaders).length > 0 ? resolvedHeaders : undefined,
568
- fetchOptions: fetchOptions ?? this.options.fetchOptions,
569
- fetch: fetch ?? this.options.fetch,
570
- };
571
- }
572
-
573
- /**
574
- * Resolves model configuration with fallbacks
575
- * Resolution priority: override > options > env (from settings.json) > process.env > default
576
- * @param model - Agent model override (optional)
577
- * @param fastModel - Fast model override (optional)
578
- * @param maxTokens - Max output tokens override (optional)
579
- * @param permissionMode - Permission mode override (optional)
580
- * @returns Resolved model configuration with defaults
581
- */
582
- resolveModelConfig(
583
- model?: string,
584
- fastModel?: string,
585
- maxTokens?: number,
586
- permissionMode?: PermissionMode,
587
- ): ModelConfig {
588
- // Resolve agent model: override > options > currentConfiguration (settings.json model, possibly remote-merged) > process.env
589
- // Priority: user's explicit model field > admin's env.WAVE_MODEL default.
590
- // If admin wants hard enforcement, they set the `model` scalar field (overwrites local in mergeRemoteSettings).
591
- const resolvedAgentModel =
592
- model ||
593
- this.options.model ||
594
- this.currentConfiguration?.model ||
595
- (this.envSnapshot.WAVE_MODEL ?? process.env.WAVE_MODEL);
596
-
597
- // Resolve fast model: override > options > process.env (includes settings.json env)
598
- const resolvedFastModel =
599
- fastModel ||
600
- this.options.fastModel ||
601
- (this.envSnapshot.WAVE_FAST_MODEL ?? process.env.WAVE_FAST_MODEL);
602
-
603
- // Resolve max output tokens
604
- const resolvedMaxTokens = this.resolveMaxOutputTokens(maxTokens);
605
-
606
- const baseConfig: ModelConfig = {
607
- model: resolvedAgentModel,
608
- fastModel: resolvedFastModel,
609
- maxTokens: resolvedMaxTokens,
610
- permissionMode: permissionMode ?? this.options.permissionMode,
611
- };
612
-
613
- // Resolve fast model generation params from models[fastModel].options.
614
- // Set on baseConfig before the modelSpecificConfig spread so it isn't overwritten.
615
- const fastModelSource =
616
- resolvedFastModel &&
617
- this.currentConfiguration?.models?.[resolvedFastModel];
618
- if (fastModelSource && fastModelSource.options) {
619
- baseConfig.fastModelOptions = fastModelSource.options;
620
- }
621
-
622
- // Resolve fast-model disable-thinking params from models[fastModel].disableThinkingOptions
623
- const fastModelDisableThinking =
624
- fastModelSource && fastModelSource.disableThinkingOptions
625
- ? fastModelSource.disableThinkingOptions
626
- : undefined;
627
- if (fastModelDisableThinking) {
628
- baseConfig.disableThinkingOptions = fastModelDisableThinking;
629
- }
630
-
631
- // Merge model-specific settings from configuration
632
- const modelSpecificConfig =
633
- resolvedAgentModel &&
634
- this.currentConfiguration?.models?.[resolvedAgentModel];
635
-
636
- if (modelSpecificConfig) {
637
- const resolved: ModelConfig = {
638
- ...baseConfig,
639
- ...modelSpecificConfig,
640
- };
641
- // Re-apply after the spread so the agent model's own
642
- // disableThinkingOptions cannot clobber the fast-model value.
643
- if (fastModelDisableThinking) {
644
- resolved.disableThinkingOptions = fastModelDisableThinking;
645
- }
646
- return resolved;
647
- }
648
-
649
- return baseConfig;
650
- }
651
-
652
- /**
653
- * Resolves token limit with fallbacks
654
- * Resolution priority: override > options > env (from settings.json) > process.env > default
655
- * @param constructorLimit - Token limit override (optional)
656
- * @returns Resolved token limit
657
- */
658
- resolveMaxInputTokens(constructorLimit?: number): number {
659
- // If override value provided, use it
660
- if (constructorLimit !== undefined) {
661
- return constructorLimit;
662
- }
663
-
664
- // If options value provided, use it
665
- if (this.options.maxInputTokens !== undefined) {
666
- return this.options.maxInputTokens;
667
- }
668
-
669
- // Try env (settings.json snapshot) first, then process.env
670
- const envMaxInputTokens =
671
- this.envSnapshot.WAVE_MAX_INPUT_TOKENS ??
672
- process.env.WAVE_MAX_INPUT_TOKENS;
673
- if (envMaxInputTokens) {
674
- const parsed = parseInt(envMaxInputTokens, 10);
675
- if (!isNaN(parsed)) {
676
- return parsed;
677
- }
678
- }
679
-
680
- // Use default
681
- return DEFAULT_WAVE_MAX_INPUT_TOKENS;
682
- }
683
-
684
- /**
685
- * Resolves preferred language with fallbacks
686
- * Resolution priority: override > options > settings.json > undefined
687
- * @param constructorLanguage - Language override (optional)
688
- * @returns Resolved language or undefined
689
- */
690
- resolveLanguage(constructorLanguage?: string): string | undefined {
691
- // 1. Override (highest priority)
692
- if (constructorLanguage !== undefined) {
693
- return constructorLanguage;
694
- }
695
-
696
- // 2. Agent options
697
- if (this.options.language !== undefined) {
698
- return this.options.language;
699
- }
700
-
701
- // 2. settings.json (merged)
702
- if (this.currentConfiguration?.language) {
703
- return this.currentConfiguration.language;
704
- }
705
-
706
- return undefined;
707
- }
708
-
709
- /**
710
- * Resolves auto-memory enabled state with fallbacks
711
- * Resolution priority: settings.json > WAVE_DISABLE_AUTO_MEMORY > default (true)
712
- * @returns Resolved auto-memory enabled state
713
- */
714
- resolveAutoMemoryEnabled(): boolean {
715
- // 1. settings.json (merged)
716
- if (this.currentConfiguration?.autoMemoryEnabled !== undefined) {
717
- return this.currentConfiguration.autoMemoryEnabled;
718
- }
719
-
720
- // 2. WAVE_DISABLE_AUTO_MEMORY environment variable (settings snapshot > OS env)
721
- const disableAutoMemory =
722
- this.envSnapshot.WAVE_DISABLE_AUTO_MEMORY ??
723
- process.env.WAVE_DISABLE_AUTO_MEMORY;
724
- if (disableAutoMemory === "1" || disableAutoMemory === "true") {
725
- return false;
726
- }
727
-
728
- // 3. Default (true)
729
- return true;
730
- }
731
-
732
- /**
733
- * Resolves worktree base ref with fallbacks
734
- * Resolution priority: settings.json > default ("fresh")
735
- * @returns Resolved worktree base ref
736
- */
737
- resolveWorktreeBaseRef(): "fresh" | "head" {
738
- const baseRef = this.currentConfiguration?.worktree?.baseRef;
739
- if (baseRef === "head") {
740
- return "head";
741
- }
742
- return "fresh";
743
- }
744
-
745
- /**
746
- * Resolves auto-memory extraction frequency with fallbacks
747
- * Resolution priority: settings.json > WAVE_AUTO_MEMORY_FREQUENCY > default (1)
748
- * @returns Resolved auto-memory extraction frequency (turns)
749
- */
750
- resolveAutoMemoryFrequency(): number {
751
- // 1. settings.json (merged)
752
- if (this.currentConfiguration?.autoMemoryFrequency !== undefined) {
753
- return this.currentConfiguration.autoMemoryFrequency;
754
- }
755
-
756
- // 2. WAVE_AUTO_MEMORY_FREQUENCY environment variable (settings snapshot > OS env)
757
- const envFrequency =
758
- this.envSnapshot.WAVE_AUTO_MEMORY_FREQUENCY ??
759
- process.env.WAVE_AUTO_MEMORY_FREQUENCY;
760
- if (envFrequency) {
761
- const parsed = parseInt(envFrequency, 10);
762
- if (!isNaN(parsed) && parsed > 0) {
763
- return parsed;
764
- }
765
- }
766
-
767
- // 3. Default (1)
768
- return 1;
769
- }
770
-
771
- /**
772
- * Resolves max output tokens with fallbacks
773
- * Resolution priority: override > options > env (from settings.json) > process.env > default
774
- * @param constructorLimit - Max output tokens override (optional)
775
- * @returns Resolved max output tokens
776
- */
777
- resolveMaxOutputTokens(constructorLimit?: number): number {
778
- // If override value provided, use it
779
- if (constructorLimit !== undefined) {
780
- return constructorLimit;
781
- }
782
-
783
- // If options value provided, use it
784
- if (this.options.maxTokens !== undefined) {
785
- return this.options.maxTokens;
786
- }
787
-
788
- // Try env (settings.json snapshot) first, then process.env
789
- const envMaxOutputTokens =
790
- this.envSnapshot.WAVE_MAX_OUTPUT_TOKENS ??
791
- process.env.WAVE_MAX_OUTPUT_TOKENS;
792
- if (envMaxOutputTokens) {
793
- const parsed = parseInt(envMaxOutputTokens, 10);
794
- if (!isNaN(parsed) && parsed > 0) {
795
- return parsed;
796
- }
797
- }
798
-
799
- // Use default
800
- return DEFAULT_WAVE_MAX_OUTPUT_TOKENS;
801
- }
802
-
803
- /**
804
- * Set the active model in the session
805
- */
806
- setModel(model: string): void {
807
- this.options.model = model;
808
- this.persistModelToSettings(model);
809
- }
810
-
811
- private async persistModelToSettings(model: string): Promise<void> {
812
- const configPath = getUserConfigPaths()[0]; // ~/.wave/settings.json
813
- const configDir = path.dirname(configPath);
814
- if (!existsSync(configDir)) {
815
- await fs.mkdir(configDir, { recursive: true });
816
- }
817
-
818
- let config: WaveConfiguration = {};
819
- if (existsSync(configPath)) {
820
- try {
821
- const content = await fs.readFile(configPath, "utf-8");
822
- config = JSON.parse(content);
823
- } catch {
824
- // Start fresh if corrupted
825
- }
826
- }
827
-
828
- config.model = model;
829
- await atomicWriteFile(configPath, JSON.stringify(config, null, 2));
830
- }
831
-
832
- /**
833
- * Get all configured models from settings.json and environment
834
- */
835
- getConfiguredModels(): string[] {
836
- const models = new Set<string>();
837
-
838
- // Add current model from options or environment (settings snapshot > OS env)
839
- const currentModel =
840
- this.options.model ||
841
- (this.envSnapshot.WAVE_MODEL ?? process.env.WAVE_MODEL);
842
- if (currentModel) {
843
- models.add(currentModel);
844
- }
845
-
846
- // Persisted model from settings (includes remote-merged)
847
- if (this.currentConfiguration?.model) {
848
- models.add(this.currentConfiguration.model);
849
- }
850
-
851
- // Add models from merged configuration
852
- if (this.currentConfiguration?.models) {
853
- Object.keys(this.currentConfiguration.models).forEach((model) => {
854
- models.add(model);
855
- });
856
- }
857
-
858
- return Array.from(models);
859
- }
860
-
861
- /**
862
- * Get the telemetry config from the loaded settings.
863
- */
864
- resolveTelemetryConfig():
865
- | Partial<import("../types/telemetry.js").TelemetryConfig>
866
- | undefined {
867
- return this.currentConfiguration?.monitoring?.telemetry;
868
- }
869
-
870
- /**
871
- * Resolve all configuration file paths
872
- */
873
- getConfigurationPaths(workdir: string): ConfigurationPaths {
874
- const allPaths = getAllConfigPaths(workdir);
875
- const existingPaths = getExistingConfigPaths(workdir);
876
-
877
- return {
878
- userPaths: allPaths.userPaths,
879
- projectPaths: allPaths.projectPaths,
880
- builtinPaths: allPaths.builtinPaths,
881
- allPaths: allPaths.allPaths,
882
- existingPaths: existingPaths.existingPaths,
883
- };
884
- }
885
-
886
- /**
887
- * Add a permission rule to the local settings.local.json
888
- */
889
- async addAllowedRule(workdir: string, rule: string): Promise<void> {
890
- const localConfigPath = path.join(workdir, ".wave", "settings.local.json");
891
-
892
- // Ensure .wave directory exists
893
- const waveDir = path.join(workdir, ".wave");
894
- ensureWaveRuntimeFilesExcluded(workdir);
895
- if (!existsSync(waveDir)) {
896
- await fs.mkdir(waveDir, { recursive: true });
897
- }
898
-
899
- let config: WaveConfiguration = {};
900
- if (existsSync(localConfigPath)) {
901
- try {
902
- const content = await fs.readFile(localConfigPath, "utf-8");
903
- config = JSON.parse(content);
904
- } catch {
905
- // If file is corrupted, start with empty config
906
- }
907
- }
908
-
909
- if (!config.permissions) {
910
- config.permissions = {};
911
- }
912
- if (!config.permissions.allow) {
913
- config.permissions.allow = [];
914
- }
915
-
916
- if (!config.permissions.allow.includes(rule)) {
917
- config.permissions.allow.push(rule);
918
- await atomicWriteFile(localConfigPath, JSON.stringify(config, null, 2));
919
- }
920
- }
921
-
922
- /**
923
- * Add an additional directory to the local settings.local.json
924
- * `permissions.additionalDirectories`, so it persists across sessions.
925
- */
926
- async addAdditionalDirectory(
927
- workdir: string,
928
- directory: string,
929
- ): Promise<void> {
930
- const localConfigPath = path.join(workdir, ".wave", "settings.local.json");
931
-
932
- // Ensure .wave directory exists
933
- const waveDir = path.join(workdir, ".wave");
934
- ensureWaveRuntimeFilesExcluded(workdir);
935
- if (!existsSync(waveDir)) {
936
- await fs.mkdir(waveDir, { recursive: true });
937
- }
938
-
939
- let config: WaveConfiguration = {};
940
- if (existsSync(localConfigPath)) {
941
- try {
942
- const content = await fs.readFile(localConfigPath, "utf-8");
943
- config = JSON.parse(content);
944
- } catch {
945
- // If file is corrupted, start with empty config
946
- }
947
- }
948
-
949
- if (!config.permissions) {
950
- config.permissions = {};
951
- }
952
- if (!config.permissions.additionalDirectories) {
953
- config.permissions.additionalDirectories = [];
954
- }
955
-
956
- if (!config.permissions.additionalDirectories.includes(directory)) {
957
- config.permissions.additionalDirectories.push(directory);
958
- await atomicWriteFile(localConfigPath, JSON.stringify(config, null, 2));
959
- }
960
- }
961
-
962
- /**
963
- * Update the enabled state of a plugin in the specified scope
964
- */
965
- async updateEnabledPlugin(
966
- workdir: string,
967
- scope: Scope,
968
- pluginId: string,
969
- enabled: boolean,
970
- ): Promise<void> {
971
- if (scope !== "user" && !existsSync(workdir)) {
972
- throw new Error(`Working directory does not exist: ${workdir}`);
973
- }
974
-
975
- let configPath: string;
976
- if (scope === "user") {
977
- configPath = getUserConfigPaths()[0]; // settings.json
978
- } else if (scope === "project") {
979
- configPath = getProjectConfigPaths(workdir)[1]; // settings.json
980
- } else {
981
- configPath = getProjectConfigPaths(workdir)[0]; // local settings.local.json
982
- }
983
-
984
- // Ensure directory exists
985
- const configDir = path.dirname(configPath);
986
- if (!existsSync(configDir)) {
987
- await fs.mkdir(configDir, { recursive: true });
988
- }
989
-
990
- let config: WaveConfiguration = {};
991
- if (existsSync(configPath)) {
992
- try {
993
- const content = await fs.readFile(configPath, "utf-8");
994
- config = JSON.parse(content);
995
- } catch {
996
- // Start with empty config if file is corrupted
997
- }
998
- }
999
-
1000
- if (!config.enabledPlugins) {
1001
- config.enabledPlugins = {};
1002
- }
1003
-
1004
- config.enabledPlugins[pluginId] = enabled;
1005
-
1006
- await atomicWriteFile(configPath, JSON.stringify(config, null, 2));
1007
- }
1008
-
1009
- /**
1010
- * Get merged marketplaces from all scopes
1011
- */
1012
- getMergedMarketplaces(workdir: string): Record<string, MarketplaceConfig> {
1013
- const mergedConfig = loadMergedWaveConfig(workdir);
1014
- return mergedConfig?.marketplaces || {};
1015
- }
1016
-
1017
- /**
1018
- * Get marketplaces at a specific scope
1019
- */
1020
- getScopedMarketplaces(
1021
- workdir: string,
1022
- scope: Scope,
1023
- ): Record<string, MarketplaceConfig> {
1024
- let configPath: string;
1025
- if (scope === "user") {
1026
- configPath = getUserConfigPaths()[0];
1027
- } else if (scope === "project") {
1028
- configPath = getProjectConfigPaths(workdir)[1];
1029
- } else {
1030
- configPath = getProjectConfigPaths(workdir)[0];
1031
- }
1032
- const config = loadWaveConfigFromFile(configPath);
1033
- return config?.marketplaces || {};
1034
- }
1035
-
1036
- /**
1037
- * Add a marketplace to the specified scope
1038
- */
1039
- async addMarketplaceToScope(
1040
- workdir: string,
1041
- scope: Scope,
1042
- name: string,
1043
- config: MarketplaceConfig,
1044
- ): Promise<void> {
1045
- if (scope !== "user" && !existsSync(workdir)) {
1046
- throw new Error(`Working directory does not exist: ${workdir}`);
1047
- }
1048
-
1049
- let configPath: string;
1050
- if (scope === "user") {
1051
- configPath = getUserConfigPaths()[0];
1052
- } else if (scope === "project") {
1053
- configPath = getProjectConfigPaths(workdir)[1];
1054
- } else {
1055
- configPath = getProjectConfigPaths(workdir)[0];
1056
- }
1057
-
1058
- const configDir = path.dirname(configPath);
1059
- if (!existsSync(configDir)) {
1060
- await fs.mkdir(configDir, { recursive: true });
1061
- }
1062
-
1063
- let fileConfig: WaveConfiguration = {};
1064
- if (existsSync(configPath)) {
1065
- try {
1066
- const content = await fs.readFile(configPath, "utf-8");
1067
- fileConfig = JSON.parse(content);
1068
- } catch {
1069
- // Start with empty config if file is corrupted
1070
- }
1071
- }
1072
-
1073
- if (!fileConfig.marketplaces) {
1074
- fileConfig.marketplaces = {};
1075
- }
1076
- fileConfig.marketplaces[name] = config;
1077
-
1078
- await atomicWriteFile(configPath, JSON.stringify(fileConfig, null, 2));
1079
- }
1080
-
1081
- /**
1082
- * Remove a marketplace from the specified scope
1083
- */
1084
- async removeMarketplaceFromScope(
1085
- workdir: string,
1086
- scope: Scope,
1087
- name: string,
1088
- ): Promise<void> {
1089
- if (scope !== "user" && !existsSync(workdir)) {
1090
- throw new Error(`Working directory does not exist: ${workdir}`);
1091
- }
1092
-
1093
- let configPath: string;
1094
- if (scope === "user") {
1095
- configPath = getUserConfigPaths()[0];
1096
- } else if (scope === "project") {
1097
- configPath = getProjectConfigPaths(workdir)[1];
1098
- } else {
1099
- configPath = getProjectConfigPaths(workdir)[0];
1100
- }
1101
-
1102
- if (!existsSync(configPath)) {
1103
- return;
1104
- }
1105
-
1106
- try {
1107
- const content = await fs.readFile(configPath, "utf-8");
1108
- const fileConfig: WaveConfiguration = JSON.parse(content);
1109
-
1110
- if (fileConfig.marketplaces && name in fileConfig.marketplaces) {
1111
- delete fileConfig.marketplaces[name];
1112
- await atomicWriteFile(configPath, JSON.stringify(fileConfig, null, 2));
1113
- }
1114
- } catch {
1115
- // Ignore errors for corrupted or non-existent files
1116
- }
1117
- }
1118
-
1119
- /**
1120
- * Remove a plugin from the enabled plugins in the specified scope
1121
- */
1122
- async removeEnabledPlugin(
1123
- workdir: string,
1124
- scope: Scope,
1125
- pluginId: string,
1126
- ): Promise<void> {
1127
- if (scope !== "user" && !existsSync(workdir)) {
1128
- throw new Error(`Working directory does not exist: ${workdir}`);
1129
- }
1130
-
1131
- let configPath: string;
1132
- if (scope === "user") {
1133
- configPath = getUserConfigPaths()[0]; // settings.json
1134
- } else if (scope === "project") {
1135
- configPath = getProjectConfigPaths(workdir)[1]; // settings.json
1136
- } else {
1137
- configPath = getProjectConfigPaths(workdir)[0]; // local settings.local.json
1138
- }
1139
-
1140
- if (!existsSync(configPath)) {
1141
- return; // Nothing to remove
1142
- }
1143
-
1144
- try {
1145
- const content = await fs.readFile(configPath, "utf-8");
1146
- const config: WaveConfiguration = JSON.parse(content);
1147
-
1148
- if (config.enabledPlugins && pluginId in config.enabledPlugins) {
1149
- delete config.enabledPlugins[pluginId];
1150
- await atomicWriteFile(configPath, JSON.stringify(config, null, 2));
1151
- }
1152
- } catch {
1153
- // Ignore errors for corrupted or non-existent files
1154
- }
1155
- }
1156
-
1157
- /**
1158
- * Get merged enabled plugins from all scopes
1159
- */
1160
- getMergedEnabledPlugins(workdir: string): Record<string, boolean> {
1161
- const mergedConfig = loadMergedWaveConfig(workdir);
1162
- return mergedConfig?.enabledPlugins || {};
1163
- }
1164
-
1165
- /**
1166
- * Load Wave configuration from a JSON file
1167
- * Supports both hooks and environment variables with proper validation
1168
- */
1169
- loadWaveConfigFromFile(filePath: string): WaveConfiguration | null {
1170
- return loadWaveConfigFromFile(filePath);
1171
- }
1172
- }
1173
- // =============================================================================
1174
- // Extracted Configuration Functions
1175
- // =============================================================================
1176
-
1177
- /**
1178
- * Validate environment variable configuration
1179
- */
1180
- export function validateEnvironmentConfig(
1181
- env: unknown,
1182
- configPath?: string,
1183
- ): EnvironmentValidationResult {
1184
- const result: EnvironmentValidationResult = {
1185
- isValid: true,
1186
- errors: [],
1187
- warnings: [],
1188
- };
1189
-
1190
- // Check if env is defined
1191
- if (env === undefined || env === null) {
1192
- return result; // undefined/null env is valid (means no env vars)
1193
- }
1194
-
1195
- // Validate that env is a Record<string, string>
1196
- if (!isValidEnvironmentVars(env)) {
1197
- result.isValid = false;
1198
- result.errors.push(
1199
- `Invalid env field format${configPath ? ` in ${configPath}` : ""}. Environment variables must be a Record<string, string>.`,
1200
- );
1201
- return result;
1202
- }
1203
-
1204
- // Additional validation for environment variable names
1205
- const envVars = env as Record<string, string>;
1206
- for (const [key, value] of Object.entries(envVars)) {
1207
- // Check for valid environment variable naming convention
1208
- if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) {
1209
- result.warnings.push(
1210
- `Environment variable '${key}' does not follow standard naming convention (alphanumeric and underscores only).`,
1211
- );
1212
- }
1213
-
1214
- // Check for empty values
1215
- if (value === "") {
1216
- result.warnings.push(`Environment variable '${key}' has an empty value.`);
1217
- }
1218
-
1219
- // Check for reserved variable names that might cause conflicts
1220
- const reservedNames = [
1221
- "PATH",
1222
- "HOME",
1223
- "USER",
1224
- "PWD",
1225
- "SHELL",
1226
- "TERM",
1227
- "NODE_ENV",
1228
- ];
1229
- if (reservedNames.includes(key.toUpperCase())) {
1230
- result.warnings.push(
1231
- `Environment variable '${key}' overrides a system variable, which may cause unexpected behavior.`,
1232
- );
1233
- }
1234
- }
1235
-
1236
- return result;
1237
- }
1238
-
1239
- /**
1240
- * Merge environment configurations with project taking precedence over user
1241
- */
1242
- export function mergeEnvironmentConfig(
1243
- userEnv: Record<string, string> | undefined,
1244
- projectEnv: Record<string, string> | undefined,
1245
- options: EnvironmentMergeOptions = {},
1246
- ): MergedEnvironmentContext {
1247
- const userVars = userEnv || {};
1248
- const projectVars = projectEnv || {};
1249
- const mergedVars: Record<string, string> = {};
1250
- const conflicts: MergedEnvironmentContext["conflicts"] = [];
1251
-
1252
- // Start with user environment variables
1253
- Object.assign(mergedVars, userVars);
1254
-
1255
- // Override with project environment variables and track conflicts
1256
- for (const [key, projectValue] of Object.entries(projectVars)) {
1257
- const userValue = userVars[key];
1258
-
1259
- if (
1260
- userValue !== undefined &&
1261
- userValue !== projectValue &&
1262
- options.includeConflictWarnings !== false
1263
- ) {
1264
- // Conflict detected - project value takes precedence
1265
- conflicts.push({
1266
- key,
1267
- userValue,
1268
- projectValue,
1269
- resolvedValue: projectValue,
1270
- });
1271
- }
1272
-
1273
- mergedVars[key] = projectValue;
1274
- }
1275
-
1276
- return {
1277
- userVars,
1278
- projectVars,
1279
- mergedVars,
1280
- conflicts,
1281
- };
1282
- }
1283
-
1284
- /**
1285
- * Load Wave configuration from a JSON file
1286
- * Supports both hooks and environment variables with proper validation
1287
- */
1288
- export function loadWaveConfigFromFile(
1289
- filePath: string,
1290
- ): WaveConfiguration | null {
1291
- if (!existsSync(filePath)) {
1292
- return null;
1293
- }
1294
-
1295
- try {
1296
- const content = readFileSync(filePath, "utf-8");
1297
-
1298
- // Tolerate empty/whitespace-only files: a concurrent writer may have the
1299
- // file truncated mid-write. Returning null lets callers skip this source
1300
- // instead of crashing (consistent with the "file not found" path).
1301
- if (content.trim() === "") {
1302
- logger.warn(`Empty configuration file (likely mid-write): ${filePath}`);
1303
- return null;
1304
- }
1305
-
1306
- const config = JSON.parse(content) as WaveConfiguration;
1307
-
1308
- return {
1309
- hooks: config.hooks || undefined,
1310
- env: config.env || undefined,
1311
- permissions: config.permissions || undefined,
1312
- enabledPlugins: config.enabledPlugins || undefined,
1313
- language: config.language || undefined,
1314
- model: config.model || undefined,
1315
- autoMemoryEnabled:
1316
- config.autoMemoryEnabled !== undefined
1317
- ? config.autoMemoryEnabled
1318
- : undefined,
1319
- models: config.models || undefined,
1320
- marketplaces: config.marketplaces || undefined,
1321
- worktree: config.worktree || undefined,
1322
- };
1323
- } catch (error) {
1324
- if (error instanceof SyntaxError) {
1325
- // Tolerate corrupt JSON: a concurrent writer may leave the file in a
1326
- // partially-written state. Warn and return null so callers skip this
1327
- // source instead of crashing.
1328
- logger.warn(`Invalid JSON syntax in ${filePath}: ${error.message}`);
1329
- return null;
1330
- }
1331
-
1332
- // Re-throw other errors as-is
1333
- throw error;
1334
- }
1335
- }
1336
-
1337
- /**
1338
- * Load and merge Wave configuration from both user and project sources
1339
- * Project configuration takes precedence over user configuration
1340
- * Checks .local.json files first, then falls back to .json files
1341
- */
1342
- export function loadMergedWaveConfig(
1343
- workdir: string,
1344
- ): WaveConfiguration | null {
1345
- const userPaths = getUserConfigPaths(); // [json]
1346
- const projectPaths = getProjectConfigPaths(workdir); // [local, json]
1347
-
1348
- // Priority order (lowest to highest):
1349
- // user settings.json -> project settings.json -> local settings.local.json
1350
- const pathsToLoad = [
1351
- userPaths[0], // user settings.json
1352
- projectPaths[1], // project settings.json
1353
- projectPaths[0], // local settings.local.json
1354
- ];
1355
-
1356
- const configs: WaveConfiguration[] = [];
1357
- for (const path of pathsToLoad) {
1358
- const config = loadWaveConfigFromFile(path);
1359
- if (config) {
1360
- configs.push(config);
1361
- }
1362
- }
1363
-
1364
- if (configs.length === 0) {
1365
- return null;
1366
- }
1367
-
1368
- // Merge all configurations in order
1369
- const mergedConfig: WaveConfiguration = {};
1370
-
1371
- for (const config of configs) {
1372
- // Merge hooks
1373
- if (config.hooks) {
1374
- if (!mergedConfig.hooks) mergedConfig.hooks = {};
1375
- for (const [event, eventConfigs] of Object.entries(config.hooks)) {
1376
- if (!isValidHookEvent(event)) continue;
1377
- if (!mergedConfig.hooks[event]) mergedConfig.hooks[event] = [];
1378
- mergedConfig.hooks[event]!.push(...eventConfigs);
1379
- }
1380
- }
1381
-
1382
- // Merge env
1383
- if (config.env) {
1384
- const environmentContext = mergeEnvironmentConfig(
1385
- mergedConfig.env,
1386
- config.env,
1387
- { includeConflictWarnings: true },
1388
- );
1389
- mergedConfig.env = environmentContext.mergedVars;
1390
- }
1391
-
1392
- // Merge permissions
1393
- if (config.permissions) {
1394
- if (!mergedConfig.permissions) mergedConfig.permissions = {};
1395
-
1396
- // Merge allow rules
1397
- if (config.permissions.allow) {
1398
- if (!mergedConfig.permissions.allow)
1399
- mergedConfig.permissions.allow = [];
1400
- mergedConfig.permissions.allow = [
1401
- ...new Set([
1402
- ...mergedConfig.permissions.allow,
1403
- ...config.permissions.allow,
1404
- ]),
1405
- ];
1406
- }
1407
-
1408
- // Merge deny rules
1409
- if (config.permissions.deny) {
1410
- if (!mergedConfig.permissions.deny) mergedConfig.permissions.deny = [];
1411
- mergedConfig.permissions.deny = [
1412
- ...new Set([
1413
- ...mergedConfig.permissions.deny,
1414
- ...config.permissions.deny,
1415
- ]),
1416
- ];
1417
- }
1418
-
1419
- // Merge permissionMode (last one wins)
1420
- if (config.permissions.permissionMode !== undefined) {
1421
- mergedConfig.permissions.permissionMode =
1422
- config.permissions.permissionMode;
1423
- }
1424
-
1425
- // Merge additionalDirectories
1426
- if (config.permissions.additionalDirectories) {
1427
- if (!mergedConfig.permissions.additionalDirectories)
1428
- mergedConfig.permissions.additionalDirectories = [];
1429
- mergedConfig.permissions.additionalDirectories = [
1430
- ...new Set([
1431
- ...mergedConfig.permissions.additionalDirectories,
1432
- ...config.permissions.additionalDirectories,
1433
- ]),
1434
- ];
1435
- }
1436
- }
1437
-
1438
- // Merge enabledPlugins
1439
- if (config.enabledPlugins) {
1440
- if (!mergedConfig.enabledPlugins) mergedConfig.enabledPlugins = {};
1441
- Object.assign(mergedConfig.enabledPlugins, config.enabledPlugins);
1442
- }
1443
-
1444
- // Merge language (last one wins)
1445
- if (config.language !== undefined) {
1446
- mergedConfig.language = config.language;
1447
- }
1448
-
1449
- // Merge model (last one wins)
1450
- if (config.model !== undefined) {
1451
- mergedConfig.model = config.model;
1452
- }
1453
-
1454
- // Merge autoMemoryEnabled (last one wins)
1455
- if (config.autoMemoryEnabled !== undefined) {
1456
- mergedConfig.autoMemoryEnabled = config.autoMemoryEnabled;
1457
- }
1458
-
1459
- // Merge autoMemoryFrequency (last one wins)
1460
- if (config.autoMemoryFrequency !== undefined) {
1461
- mergedConfig.autoMemoryFrequency = config.autoMemoryFrequency;
1462
- }
1463
-
1464
- // Merge marketplaces (last one wins for same key)
1465
- if (config.marketplaces) {
1466
- if (!mergedConfig.marketplaces) mergedConfig.marketplaces = {};
1467
- Object.assign(mergedConfig.marketplaces, config.marketplaces);
1468
- }
1469
-
1470
- // Merge worktree (last one wins)
1471
- if (config.worktree !== undefined) {
1472
- mergedConfig.worktree = config.worktree;
1473
- }
1474
-
1475
- // Merge models
1476
- if (config.models) {
1477
- if (!mergedConfig.models) mergedConfig.models = {};
1478
- for (const [modelName, modelConfig] of Object.entries(config.models)) {
1479
- if (!mergedConfig.models[modelName]) {
1480
- mergedConfig.models[modelName] = {};
1481
- }
1482
- Object.assign(mergedConfig.models[modelName], modelConfig);
1483
- }
1484
- }
1485
- }
1486
-
1487
- return {
1488
- hooks:
1489
- mergedConfig.hooks && Object.keys(mergedConfig.hooks).length > 0
1490
- ? mergedConfig.hooks
1491
- : undefined,
1492
- env:
1493
- mergedConfig.env && Object.keys(mergedConfig.env).length > 0
1494
- ? mergedConfig.env
1495
- : undefined,
1496
- permissions:
1497
- mergedConfig.permissions &&
1498
- Object.keys(mergedConfig.permissions).length > 0
1499
- ? mergedConfig.permissions
1500
- : undefined,
1501
- enabledPlugins:
1502
- mergedConfig.enabledPlugins &&
1503
- Object.keys(mergedConfig.enabledPlugins).length > 0
1504
- ? mergedConfig.enabledPlugins
1505
- : undefined,
1506
- language: mergedConfig.language,
1507
- model: mergedConfig.model,
1508
- autoMemoryEnabled: mergedConfig.autoMemoryEnabled,
1509
- marketplaces:
1510
- mergedConfig.marketplaces &&
1511
- Object.keys(mergedConfig.marketplaces).length > 0
1512
- ? mergedConfig.marketplaces
1513
- : undefined,
1514
- models:
1515
- mergedConfig.models && Object.keys(mergedConfig.models).length > 0
1516
- ? mergedConfig.models
1517
- : undefined,
1518
- worktree: mergedConfig.worktree,
1519
- };
1520
- }