Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 105 additions & 1 deletion cli/src/__tests__/utils/project-picker.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import os from 'os'
import path from 'path'

import { describe, test, expect } from 'bun:test'

import { shouldShowProjectPicker } from '../../utils/project-picker'
import {
getProjectRoot,
setProjectRoot,
tryGetProjectRoot,
} from '../../project-files'
import {
__resetLocalAgentRegistryForTests,
findAgentsDirectory,
getLoadedMCPServers,
initializeAgentRegistry,
loadAgentDefinitions,
loadLocalAgents,
} from '../../utils/local-agent-registry'
import {
activateProject,
shouldShowProjectPicker,
} from '../../utils/project-picker'

describe('cli/utils/project-picker', () => {
test('returns true when start cwd is home directory', () => {
Expand Down Expand Up @@ -36,4 +54,90 @@ describe('cli/utils/project-picker', () => {

expect(shouldShowProjectPicker(siblingDir, homeDir)).toBe(false)
})

test('reloads local agents and MCP servers after selecting a project', async () => {
const originalCwd = process.cwd()
const originalProjectRoot = tryGetProjectRoot()
const tempDir = mkdtempSync(path.join(os.tmpdir(), 'freebuff-project-'))
const launchDir = path.join(tempDir, 'launch')
const launchAgentsDir = path.join(launchDir, '.agents')
const projectDir = path.join(tempDir, 'project')
const agentsDir = path.join(projectDir, '.agents')

mkdirSync(launchAgentsDir, { recursive: true })
mkdirSync(agentsDir, { recursive: true })
writeFileSync(
path.join(launchAgentsDir, 'launch-agent.ts'),
`export default {
id: 'launch-project-agent',
displayName: 'Launch Project Agent',
model: 'anthropic/claude-sonnet-4',
instructions: 'Loaded from the launch project'
}`,
)
writeFileSync(
path.join(agentsDir, 'selected-agent.ts'),
`export default {
id: 'selected-project-agent',
displayName: 'Selected Project Agent',
model: 'anthropic/claude-sonnet-4',
instructions: 'Loaded from the selected project'
}`,
)
writeFileSync(
path.join(agentsDir, 'mcp.json'),
JSON.stringify({
mcpServers: {
projectPickerServer: {
command: 'node',
args: ['server.js'],
},
},
}),
)

try {
process.chdir(launchDir)
setProjectRoot(launchDir)
__resetLocalAgentRegistryForTests()
await initializeAgentRegistry()

expect(findAgentsDirectory()).toBe(launchAgentsDir)
expect(
loadLocalAgents().find((agent) => agent.id === 'launch-project-agent'),
).toBeDefined()
expect(getLoadedMCPServers().projectPickerServer).toBeUndefined()

await activateProject(projectDir)

expect(process.cwd()).toBe(projectDir)
expect(getProjectRoot()).toBe(projectDir)
expect(findAgentsDirectory()).toBe(agentsDir)
const localAgents = loadLocalAgents()
expect(
localAgents.find((agent) => agent.id === 'launch-project-agent'),
).toBeUndefined()
expect(
localAgents.find((agent) => agent.id === 'selected-project-agent'),
).toBeDefined()
expect(getLoadedMCPServers().projectPickerServer).toMatchObject({
command: 'node',
args: ['server.js'],
})

const baseAgent = loadAgentDefinitions().find((definition) =>
definition.id.startsWith('base'),
)
expect(baseAgent).toBeDefined()
expect(baseAgent?.mcpServers?.projectPickerServer).toMatchObject({
command: 'node',
args: ['server.js'],
})
} finally {
process.chdir(originalCwd)
setProjectRoot(originalProjectRoot ?? originalCwd)
__resetLocalAgentRegistryForTests()
rmSync(tempDir, { recursive: true, force: true })
}
})
})
19 changes: 9 additions & 10 deletions cli/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,19 @@ import { loadPackageVersion, parseArgs } from './cli-args'
import { handlePublish } from './commands/publish'
import { runPlainLogin } from './login/plain-login'
import { initializeApp } from './init/init-app'
import { getProjectRoot, setProjectRoot } from './project-files'
import { getProjectRoot } from './project-files'
import { trackEvent } from './utils/analytics'
import { getAuthToken, getAuthTokenDetails } from './utils/auth'
import { resetCodebuffClient } from './utils/codebuff-client'
import { setApiClientAuthToken } from './utils/codebuff-api'
import { IS_FREEBUFF } from './utils/constants'
import { initializeAgentRegistry } from './utils/local-agent-registry'
import { trimOversizedChatLogs } from './utils/chat-history'
import { clearLogFile, logger } from './utils/logger'
import { drainClientLogs } from './utils/log-shipper'
import { shouldShowProjectPicker } from './utils/project-picker'
import {
activateProject,
shouldShowProjectPicker,
} from './utils/project-picker'
import { saveRecentProject } from './utils/recent-projects'
import { startEngagementTracking } from './utils/engagement'
import {
Expand Down Expand Up @@ -343,8 +345,9 @@ async function main(): Promise<void> {
// Callback for when user selects a new project from the picker
const handleProjectChange = React.useCallback(
async (newProjectPath: string) => {
// Change process working directory
process.chdir(newProjectPath)
await activateProject(newProjectPath, {
reloadAgentRegistry: !hasAgentOverride,
})

// Track directory change (avoid logging full paths for privacy)
const isGitRepo = fs.existsSync(path.join(newProjectPath, '.git'))
Expand All @@ -354,10 +357,6 @@ async function main(): Promise<void> {
pathDepth,
isHomeDir: newProjectPath === os.homedir(),
})
// Update the project root in the module state
setProjectRoot(newProjectPath)
// Reset client to ensure tools use the updated project root
resetCodebuffClient()
// Save to recent projects list
saveRecentProject(newProjectPath)
// Update local state
Expand All @@ -367,7 +366,7 @@ async function main(): Promise<void> {
// Hide the picker and show the chat
setShowProjectPickerScreen(false)
},
[],
[hasAgentOverride],
)

return (
Expand Down
13 changes: 13 additions & 0 deletions cli/src/utils/local-agent-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ export async function initializeAgentRegistry(): Promise<void> {
}
}

/**
* Reload the local agent registry after the active project changes.
*
* The derived agent-list and directory caches depend on the current working
* directory, so they must be cleared before the registry is initialized for
* the new project.
*/
export async function reloadLocalAgentRegistry(): Promise<void> {
cachedAgentsByMode.clear()
cachedAgentsDir = null
await initializeAgentRegistry()
}

/**
* Get default agent directories to scan.
* Matches the SDK's getDefaultAgentDirs() to ensure consistency.
Expand Down
22 changes: 22 additions & 0 deletions cli/src/utils/project-picker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
import path from 'path'

import { setProjectRoot } from '../project-files'
import { resetCodebuffClient } from './codebuff-client'
import { reloadLocalAgentRegistry } from './local-agent-registry'

interface ActivateProjectOptions {
reloadAgentRegistry?: boolean
}

export async function activateProject(
projectPath: string,
{ reloadAgentRegistry = true }: ActivateProjectOptions = {},
): Promise<void> {
process.chdir(projectPath)
setProjectRoot(projectPath)

if (reloadAgentRegistry) {
await reloadLocalAgentRegistry()
}

resetCodebuffClient()
}

export function shouldShowProjectPicker(
startCwd: string,
homeDir: string,
Expand Down