diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 871991b75..117108a3a 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -2686,8 +2686,11 @@ The example above has no `openai-responses` group, so Codex is left alone; add t teamai models list # every profile: file it comes from, key source, gateway, models, agents, where it is active teamai models list tokenhub # just one profile teamai models switch tokenhub # asks for the key the first time +teamai models switch # lists the profiles and asks which one to use ``` +Run `switch` with no profile and it lists every profile, team ones first, and switches the one you pick; answer `none` to cancel. It takes a single profile, so an answer naming several is asked again rather than silently narrowed. Without a terminal there is nothing to pick from, so the profile is required there. + `switch` updates every installed, compatible agent. Narrow it with `--agent claude` (repeatable), pick the default model with `--model deepseek-v4-flash`, or preview with `--dry-run`. To avoid storing the key, reference an environment variable instead: diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 2d3f47adf..c80284678 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -2517,8 +2517,11 @@ profiles: teamai models list # 全部配置:来源文件、密钥来源、网关、模型、Agent 及生效位置 teamai models list tokenhub # 只看一个配置 teamai models switch tokenhub # 首次使用时提示输入密钥 +teamai models switch # 列出全部配置,询问使用哪一个 ``` +`switch` 不带配置名时会列出全部配置(团队配置在前),并切换到你所选的那个;输入 `none` 可取消。它一次只接受一个配置,因此填了多个会重新询问,而不会静默取第一个。没有终端时无处可选,因此此时必须给出配置名。 + `switch` 会更新所有已安装且兼容的 Agent。可以用 `--agent claude`(可重复)缩小范围,用 `--model deepseek-v4-flash` 指定默认模型,用 `--dry-run` 预览。 如果不想保存密钥,可以改为引用环境变量: diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 9b8ab15b4..3bb10f35a 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -253,7 +253,7 @@ Generated: do not edit by hand. Regenerate with - `--base-url ` — Personal profiles: new gateway root URL - `--protocol ` — Personal profiles: serve models over these protocols too - `--model ` — Personal profiles: add model IDs - - `teamai models switch ` — Point agents at a model profile (every compatible agent by default) + - `teamai models switch [profile]` — Point agents at a model profile (every compatible agent by default); omit the profile to pick one - `--agent ` — Only switch this agent. Repeatable or comma-separated. - `--model ` — Default model to select (defaults to the first in the profile) - `--dry-run` — Show what would change without writing diff --git a/src/__tests__/models-cmd.test.ts b/src/__tests__/models-cmd.test.ts index a12c9f30b..114c9a380 100644 --- a/src/__tests__/models-cmd.test.ts +++ b/src/__tests__/models-cmd.test.ts @@ -5,7 +5,8 @@ import { Readable } from 'node:stream'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { modelsAdd, modelsConfigure, modelsList, modelsRemove, modelsRestore, modelsSwitch } from '../models-cmd.js'; import { getLocalValuesPath, loadLocalProfiles, loadModelInputs } from '../models/profile.js'; -import { askSecret, isInteractive } from '../utils/prompt.js'; +import { askQuestion, askSecret, isInteractive } from '../utils/prompt.js'; +import { log } from '../utils/logger.js'; vi.mock('../utils/logger.js', () => ({ log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, @@ -15,6 +16,7 @@ vi.mock('../utils/prompt.js', async (importOriginal) => ({ ...(await importOriginal()), isInteractive: vi.fn(() => false), askSecret: vi.fn(), + askQuestion: vi.fn(), })); let home: string; @@ -28,6 +30,7 @@ beforeEach(async () => { home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-models-cmd-')); process.env.HOME = home; process.env.MY_MODEL_KEY = 'sk-from-env'; + vi.clearAllMocks(); }); afterEach(async () => { @@ -171,6 +174,86 @@ describe('models commands', () => { await expect(modelsSwitch('mine', {})).rejects.toThrow(/has no API key. Run `teamai models configure local:mine`/); }); + it('lists the profiles and switches to the one picked when no profile is given', async () => { + await fse.outputJson(path.join(home, '.claude', 'settings.json'), {}); + await addMine(); + await modelsAdd('other', { name: 'Other', protocol: 'anthropic', baseUrl: 'https://other.example.test', model: 'glm-5.3', fromEnv: 'MY_MODEL_KEY' }); + vi.mocked(isInteractive).mockReturnValue(true); + vi.mocked(askQuestion).mockResolvedValue('2'); + try { + const output = await captureOutput(() => modelsSwitch(undefined, { agent: ['claude'] })); + expect(output).toContain(' 1. local:mine — Mine (personal)'); + expect(output).toContain(' 2. local:other — Other (personal)'); + expect(output.filter((line) => line.startsWith('switched')).length).toBe(1); + expect((await fse.readJson(path.join(home, '.claude', 'settings.json'))).model).toBe('glm-5.3'); + } finally { + vi.mocked(isInteractive).mockReturnValue(false); + } + }); + + it('leaves agents alone when the pick is cancelled', async () => { + await fse.outputJson(path.join(home, '.claude', 'settings.json'), { model: 'keep' }); + await addMine(); + vi.mocked(isInteractive).mockReturnValue(true); + vi.mocked(askQuestion).mockResolvedValue('none'); + try { + const output = await captureOutput(() => modelsSwitch(undefined, {})); + expect(output.some((line) => line.startsWith('switched'))).toBe(false); + expect(await fse.readJson(path.join(home, '.claude', 'settings.json'))).toEqual({ model: 'keep' }); + } finally { + vi.mocked(isInteractive).mockReturnValue(false); + } + }); + + it('asks again when the pick names more than one profile, instead of taking the first', async () => { + await fse.outputJson(path.join(home, '.claude', 'settings.json'), {}); + await addMine(); + await modelsAdd('other', { name: 'Other', protocol: 'anthropic', baseUrl: 'https://other.example.test', model: 'glm-5.3', fromEnv: 'MY_MODEL_KEY' }); + vi.mocked(isInteractive).mockReturnValue(true); + vi.mocked(askQuestion).mockResolvedValueOnce('1,2').mockResolvedValueOnce('2'); + try { + const output = await captureOutput(() => modelsSwitch(undefined, { agent: ['claude'] })); + expect(vi.mocked(log.warn)).toHaveBeenCalledWith(expect.stringMatching(/takes one profile/)); + // The second answer decides: profile 2 (Other), not the first of "1,2". + expect((await fse.readJson(path.join(home, '.claude', 'settings.json'))).env.ANTHROPIC_BASE_URL) + .toBe('https://other.example.test'); + expect(output.filter((line) => line.startsWith('switched')).length).toBe(1); + } finally { + vi.mocked(isInteractive).mockReturnValue(false); + } + }); + + it('asks again when the pick cannot be used at all', async () => { + await fse.outputJson(path.join(home, '.claude', 'settings.json'), {}); + await addMine(); + vi.mocked(isInteractive).mockReturnValue(true); + vi.mocked(askQuestion).mockResolvedValueOnce('banana').mockResolvedValueOnce('9').mockResolvedValueOnce('1'); + try { + const output = await captureOutput(() => modelsSwitch(undefined, { agent: ['claude'] })); + // Both unusable answers (a word, and a number out of range) are asked again. + expect(vi.mocked(log.warn).mock.calls.filter(([line]) => /Enter one number from 1 to 1/.test(line)).length).toBe(2); + expect((await fse.readJson(path.join(home, '.claude', 'settings.json'))).env.ANTHROPIC_BASE_URL) + .toBe('https://gateway.example.test'); + expect(output.filter((line) => line.startsWith('switched')).length).toBe(1); + } finally { + vi.mocked(isInteractive).mockReturnValue(false); + } + }); + + it('names the profile argument when there is no terminal to pick one with', async () => { + await addMine(); + await expect(modelsSwitch(undefined, {})).rejects.toThrow(/non-interactive.*teamai models switch /s); + }); + + it('reports when there is no profile to pick from', async () => { + vi.mocked(isInteractive).mockReturnValue(true); + try { + await expect(modelsSwitch(undefined, {})).rejects.toThrow(/No model profiles found/); + } finally { + vi.mocked(isInteractive).mockReturnValue(false); + } + }); + it('persists the key asked for on the first interactive switch to a personal profile', async () => { await fse.outputJson(path.join(home, '.claude', 'settings.json'), {}); await addMine(); diff --git a/src/index.ts b/src/index.ts index fbc8f6d1c..4927bb005 100644 --- a/src/index.ts +++ b/src/index.ts @@ -879,12 +879,12 @@ modelsCmd .action((profile: string, cmdOpts) => runModelsCommand((m) => m.modelsConfigure(profile, cmdOpts))); modelsCmd - .command('switch ') - .description('Point agents at a model profile (every compatible agent by default)') + .command('switch [profile]') + .description('Point agents at a model profile (every compatible agent by default); omit the profile to pick one') .option('--agent ', 'Only switch this agent. Repeatable or comma-separated.', collectRepeatable, [] as string[]) .option('--model ', 'Default model to select (defaults to the first in the profile)') .option('--dry-run', 'Show what would change without writing') - .action((profile: string, cmdOpts) => { + .action((profile: string | undefined, cmdOpts) => { const globalOpts = program.opts() as GlobalOptions; return runModelsCommand((m) => m.modelsSwitch(profile, { ...globalOpts, ...cmdOpts })); }); diff --git a/src/models-cmd.ts b/src/models-cmd.ts index 65103ffbc..3d123c8aa 100644 --- a/src/models-cmd.ts +++ b/src/models-cmd.ts @@ -3,7 +3,7 @@ import { autoDetectInit } from './config.js'; import { describeEntryFailure, describeOrigin, reportEntryResolution, resolveEntriesFor } from './namespaced-entries.js'; import { pathExists } from './utils/fs.js'; import { log } from './utils/logger.js'; -import { askConfirmation, askQuestion, askSecret, isInteractive, readStdin } from './utils/prompt.js'; +import { askConfirmation, askQuestion, askSecret, isInteractive, parseSelection, readStdin } from './utils/prompt.js'; import type { LocalConfig } from './types.js'; import { API_KEY_PLACEHOLDER, @@ -233,14 +233,18 @@ async function apiKeyFromOptions(options: ApiKeyOptions): Promise { const [context, local] = await Promise.all([teamContext(options), loadLocalProfiles()]); if (!context) return null; - const ref = resolveProfileRef(reference, context.team, local); + const ref = typeof reference === 'string' ? resolveProfileRef(reference, context.team, local) : reference; if (ref.source === 'team' && context.localConfig) ref.team = getTeamIdentity(context.localConfig); return { ref, local, context }; } @@ -483,8 +487,60 @@ interface SwitchOptions { dryRun?: boolean; } -export async function modelsSwitch(reference: string, options: SwitchOptions): Promise { - const found = await findProfile(reference, { dryRun: options.dryRun }); +/** + * The profile named on the command line, or the one chosen from a numbered + * list when it was omitted. A cancelled pick is not an error. + */ +async function chooseProfile( + reference: string | undefined, + options: { dryRun?: boolean }, +): Promise { + const [context, local] = await Promise.all([teamContext(options), loadLocalProfiles()]); + if (!context) return undefined; + if (reference) return resolveProfileRef(reference, context.team, local); + + const team = context.localConfig ? getTeamIdentity(context.localConfig) : undefined; + // Team profiles first, in the order `models list` shows them. + const refs: ProfileRef[] = [ + ...context.team.profiles.map((profile) => ({ ...resolveProfileRef(`team:${profile.id}`, context.team, local), team })), + ...local.profiles.map((profile): ProfileRef => ({ source: 'local', profile })), + ]; + if (refs.length === 0) throw new Error('No model profiles found. Add one with `teamai models add `.'); + if (!isInteractive()) { + throw new Error('Cannot prompt in non-interactive mode: "Select a profile". Run `teamai models switch `.'); + } + console.log(''); + refs.forEach((ref, index) => { + console.log(` ${index + 1}. ${profileRefName(ref)} — ${ref.profile.name}${ref.source === 'local' ? ' (personal)' : ''}`); + }); + console.log(''); + // `switch` points each agent at exactly one gateway, so the pick is a single + // profile. An answer that names several, or none it can use, is asked again + // rather than silently resolved to the first. + const prompt = `Select a profile [1-${refs.length}, or "none" to cancel]: `; + for (;;) { + const answer = await askQuestion(prompt); + if (answer.toLowerCase() === 'none' || answer === '0') { + log.info('Cancelled'); + return null; + } + const indices = parseSelection(answer, refs.length); + if (!indices) { + log.warn(`Enter one number from 1 to ${refs.length}, or "none" to cancel.`); + continue; + } + if (indices.length > 1) { + log.warn(`switch takes one profile; you named ${indices.length}. Enter a single number.`); + continue; + } + return refs[indices[0]]; + } +} + +export async function modelsSwitch(reference: string | undefined, options: SwitchOptions): Promise { + const chosen = await chooseProfile(reference, { dryRun: options.dryRun }); + if (!chosen) return; + const found = await findProfile(chosen, { dryRun: options.dryRun }); if (!found) return; const { ref, context } = found; const key = profileRefName(ref);