Skip to content
Merged
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
7 changes: 6 additions & 1 deletion src/cli/commands/assistants/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
128 changes: 125 additions & 3 deletions src/cli/commands/assistants/setup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -165,6 +267,9 @@ async function setupAssistants(options: SetupCommandOptions): Promise<void> {
}
}

// 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,
Expand All @@ -173,12 +278,29 @@ async function setupAssistants(options: SetupCommandOptions): Promise<void> {
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);
}

/**
Expand Down
6 changes: 5 additions & 1 deletion src/cli/commands/assistants/setup/summary/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
45 changes: 45 additions & 0 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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
*/
Expand Down