@covibes/zeroshot 5.2.1 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +174 -189
  2. package/README.md +226 -195
  3. package/cli/commands/providers.js +149 -0
  4. package/cli/index.js +3145 -2366
  5. package/cli/lib/first-run.js +40 -3
  6. package/cli/message-formatters-normal.js +28 -6
  7. package/cluster-templates/base-templates/debug-workflow.json +24 -78
  8. package/cluster-templates/base-templates/full-workflow.json +99 -316
  9. package/cluster-templates/base-templates/single-worker.json +23 -15
  10. package/cluster-templates/base-templates/worker-validator.json +105 -36
  11. package/cluster-templates/conductor-bootstrap.json +9 -7
  12. package/lib/docker-config.js +14 -1
  13. package/lib/git-remote-utils.js +165 -0
  14. package/lib/id-detector.js +10 -7
  15. package/lib/provider-defaults.js +62 -0
  16. package/lib/provider-detection.js +59 -0
  17. package/lib/provider-names.js +57 -0
  18. package/lib/settings/claude-auth.js +78 -0
  19. package/lib/settings.js +298 -15
  20. package/lib/stream-json-parser.js +4 -238
  21. package/package.json +27 -6
  22. package/scripts/setup-merge-queue.sh +170 -0
  23. package/scripts/validate-templates.js +100 -0
  24. package/src/agent/agent-config.js +140 -63
  25. package/src/agent/agent-context-builder.js +336 -165
  26. package/src/agent/agent-hook-executor.js +337 -67
  27. package/src/agent/agent-lifecycle.js +386 -287
  28. package/src/agent/agent-stuck-detector.js +7 -7
  29. package/src/agent/agent-task-executor.js +944 -683
  30. package/src/agent/output-extraction.js +217 -0
  31. package/src/agent/output-reformatter.js +175 -0
  32. package/src/agent/schema-utils.js +146 -0
  33. package/src/agent-wrapper.js +112 -31
  34. package/src/agents/git-pusher-template.js +285 -0
  35. package/src/claude-task-runner.js +145 -44
  36. package/src/config-router.js +13 -13
  37. package/src/config-validator.js +1049 -563
  38. package/src/input-helpers.js +65 -0
  39. package/src/isolation-manager.js +499 -320
  40. package/src/issue-providers/README.md +305 -0
  41. package/src/issue-providers/azure-devops-provider.js +273 -0
  42. package/src/issue-providers/base-provider.js +232 -0
  43. package/src/issue-providers/github-provider.js +179 -0
  44. package/src/issue-providers/gitlab-provider.js +241 -0
  45. package/src/issue-providers/index.js +196 -0
  46. package/src/issue-providers/jira-provider.js +239 -0
  47. package/src/ledger.js +50 -11
  48. package/src/lib/safe-exec.js +88 -0
  49. package/src/orchestrator.js +1348 -757
  50. package/src/preflight.js +306 -149
  51. package/src/process-metrics.js +98 -56
  52. package/src/providers/anthropic/cli-builder.js +73 -0
  53. package/src/providers/anthropic/index.js +204 -0
  54. package/src/providers/anthropic/models.js +23 -0
  55. package/src/providers/anthropic/output-parser.js +177 -0
  56. package/src/providers/base-provider.js +251 -0
  57. package/src/providers/capabilities.js +60 -0
  58. package/src/providers/google/cli-builder.js +55 -0
  59. package/src/providers/google/index.js +116 -0
  60. package/src/providers/google/models.js +24 -0
  61. package/src/providers/google/output-parser.js +101 -0
  62. package/src/providers/index.js +91 -0
  63. package/src/providers/openai/cli-builder.js +133 -0
  64. package/src/providers/openai/index.js +136 -0
  65. package/src/providers/openai/models.js +21 -0
  66. package/src/providers/openai/output-parser.js +143 -0
  67. package/src/providers/opencode/cli-builder.js +42 -0
  68. package/src/providers/opencode/index.js +103 -0
  69. package/src/providers/opencode/models.js +55 -0
  70. package/src/providers/opencode/output-parser.js +122 -0
  71. package/src/schemas/sub-cluster.js +68 -39
  72. package/src/status-footer.js +94 -48
  73. package/src/sub-cluster-wrapper.js +92 -36
  74. package/src/task-runner.js +8 -6
  75. package/src/template-resolver.js +12 -9
  76. package/src/tui/data-poller.js +123 -99
  77. package/src/tui/formatters.js +4 -3
  78. package/src/tui/keybindings.js +259 -318
  79. package/src/tui/layout.js +20 -3
  80. package/src/tui/renderer.js +11 -21
  81. package/task-lib/attachable-watcher.js +150 -111
  82. package/task-lib/claude-recovery.js +156 -0
  83. package/task-lib/commands/episodes.js +105 -0
  84. package/task-lib/commands/list.js +3 -3
  85. package/task-lib/commands/logs.js +231 -189
  86. package/task-lib/commands/resume.js +3 -2
  87. package/task-lib/commands/run.js +12 -3
  88. package/task-lib/commands/schedules.js +111 -61
  89. package/task-lib/config.js +0 -2
  90. package/task-lib/runner.js +128 -50
  91. package/task-lib/scheduler.js +3 -3
  92. package/task-lib/store.js +464 -152
  93. package/task-lib/tui/formatters.js +94 -45
  94. package/task-lib/tui/renderer.js +13 -3
  95. package/task-lib/tui.js +73 -32
  96. package/task-lib/watcher.js +157 -100
  97. package/src/agents/git-pusher-agent.json +0 -20
  98. package/src/github.js +0 -103
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Provider defaults module
3
+ *
4
+ * Separated from settings.js to break circular dependency:
5
+ * - settings.js requires provider defaults
6
+ * - providers require settings.js (for loadSettings, getClaudeCommand, etc.)
7
+ *
8
+ * CRITICAL: This module should NOT import from settings.js
9
+ */
10
+
11
+ // Cache provider defaults to avoid repeated instantiation
12
+ let _providerDefaultsCache = null;
13
+
14
+ /**
15
+ * Build provider default settings by instantiating each provider
16
+ * and calling getDefaultSettings()
17
+ * @returns {Object} Map of provider name to default settings
18
+ */
19
+ function buildProviderDefaults() {
20
+ const { listProviders, getProvider } = require('../src/providers');
21
+
22
+ const defaults = {};
23
+ for (const providerName of listProviders()) {
24
+ try {
25
+ const provider = getProvider(providerName);
26
+ defaults[providerName] = provider.getDefaultSettings();
27
+ } catch (error) {
28
+ // If provider fails to instantiate, use basic defaults
29
+ console.warn(`Warning: Could not get defaults for ${providerName}: ${error.message}`);
30
+ defaults[providerName] = {
31
+ maxLevel: 'level3',
32
+ minLevel: 'level1',
33
+ defaultLevel: 'level2',
34
+ levelOverrides: {},
35
+ };
36
+ }
37
+ }
38
+ return defaults;
39
+ }
40
+
41
+ /**
42
+ * Get or build cached provider defaults
43
+ * @returns {Object} Provider defaults
44
+ */
45
+ function getProviderDefaults() {
46
+ if (!_providerDefaultsCache) {
47
+ _providerDefaultsCache = buildProviderDefaults();
48
+ }
49
+ return _providerDefaultsCache;
50
+ }
51
+
52
+ /**
53
+ * Clear the provider defaults cache (primarily for testing)
54
+ */
55
+ function clearProviderDefaultsCache() {
56
+ _providerDefaultsCache = null;
57
+ }
58
+
59
+ module.exports = {
60
+ getProviderDefaults,
61
+ clearProviderDefaultsCache,
62
+ };
@@ -0,0 +1,59 @@
1
+ const { execSync, spawnSync } = require('child_process');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ function commandExists(command) {
6
+ if (!command) return false;
7
+ if (command.includes(path.sep)) {
8
+ return fs.existsSync(command);
9
+ }
10
+ try {
11
+ execSync(`command -v ${command}`, { stdio: 'pipe' });
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ function getCommandPath(command) {
19
+ if (!command) return null;
20
+ if (command.includes(path.sep)) {
21
+ return fs.existsSync(command) ? command : null;
22
+ }
23
+ try {
24
+ const output = execSync(`command -v ${command}`, { encoding: 'utf8', stdio: 'pipe' });
25
+ return output.trim() || null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function getHelpOutput(command, args = []) {
32
+ if (!commandExists(command)) return '';
33
+
34
+ const attempt = (flag) => {
35
+ const result = spawnSync(command, [...args, flag], { encoding: 'utf8' });
36
+ const output = `${result.stdout || ''}${result.stderr || ''}`;
37
+ return output.trim();
38
+ };
39
+
40
+ const help = attempt('--help');
41
+ if (help) return help;
42
+
43
+ const alt = attempt('-h');
44
+ return alt || '';
45
+ }
46
+
47
+ function getVersionOutput(command, args = []) {
48
+ if (!commandExists(command)) return '';
49
+ const result = spawnSync(command, [...args, '--version'], { encoding: 'utf8' });
50
+ const output = `${result.stdout || ''}${result.stderr || ''}`;
51
+ return output.trim();
52
+ }
53
+
54
+ module.exports = {
55
+ commandExists,
56
+ getCommandPath,
57
+ getHelpOutput,
58
+ getVersionOutput,
59
+ };
@@ -0,0 +1,57 @@
1
+ const PROVIDER_ALIASES = {
2
+ anthropic: 'claude',
3
+ openai: 'codex',
4
+ google: 'gemini',
5
+ claude: 'claude',
6
+ codex: 'codex',
7
+ gemini: 'gemini',
8
+ opencode: 'opencode',
9
+ };
10
+
11
+ const VALID_PROVIDERS = ['claude', 'codex', 'gemini', 'opencode'];
12
+
13
+ function normalizeProviderName(name) {
14
+ if (!name || typeof name !== 'string') return name;
15
+ const normalized = PROVIDER_ALIASES[name.toLowerCase()];
16
+ return normalized || name;
17
+ }
18
+
19
+ function normalizeProviderSettings(providerSettings) {
20
+ if (
21
+ !providerSettings ||
22
+ typeof providerSettings !== 'object' ||
23
+ Array.isArray(providerSettings)
24
+ ) {
25
+ return providerSettings;
26
+ }
27
+
28
+ const normalized = {};
29
+ const entries = Object.entries(providerSettings);
30
+ const aliasFirst = entries.sort(([left], [right]) => {
31
+ const leftIsCanonical = normalizeProviderName(left) === left;
32
+ const rightIsCanonical = normalizeProviderName(right) === right;
33
+ if (leftIsCanonical === rightIsCanonical) return 0;
34
+ return leftIsCanonical ? 1 : -1;
35
+ });
36
+
37
+ for (const [key, value] of aliasFirst) {
38
+ const canonical = normalizeProviderName(key);
39
+ if (!VALID_PROVIDERS.includes(canonical)) {
40
+ normalized[key] = value;
41
+ continue;
42
+ }
43
+ normalized[canonical] = {
44
+ ...(normalized[canonical] || {}),
45
+ ...(value || {}),
46
+ };
47
+ }
48
+
49
+ return normalized;
50
+ }
51
+
52
+ module.exports = {
53
+ PROVIDER_ALIASES,
54
+ VALID_PROVIDERS,
55
+ normalizeProviderName,
56
+ normalizeProviderSettings,
57
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Claude authentication settings and helpers
3
+ * Extracted from settings.js for provider-specific isolation
4
+ */
5
+
6
+ /**
7
+ * Anthropic API key prefix for validation
8
+ */
9
+ const ANTHROPIC_KEY_PREFIX = 'sk-ant-';
10
+
11
+ /**
12
+ * Environment variables used for Claude authentication
13
+ */
14
+ const CLAUDE_AUTH_ENV_VARS = [
15
+ 'ANTHROPIC_API_KEY',
16
+ 'AWS_BEARER_TOKEN_BEDROCK',
17
+ 'AWS_REGION',
18
+ 'CLAUDE_CODE_USE_BEDROCK',
19
+ ];
20
+
21
+ /**
22
+ * Validate an Anthropic API key format
23
+ * @param {string|null|undefined} key - API key to validate
24
+ * @returns {boolean} True if key is falsy OR starts with valid prefix
25
+ */
26
+ function isValidAnthropicKey(key) {
27
+ return !key || key.startsWith(ANTHROPIC_KEY_PREFIX);
28
+ }
29
+
30
+ /**
31
+ * Check if Bedrock mode is active based on env and overrides
32
+ * @param {Object} [envOverrides={}] - Environment variable overrides to check
33
+ * @returns {boolean} True if Bedrock mode is active
34
+ */
35
+ function isBedrockMode(envOverrides = {}) {
36
+ return (
37
+ envOverrides.CLAUDE_CODE_USE_BEDROCK === '1' || process.env.CLAUDE_CODE_USE_BEDROCK === '1'
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Resolve Claude authentication environment variables from settings
43
+ * Bedrock takes priority over direct Anthropic API key AND OAuth session
44
+ * @param {Object} settings - Settings object from loadSettings()
45
+ * @returns {Object} Environment variables to set for Claude auth
46
+ */
47
+ function resolveClaudeAuth(settings) {
48
+ const claudeSettings = settings.providerSettings?.claude || {};
49
+ const env = {};
50
+
51
+ // Bedrock takes priority over everything (including OAuth)
52
+ if (!process.env.AWS_BEARER_TOKEN_BEDROCK && claudeSettings.bedrockApiKey) {
53
+ env.AWS_BEARER_TOKEN_BEDROCK = claudeSettings.bedrockApiKey;
54
+ env.CLAUDE_CODE_USE_BEDROCK = '1';
55
+ if (claudeSettings.bedrockRegion && !process.env.AWS_REGION) {
56
+ env.AWS_REGION = claudeSettings.bedrockRegion;
57
+ }
58
+ } else if (process.env.AWS_BEARER_TOKEN_BEDROCK && !process.env.CLAUDE_CODE_USE_BEDROCK) {
59
+ // Auto-set CLAUDE_CODE_USE_BEDROCK if token present in env
60
+ env.CLAUDE_CODE_USE_BEDROCK = '1';
61
+ }
62
+
63
+ // Anthropic API key only if no Bedrock
64
+ const hasBedrock = env.AWS_BEARER_TOKEN_BEDROCK || process.env.AWS_BEARER_TOKEN_BEDROCK;
65
+ if (!process.env.ANTHROPIC_API_KEY && !hasBedrock && claudeSettings.anthropicApiKey) {
66
+ env.ANTHROPIC_API_KEY = claudeSettings.anthropicApiKey;
67
+ }
68
+
69
+ return env;
70
+ }
71
+
72
+ module.exports = {
73
+ ANTHROPIC_KEY_PREFIX,
74
+ CLAUDE_AUTH_ENV_VARS,
75
+ isValidAnthropicKey,
76
+ isBedrockMode,
77
+ resolveClaudeAuth,
78
+ };
package/lib/settings.js CHANGED
@@ -7,6 +7,20 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
9
  const { validateMountConfig, validateEnvPassthrough } = require('./docker-config');
10
+ const {
11
+ VALID_PROVIDERS,
12
+ normalizeProviderName,
13
+ normalizeProviderSettings,
14
+ } = require('./provider-names');
15
+
16
+ // Lazy-loaded to avoid circular dependency (issue-providers → settings → issue-providers)
17
+ let _issueProviderFns = null;
18
+ function getIssueProviderFns() {
19
+ if (!_issueProviderFns) {
20
+ _issueProviderFns = require('../src/issue-providers');
21
+ }
22
+ return _issueProviderFns;
23
+ }
10
24
 
11
25
  /**
12
26
  * Get settings file path (dynamically reads env var for testing)
@@ -14,9 +28,14 @@ const { validateMountConfig, validateEnvPassthrough } = require('./docker-config
14
28
  * @returns {string}
15
29
  */
16
30
  function getSettingsFile() {
17
- return process.env.ZEROSHOT_SETTINGS_FILE || path.join(os.homedir(), '.zeroshot', 'settings.json');
31
+ return (
32
+ process.env.ZEROSHOT_SETTINGS_FILE || path.join(os.homedir(), '.zeroshot', 'settings.json')
33
+ );
18
34
  }
19
35
 
36
+ // Import provider defaults from separate module to avoid circular dependency
37
+ const { getProviderDefaults, clearProviderDefaultsCache } = require('./provider-defaults');
38
+
20
39
  /**
21
40
  * Model hierarchy for cost ceiling validation
22
41
  * Higher number = more expensive/capable model
@@ -28,15 +47,17 @@ const MODEL_HIERARCHY = {
28
47
  };
29
48
 
30
49
  const VALID_MODELS = Object.keys(MODEL_HIERARCHY);
50
+ const LEVEL_RANKS = { level1: 1, level2: 2, level3: 3 };
31
51
 
32
52
  /**
33
- * Validate a requested model against the maxModel ceiling
53
+ * Validate a requested model against the maxModel ceiling and minModel floor
34
54
  * @param {string} requestedModel - Model the agent wants to use
35
55
  * @param {string} maxModel - Maximum allowed model (cost ceiling)
56
+ * @param {string|null} minModel - Minimum required model (cost floor)
36
57
  * @returns {string} The validated model
37
- * @throws {Error} If requested model exceeds ceiling
58
+ * @throws {Error} If requested model exceeds ceiling or falls below floor
38
59
  */
39
- function validateModelAgainstMax(requestedModel, maxModel) {
60
+ function validateModelAgainstMax(requestedModel, maxModel, minModel = null) {
40
61
  if (!requestedModel) return maxModel; // Default to ceiling if unspecified
41
62
 
42
63
  if (!VALID_MODELS.includes(requestedModel)) {
@@ -52,12 +73,34 @@ function validateModelAgainstMax(requestedModel, maxModel) {
52
73
  `Either lower agent's model or raise maxModel.`
53
74
  );
54
75
  }
76
+
77
+ if (minModel) {
78
+ if (!VALID_MODELS.includes(minModel)) {
79
+ throw new Error(`Invalid minModel "${minModel}". Valid: ${VALID_MODELS.join(', ')}`);
80
+ }
81
+ if (MODEL_HIERARCHY[minModel] > MODEL_HIERARCHY[maxModel]) {
82
+ throw new Error(`minModel "${minModel}" cannot be higher than maxModel "${maxModel}".`);
83
+ }
84
+ if (MODEL_HIERARCHY[requestedModel] < MODEL_HIERARCHY[minModel]) {
85
+ throw new Error(
86
+ `Agent requests "${requestedModel}" but minModel is "${minModel}". ` +
87
+ `Either raise agent's model or lower minModel.`
88
+ );
89
+ }
90
+ }
91
+
55
92
  return requestedModel;
56
93
  }
57
94
 
58
- // Default settings
59
- const DEFAULT_SETTINGS = {
95
+ // Default settings (base - without issue provider settings which are added dynamically)
96
+ const DEFAULT_SETTINGS_BASE = {
60
97
  maxModel: 'opus', // Cost ceiling - agents cannot use models above this
98
+ minModel: null, // Cost floor - agents cannot use models below this (null = no minimum)
99
+ defaultProvider: 'claude',
100
+ get providerSettings() {
101
+ // Dynamically build from providers on first access
102
+ return getProviderDefaults();
103
+ },
61
104
  defaultConfig: 'conductor-bootstrap',
62
105
  defaultDocker: false,
63
106
  strictSchema: true, // true = reliable json output (default), false = live streaming (may crash - see bold-meadow-11)
@@ -70,7 +113,7 @@ const DEFAULT_SETTINGS = {
70
113
  // Example: 'ccr code' for claude-code-router integration
71
114
  claudeCommand: 'claude',
72
115
  // Docker isolation mounts - preset names or {host, container, readonly?} objects
73
- // Valid presets: gh, git, ssh, aws, azure, kube, terraform, gcloud
116
+ // Valid presets: gh, git, ssh, aws, azure, kube, terraform, gcloud, claude, codex, gemini, opencode
74
117
  dockerMounts: ['gh', 'git', 'ssh'],
75
118
  // Extra env vars to pass to Docker container (in addition to preset-implied ones)
76
119
  // Supports: VAR (if set), VAR_* (pattern), VAR=value (forced), VAR= (empty)
@@ -78,22 +121,156 @@ const DEFAULT_SETTINGS = {
78
121
  // Container home directory - where $HOME resolves in container paths
79
122
  // Default: /home/node (matches zeroshot-cluster-base image)
80
123
  dockerContainerHome: '/home/node',
124
+ // Issue provider settings - defaultIssueSource is here, others come from providers
125
+ defaultIssueSource: 'github', // 'github' | 'gitlab' | 'jira' | 'azure-devops'
81
126
  };
82
127
 
128
+ // Cache for merged defaults (base + issue provider settings)
129
+ let _defaultSettingsCache = null;
130
+
131
+ /**
132
+ * Get DEFAULT_SETTINGS with issue provider settings merged in
133
+ * Lazy-loaded to avoid circular dependency at module load time
134
+ */
135
+ function getDefaultSettings() {
136
+ if (!_defaultSettingsCache) {
137
+ const issueProviderDefaults = getIssueProviderFns().getIssueProviderSettingsDefaults();
138
+ _defaultSettingsCache = {
139
+ ...DEFAULT_SETTINGS_BASE,
140
+ ...issueProviderDefaults,
141
+ };
142
+ }
143
+ return _defaultSettingsCache;
144
+ }
145
+
146
+ // For backward compatibility, export DEFAULT_SETTINGS as a getter
147
+ const DEFAULT_SETTINGS = new Proxy(DEFAULT_SETTINGS_BASE, {
148
+ get(target, prop) {
149
+ // For known base properties, return from base
150
+ if (prop in target) {
151
+ return target[prop];
152
+ }
153
+ // For issue provider settings, get from merged defaults
154
+ const merged = getDefaultSettings();
155
+ return merged[prop];
156
+ },
157
+ has(target, prop) {
158
+ if (prop in target) return true;
159
+ const merged = getDefaultSettings();
160
+ return prop in merged;
161
+ },
162
+ ownKeys() {
163
+ const merged = getDefaultSettings();
164
+ return Object.keys(merged);
165
+ },
166
+ getOwnPropertyDescriptor(target, prop) {
167
+ const merged = getDefaultSettings();
168
+ if (prop in merged) {
169
+ return { enumerable: true, configurable: true, value: merged[prop] };
170
+ }
171
+ return undefined;
172
+ },
173
+ });
174
+
175
+ function mapLegacyModelToLevel(model) {
176
+ switch (model) {
177
+ case 'haiku':
178
+ return 'level1';
179
+ case 'sonnet':
180
+ return 'level2';
181
+ case 'opus':
182
+ return 'level3';
183
+ default:
184
+ return null;
185
+ }
186
+ }
187
+
188
+ function mergeProviderSettings(current, overrides) {
189
+ // Ensure current has all providers with their defaults
190
+ const providerDefaults = getProviderDefaults();
191
+ const merged = {};
192
+
193
+ for (const provider of VALID_PROVIDERS) {
194
+ merged[provider] = {
195
+ ...(providerDefaults[provider] || {}),
196
+ ...(current[provider] || {}),
197
+ ...(overrides?.[provider] || {}),
198
+ };
199
+ if (!merged[provider].levelOverrides) {
200
+ merged[provider].levelOverrides = {};
201
+ }
202
+ }
203
+ return merged;
204
+ }
205
+
206
+ function applyLegacyModelBounds(settings) {
207
+ if (!settings.providerSettings) return settings;
208
+ const claude = settings.providerSettings.claude || {};
209
+ const legacyMaxLevel = mapLegacyModelToLevel(settings.maxModel);
210
+ const legacyMinLevel = mapLegacyModelToLevel(settings.minModel);
211
+
212
+ if (legacyMaxLevel) {
213
+ claude.maxLevel = legacyMaxLevel;
214
+ }
215
+
216
+ if (legacyMinLevel) {
217
+ claude.minLevel = legacyMinLevel;
218
+ }
219
+
220
+ const minRank = LEVEL_RANKS[claude.minLevel] || LEVEL_RANKS.level1;
221
+ const maxRank = LEVEL_RANKS[claude.maxLevel] || LEVEL_RANKS.level3;
222
+ const defaultRank = LEVEL_RANKS[claude.defaultLevel] || LEVEL_RANKS.level2;
223
+
224
+ if (minRank > maxRank) {
225
+ claude.minLevel = 'level1';
226
+ claude.maxLevel = 'level3';
227
+ } else if (defaultRank < minRank) {
228
+ claude.defaultLevel = claude.minLevel;
229
+ } else if (defaultRank > maxRank) {
230
+ claude.defaultLevel = claude.maxLevel;
231
+ }
232
+
233
+ settings.providerSettings.claude = claude;
234
+ return settings;
235
+ }
236
+
237
+ function normalizeLoadedSettings(parsed) {
238
+ const normalized = { ...parsed };
239
+ if (parsed.defaultProvider) {
240
+ normalized.defaultProvider = normalizeProviderName(parsed.defaultProvider);
241
+ }
242
+ if (parsed.providerSettings) {
243
+ normalized.providerSettings = normalizeProviderSettings(parsed.providerSettings);
244
+ }
245
+ return normalized;
246
+ }
247
+
83
248
  /**
84
249
  * Load settings from disk, merging with defaults
85
250
  */
86
251
  function loadSettings() {
87
252
  const settingsFile = getSettingsFile();
88
253
  if (!fs.existsSync(settingsFile)) {
89
- return { ...DEFAULT_SETTINGS };
254
+ // Return a copy with resolved providerSettings
255
+ return {
256
+ ...DEFAULT_SETTINGS,
257
+ providerSettings: getProviderDefaults(),
258
+ };
90
259
  }
91
260
  try {
92
261
  const data = fs.readFileSync(settingsFile, 'utf8');
93
- return { ...DEFAULT_SETTINGS, ...JSON.parse(data) };
262
+ const parsed = normalizeLoadedSettings(JSON.parse(data));
263
+ const merged = { ...DEFAULT_SETTINGS, ...parsed };
264
+ merged.defaultProvider =
265
+ normalizeProviderName(merged.defaultProvider) || DEFAULT_SETTINGS.defaultProvider;
266
+ merged.providerSettings = mergeProviderSettings(getProviderDefaults(), parsed.providerSettings);
267
+ return applyLegacyModelBounds(merged);
94
268
  } catch {
95
269
  console.error('Warning: Could not load settings, using defaults');
96
- return { ...DEFAULT_SETTINGS };
270
+ return {
271
+ ...DEFAULT_SETTINGS,
272
+ providerSettings: getProviderDefaults(),
273
+ };
97
274
  }
98
275
  }
99
276
 
@@ -109,6 +286,51 @@ function saveSettings(settings) {
109
286
  fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2), 'utf8');
110
287
  }
111
288
 
289
+ /**
290
+ * Validate claudeCommand setting
291
+ * @returns {string|null} Error message if invalid, null if valid
292
+ */
293
+ function validateClaudeCommand(value) {
294
+ if (typeof value !== 'string') {
295
+ return 'claudeCommand must be a string';
296
+ }
297
+ if (value.trim().length === 0) {
298
+ return 'claudeCommand cannot be empty';
299
+ }
300
+ return null;
301
+ }
302
+
303
+ /**
304
+ * Validate providerSettings structure by delegating to provider implementations
305
+ * @returns {string|null} Error message if invalid, null if valid
306
+ */
307
+ function validateProviderSettings(value) {
308
+ const normalizedSettings = normalizeProviderSettings(value);
309
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
310
+ return 'providerSettings must be an object';
311
+ }
312
+
313
+ // Lazy require to avoid circular dependency
314
+ const { getProvider } = require('../src/providers');
315
+
316
+ for (const [providerName, settings] of Object.entries(normalizedSettings || {})) {
317
+ if (!VALID_PROVIDERS.includes(providerName)) {
318
+ return `Unknown provider in providerSettings: ${providerName}`;
319
+ }
320
+
321
+ // Delegate validation to the provider
322
+ try {
323
+ const provider = getProvider(providerName);
324
+ const error = provider.validateSettings(settings);
325
+ if (error) return error;
326
+ } catch (err) {
327
+ return `Failed to validate ${providerName} settings: ${err.message}`;
328
+ }
329
+ }
330
+
331
+ return null;
332
+ }
333
+
112
334
  /**
113
335
  * Validate a setting value
114
336
  * @returns {string|null} Error message if invalid, null if valid
@@ -122,19 +344,29 @@ function validateSetting(key, value) {
122
344
  return `Invalid model: ${value}. Valid models: ${VALID_MODELS.join(', ')}`;
123
345
  }
124
346
 
347
+ if (key === 'minModel' && value !== null && !VALID_MODELS.includes(value)) {
348
+ return `Invalid model: ${value}. Valid models: ${VALID_MODELS.join(', ')}, null`;
349
+ }
350
+
125
351
  if (key === 'logLevel' && !['quiet', 'normal', 'verbose'].includes(value)) {
126
352
  return `Invalid log level: ${value}. Valid levels: quiet, normal, verbose`;
127
353
  }
128
354
 
129
355
  if (key === 'claudeCommand') {
130
- if (typeof value !== 'string') {
131
- return 'claudeCommand must be a string';
132
- }
133
- if (value.trim().length === 0) {
134
- return 'claudeCommand cannot be empty';
356
+ return validateClaudeCommand(value);
357
+ }
358
+
359
+ if (key === 'defaultProvider') {
360
+ const normalized = normalizeProviderName(value);
361
+ if (!VALID_PROVIDERS.includes(normalized)) {
362
+ return `Invalid provider: ${value}. Valid providers: ${VALID_PROVIDERS.join(', ')}`;
135
363
  }
136
364
  }
137
365
 
366
+ if (key === 'providerSettings') {
367
+ return validateProviderSettings(value);
368
+ }
369
+
138
370
  if (key === 'dockerMounts') {
139
371
  return validateMountConfig(value);
140
372
  }
@@ -143,15 +375,49 @@ function validateSetting(key, value) {
143
375
  return validateEnvPassthrough(value);
144
376
  }
145
377
 
378
+ // Issue source settings validation (grouped for maintainability)
379
+ const issueSourceError = validateIssueSourceSetting(key, value);
380
+ if (issueSourceError !== undefined) {
381
+ return issueSourceError;
382
+ }
383
+
146
384
  return null;
147
385
  }
148
386
 
387
+ /**
388
+ * Validate issue source related settings
389
+ * Delegates to issue providers for provider-specific settings
390
+ * @param {string} key - Setting key
391
+ * @param {any} value - Setting value
392
+ * @returns {string|null|undefined} Error message, null if valid, undefined if not an issue source setting
393
+ */
394
+ function validateIssueSourceSetting(key, value) {
395
+ // defaultIssueSource is handled here (not provider-specific)
396
+ if (key === 'defaultIssueSource') {
397
+ const { listProviders } = getIssueProviderFns();
398
+ const validSources = listProviders();
399
+ if (!validSources.includes(value)) {
400
+ return `Invalid issue source: ${value}. Valid: ${validSources.join(', ')}`;
401
+ }
402
+ return null;
403
+ }
404
+
405
+ // Delegate to issue providers for provider-specific settings
406
+ const { validateIssueProviderSetting } = getIssueProviderFns();
407
+ return validateIssueProviderSetting(key, value);
408
+ }
409
+
149
410
  /**
150
411
  * Coerce value to correct type based on default value type
151
412
  */
152
413
  function coerceValue(key, value) {
153
414
  const defaultValue = DEFAULT_SETTINGS[key];
154
415
 
416
+ // Handle null values for minModel
417
+ if (key === 'minModel' && (value === 'null' || value === null)) {
418
+ return null;
419
+ }
420
+
155
421
  if (typeof defaultValue === 'boolean') {
156
422
  return value === 'true' || value === '1' || value === 'yes' || value === true;
157
423
  }
@@ -183,6 +449,21 @@ function coerceValue(key, value) {
183
449
  return value;
184
450
  }
185
451
 
452
+ if (key === 'providerSettings') {
453
+ if (typeof value === 'string') {
454
+ try {
455
+ return normalizeProviderSettings(JSON.parse(value));
456
+ } catch {
457
+ throw new Error(`Invalid JSON for providerSettings: ${value}`);
458
+ }
459
+ }
460
+ return normalizeProviderSettings(value);
461
+ }
462
+
463
+ if (key === 'defaultProvider') {
464
+ return normalizeProviderName(value);
465
+ }
466
+
186
467
  return value;
187
468
  }
188
469
 
@@ -213,6 +494,8 @@ module.exports = {
213
494
  MODEL_HIERARCHY,
214
495
  VALID_MODELS,
215
496
  validateModelAgainstMax,
497
+ // Provider defaults exports
498
+ clearProviderDefaultsCache,
216
499
  // Backward compatibility: SETTINGS_FILE as getter (reads env var dynamically)
217
500
  get SETTINGS_FILE() {
218
501
  return getSettingsFile();