diff --git a/src/cli/commands/assistants/constants.ts b/src/cli/commands/assistants/constants.ts index 45a393ff..616a97fc 100644 --- a/src/cli/commands/assistants/constants.ts +++ b/src/cli/commands/assistants/constants.ts @@ -79,6 +79,11 @@ export const MESSAGES = { SUMMARY_REGISTERED: (count: number) => ` Registered: ${count}`, SUMMARY_UNREGISTERED: (count: number) => ` Unregistered: ${count}`, SUMMARY_PROFILE: (profile: string) => ` Profile: ${profile}\n`, - CURRENTLY_REGISTERED: 'Currently registered assistants:' + CURRENTLY_REGISTERED: 'Currently registered assistants:', + PROMPT_STORAGE_SCOPE: 'Where would you like to save assistant configuration?', + STORAGE_GLOBAL_LABEL: 'Global (~/.codemie/) - Available across all projects', + STORAGE_LOCAL_LABEL: 'Local (.codemie/) - Only for this project', + STORAGE_LOCAL_NOTE: 'Project-scoped assistants will override global ones for this repository.', + SUMMARY_CONFIG_LOCATION: (location: string) => ` Config: ${location}\n` } } as const; diff --git a/src/cli/commands/assistants/setup/index.ts b/src/cli/commands/assistants/setup/index.ts index 26ffbf14..79ce7294 100644 --- a/src/cli/commands/assistants/setup/index.ts +++ b/src/cli/commands/assistants/setup/index.ts @@ -84,6 +84,108 @@ function handleError(error: unknown): never { process.exit(1); } +/** + * Prompt user to choose where to save assistant configuration. + * Implemented as a raw-mode TUI (same as promptModeSelection) to avoid + * stdin state issues after the custom TUI prompts that precede it. + */ +async function promptStorageScope(): Promise<'global' | 'local'> { + const ANSI = { + CLEAR_SCREEN: '\x1B[2J\x1B[H', + HIDE_CURSOR: '\x1B[?25l', + SHOW_CURSOR: '\x1B[?25h', + } as const; + + const KEY = { + UP: '\x1B[A', + DOWN: '\x1B[B', + ENTER: '\r', + ESC: '\x1B', + CTRL_C: '\x03', + } as const; + + const choices = ['global', 'local'] as const; + let selectedIndex = 0; + + function renderUI(): string { + const lines: string[] = [ + '', + ` ${MESSAGES.SETUP.PROMPT_STORAGE_SCOPE}`, + '', + ]; + + choices.forEach((choice, i) => { + const marker = i === selectedIndex ? chalk.cyan('●') : chalk.dim('○'); + const label = choice === 'global' + ? `${chalk.cyan('Global')} ${chalk.dim(MESSAGES.SETUP.STORAGE_GLOBAL_LABEL)}` + : `${chalk.yellow('Local')} ${chalk.dim(MESSAGES.SETUP.STORAGE_LOCAL_LABEL)}`; + lines.push(` ${marker} ${label}`); + }); + + lines.push(''); + lines.push(chalk.dim(' ↑↓ Navigate Enter Confirm')); + + if (selectedIndex === 1) { + lines.push(''); + lines.push(chalk.dim(` ${MESSAGES.SETUP.STORAGE_LOCAL_NOTE}`)); + } + + lines.push(''); + return lines.join('\n'); + } + + return new Promise((resolve) => { + let keepAliveTimer: NodeJS.Timeout | null = null; + + function cleanup() { + if (keepAliveTimer) { + clearInterval(keepAliveTimer); + keepAliveTimer = null; + } + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdin.removeAllListeners('data'); + process.stdout.write(ANSI.SHOW_CURSOR + ANSI.CLEAR_SCREEN); + } + + function stop(choice: 'global' | 'local') { + cleanup(); + resolve(choice); + } + + function render() { + process.stdout.write(ANSI.CLEAR_SCREEN + ANSI.HIDE_CURSOR + renderUI()); + } + + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.setEncoding('utf8'); + + process.stdin.on('data', (key: string) => { + switch (key) { + case KEY.UP: + selectedIndex = Math.max(0, selectedIndex - 1); + render(); + break; + case KEY.DOWN: + selectedIndex = Math.min(choices.length - 1, selectedIndex + 1); + render(); + break; + case KEY.ENTER: + stop(choices[selectedIndex]); + break; + case KEY.ESC: + case KEY.CTRL_C: + stop('global'); + break; + } + }); + + keepAliveTimer = setInterval(() => {}, 60000); + render(); + }); +} + /** * Setup assistants - unified list/register/unregister */ @@ -165,6 +267,9 @@ async function setupAssistants(options: SetupCommandOptions): Promise { } } + // Prompt for storage scope before making any changes + const storageScope = await promptStorageScope(); + // Apply changes and get summary data const { newRegistrations, registered, unregistered } = await applyChanges( selectedIds, @@ -173,12 +278,29 @@ async function setupAssistants(options: SetupCommandOptions): Promise { registrationModes ); - // Update config + // Always reflect new state in config for display purposes config.codemieAssistants = newRegistrations; - await ConfigLoader.saveProfile(profileName, config); + + // Skip saving (and showing configLocation) when nothing changed + if (registered.length === 0 && unregistered.length === 0) { + displaySummary(registered, unregistered, profileName, config); + return; + } + + // Save to the appropriate config location + const workingDir = process.cwd(); + let configLocation: string; + + if (storageScope === 'local') { + await ConfigLoader.saveAssistantsToProjectConfig(workingDir, profileName, newRegistrations); + configLocation = `${workingDir}/.codemie/codemie-cli.config.json`; + } else { + await ConfigLoader.saveProfile(profileName, config); + configLocation = `global (~/.codemie/codemie-cli.config.json)`; + } // Display summary - displaySummary(registered, unregistered, profileName, config); + displaySummary(registered, unregistered, profileName, config, configLocation); } /** diff --git a/src/cli/commands/assistants/setup/summary/index.ts b/src/cli/commands/assistants/setup/summary/index.ts index a80d4152..bd1f8228 100644 --- a/src/cli/commands/assistants/setup/summary/index.ts +++ b/src/cli/commands/assistants/setup/summary/index.ts @@ -18,11 +18,15 @@ export function displaySummary( toRegister: Assistant[], toUnregister: CodemieAssistant[], profileName: string, - config: ProviderProfile + config: ProviderProfile, + configLocation?: string ): void { const totalChanges = toRegister.length + toUnregister.length; console.log(chalk.green(MESSAGES.SETUP.SUMMARY_UPDATED(totalChanges))); console.log(chalk.dim(MESSAGES.SETUP.SUMMARY_PROFILE(profileName))); + if (configLocation) { + console.log(chalk.dim(MESSAGES.SETUP.SUMMARY_CONFIG_LOCATION(configLocation))); + } displayCurrentlyRegistered(config); } diff --git a/src/utils/config.ts b/src/utils/config.ts index 34ec07c6..e57205eb 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -659,6 +659,51 @@ export class ConfigLoader { ); } + /** + * Save assistants configuration to project-local config file + * Only updates the codemieAssistants field in the specified profile, + * leaving all other local config fields untouched. + * Creates the local config file if it does not exist. + */ + static async saveAssistantsToProjectConfig( + workingDir: string, + profileName: string, + assistants: CodemieAssistant[] + ): Promise { + const localConfigPath = path.join(workingDir, this.LOCAL_CONFIG); + const configDir = path.dirname(localConfigPath); + await fs.mkdir(configDir, { recursive: true }); + + const rawConfig = await this.loadJsonConfig(localConfigPath); + + let config: MultiProviderConfig; + + if (isMultiProviderConfig(rawConfig)) { + config = rawConfig; + } else { + config = { + version: 2, + activeProfile: profileName, + profiles: {} + }; + } + + if (config.profiles[profileName]) { + config.profiles[profileName].codemieAssistants = assistants; + } else { + config.profiles[profileName] = { codemieAssistants: assistants }; + } + + // Keep activeProfile pointing at a valid profile. + // Update it to profileName when: no active profile is set, the current active + // profile no longer exists, or this is the only profile in the file. + if (!config.activeProfile || !config.profiles[config.activeProfile] || Object.keys(config.profiles).length === 1) { + config.activeProfile = profileName; + } + + await fs.writeFile(localConfigPath, JSON.stringify(config, null, 2), 'utf-8'); + } + /** * Delete global config file */