Skip to content

Commit 0cefe28

Browse files
committed
Add SettingsManager for per-agent model configuration Implement project-level settings loading from .hatchbox/settings.json with support for per-agent model overrides. Integrate settings into IgniteCommand to merge agent configurations before passing to Claude CLI. Includes comprehensive test coverage for settings loading, validation, and model precedence. Fixes #93
1 parent e6f8884 commit 0cefe28

File tree

6 files changed

+914
-4
lines changed

6 files changed

+914
-4
lines changed

src/commands/ignite.test.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,4 +1112,218 @@ describe('IgniteCommand', () => {
11121112
}
11131113
})
11141114
})
1115+
1116+
describe('settings integration', () => {
1117+
it('should load settings and pass to AgentManager', async () => {
1118+
const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined)
1119+
const getRepoInfoSpy = vi.spyOn(githubUtils, 'getRepoInfo').mockResolvedValue({
1120+
owner: 'testowner',
1121+
name: 'testrepo',
1122+
})
1123+
1124+
const mockSettings = {
1125+
agents: {
1126+
'test-agent': {
1127+
model: 'haiku',
1128+
},
1129+
},
1130+
}
1131+
1132+
const mockSettingsManager = {
1133+
loadSettings: vi.fn().mockResolvedValue(mockSettings),
1134+
}
1135+
1136+
const mockAgentManager = {
1137+
loadAgents: vi.fn().mockResolvedValue({
1138+
'test-agent': {
1139+
description: 'Test agent',
1140+
prompt: 'Test prompt',
1141+
tools: ['Read'],
1142+
model: 'haiku', // Should be overridden
1143+
},
1144+
}),
1145+
formatForCli: vi.fn((agents) => agents),
1146+
}
1147+
1148+
const originalCwd = process.cwd
1149+
process.cwd = vi.fn().mockReturnValue('/path/to/feat/issue-123-test')
1150+
1151+
const commandWithSettings = new IgniteCommand(
1152+
mockTemplateManager,
1153+
mockGitWorktreeManager,
1154+
mockAgentManager as never,
1155+
mockSettingsManager as never,
1156+
)
1157+
1158+
try {
1159+
await commandWithSettings.execute()
1160+
1161+
// Verify settings were loaded
1162+
expect(mockSettingsManager.loadSettings).toHaveBeenCalled()
1163+
1164+
// Verify settings were passed to loadAgents
1165+
expect(mockAgentManager.loadAgents).toHaveBeenCalledWith(mockSettings)
1166+
} finally {
1167+
process.cwd = originalCwd
1168+
launchClaudeSpy.mockRestore()
1169+
getRepoInfoSpy.mockRestore()
1170+
}
1171+
})
1172+
1173+
it('should handle missing settings gracefully and continue', async () => {
1174+
const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined)
1175+
const getRepoInfoSpy = vi.spyOn(githubUtils, 'getRepoInfo').mockResolvedValue({
1176+
owner: 'testowner',
1177+
name: 'testrepo',
1178+
})
1179+
1180+
const mockSettingsManager = {
1181+
loadSettings: vi.fn().mockResolvedValue({}), // Empty settings
1182+
}
1183+
1184+
const mockAgentManager = {
1185+
loadAgents: vi.fn().mockResolvedValue({
1186+
'test-agent': {
1187+
description: 'Test agent',
1188+
prompt: 'Test prompt',
1189+
tools: ['Read'],
1190+
model: 'sonnet',
1191+
},
1192+
}),
1193+
formatForCli: vi.fn((agents) => agents),
1194+
}
1195+
1196+
const originalCwd = process.cwd
1197+
process.cwd = vi.fn().mockReturnValue('/path/to/feat/issue-123-test')
1198+
1199+
const commandWithSettings = new IgniteCommand(
1200+
mockTemplateManager,
1201+
mockGitWorktreeManager,
1202+
mockAgentManager as never,
1203+
mockSettingsManager as never,
1204+
)
1205+
1206+
try {
1207+
await commandWithSettings.execute()
1208+
1209+
// Should still execute successfully
1210+
expect(mockSettingsManager.loadSettings).toHaveBeenCalled()
1211+
expect(mockAgentManager.loadAgents).toHaveBeenCalledWith({})
1212+
expect(launchClaudeSpy).toHaveBeenCalled()
1213+
} finally {
1214+
process.cwd = originalCwd
1215+
launchClaudeSpy.mockRestore()
1216+
getRepoInfoSpy.mockRestore()
1217+
}
1218+
})
1219+
1220+
it('should handle settings loading errors without crashing', async () => {
1221+
const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined)
1222+
const getRepoInfoSpy = vi.spyOn(githubUtils, 'getRepoInfo').mockResolvedValue({
1223+
owner: 'testowner',
1224+
name: 'testrepo',
1225+
})
1226+
1227+
const mockSettingsManager = {
1228+
loadSettings: vi.fn().mockRejectedValue(new Error('Failed to load settings')),
1229+
}
1230+
1231+
const mockAgentManager = {
1232+
loadAgents: vi.fn().mockResolvedValue({
1233+
'test-agent': {
1234+
description: 'Test agent',
1235+
prompt: 'Test prompt',
1236+
tools: ['Read'],
1237+
model: 'sonnet',
1238+
},
1239+
}),
1240+
formatForCli: vi.fn((agents) => agents),
1241+
}
1242+
1243+
const originalCwd = process.cwd
1244+
process.cwd = vi.fn().mockReturnValue('/path/to/feat/issue-123-test')
1245+
1246+
const commandWithSettings = new IgniteCommand(
1247+
mockTemplateManager,
1248+
mockGitWorktreeManager,
1249+
mockAgentManager as never,
1250+
mockSettingsManager as never,
1251+
)
1252+
1253+
try {
1254+
// Should not throw - execution continues without settings
1255+
await commandWithSettings.execute()
1256+
1257+
expect(mockSettingsManager.loadSettings).toHaveBeenCalled()
1258+
// loadAgents should be called without settings (undefined)
1259+
expect(mockAgentManager.loadAgents).toHaveBeenCalled()
1260+
expect(launchClaudeSpy).toHaveBeenCalled()
1261+
} finally {
1262+
process.cwd = originalCwd
1263+
launchClaudeSpy.mockRestore()
1264+
getRepoInfoSpy.mockRestore()
1265+
}
1266+
})
1267+
1268+
it('should pass merged agent configs to Claude CLI', async () => {
1269+
const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined)
1270+
const getRepoInfoSpy = vi.spyOn(githubUtils, 'getRepoInfo').mockResolvedValue({
1271+
owner: 'testowner',
1272+
name: 'testrepo',
1273+
})
1274+
1275+
const mockSettings = {
1276+
agents: {
1277+
'test-agent': {
1278+
model: 'haiku',
1279+
},
1280+
},
1281+
}
1282+
1283+
const mockSettingsManager = {
1284+
loadSettings: vi.fn().mockResolvedValue(mockSettings),
1285+
}
1286+
1287+
const mockAgentManager = {
1288+
loadAgents: vi.fn().mockResolvedValue({
1289+
'test-agent': {
1290+
description: 'Test agent',
1291+
prompt: 'Test prompt',
1292+
tools: ['Read'],
1293+
model: 'haiku', // Overridden by settings
1294+
},
1295+
}),
1296+
formatForCli: vi.fn((agents) => agents),
1297+
}
1298+
1299+
const originalCwd = process.cwd
1300+
process.cwd = vi.fn().mockReturnValue('/path/to/feat/issue-123-test')
1301+
1302+
const commandWithSettings = new IgniteCommand(
1303+
mockTemplateManager,
1304+
mockGitWorktreeManager,
1305+
mockAgentManager as never,
1306+
mockSettingsManager as never,
1307+
)
1308+
1309+
try {
1310+
await commandWithSettings.execute()
1311+
1312+
const launchClaudeCall = launchClaudeSpy.mock.calls[0]
1313+
expect(launchClaudeCall[1]).toHaveProperty('agents')
1314+
expect(launchClaudeCall[1].agents).toEqual({
1315+
'test-agent': {
1316+
description: 'Test agent',
1317+
prompt: 'Test prompt',
1318+
tools: ['Read'],
1319+
model: 'haiku', // Should reflect the override
1320+
},
1321+
})
1322+
} finally {
1323+
process.cwd = originalCwd
1324+
launchClaudeSpy.mockRestore()
1325+
getRepoInfoSpy.mockRestore()
1326+
}
1327+
})
1328+
})
11151329
})

src/commands/ignite.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { launchClaude, ClaudeCliOptions } from '../utils/claude.js'
66
import { PromptTemplateManager, TemplateVariables } from '../lib/PromptTemplateManager.js'
77
import { getRepoInfo } from '../utils/github.js'
88
import { AgentManager } from '../lib/AgentManager.js'
9+
import { SettingsManager } from '../lib/SettingsManager.js'
910

1011
/**
1112
* IgniteCommand: Auto-detect workspace context and launch Claude
@@ -23,15 +24,18 @@ export class IgniteCommand {
2324
private templateManager: PromptTemplateManager
2425
private gitWorktreeManager: GitWorktreeManager
2526
private agentManager: AgentManager
27+
private settingsManager: SettingsManager
2628

2729
constructor(
2830
templateManager?: PromptTemplateManager,
2931
gitWorktreeManager?: GitWorktreeManager,
30-
agentManager?: AgentManager
32+
agentManager?: AgentManager,
33+
settingsManager?: SettingsManager
3134
) {
3235
this.templateManager = templateManager ?? new PromptTemplateManager()
3336
this.gitWorktreeManager = gitWorktreeManager ?? new GitWorktreeManager()
3437
this.agentManager = agentManager ?? new AgentManager()
38+
this.settingsManager = settingsManager ?? new SettingsManager()
3539
}
3640

3741
/**
@@ -107,10 +111,24 @@ export class IgniteCommand {
107111
}
108112
}
109113

110-
// Step 4.6: Load agent configurations
114+
// Step 4.6: Load project settings and agent configurations
111115
let agents: Record<string, unknown> | undefined
112116
try {
113-
const loadedAgents = await this.agentManager.loadAgents()
117+
// Load project-level settings first
118+
let settings
119+
try {
120+
settings = await this.settingsManager.loadSettings()
121+
if (settings?.agents && Object.keys(settings.agents).length > 0) {
122+
logger.debug('Loaded project settings', {
123+
agentOverrides: Object.keys(settings.agents),
124+
})
125+
}
126+
} catch (error) {
127+
logger.warn(`Failed to load settings: ${error instanceof Error ? error.message : 'Unknown error'}`)
128+
}
129+
130+
// Load agents with settings overrides
131+
const loadedAgents = await this.agentManager.loadAgents(settings)
114132
agents = this.agentManager.formatForCli(loadedAgents)
115133
logger.debug('Loaded agent configurations', {
116134
agentCount: Object.keys(agents).length,

0 commit comments

Comments
 (0)