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
3 changes: 3 additions & 0 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 预览。

如果不想保存密钥,可以改为引用环境变量:
Expand Down
2 changes: 1 addition & 1 deletion skill-data/core/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ Generated: do not edit by hand. Regenerate with
- `--base-url <url>` — Personal profiles: new gateway root URL
- `--protocol <protocols>` — Personal profiles: serve models over these protocols too
- `--model <ids>` — Personal profiles: add model IDs
- `teamai models switch <profile>` — 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 <name>` — Only switch this agent. Repeatable or comma-separated.
- `--model <id>` — Default model to select (defaults to the first in the profile)
- `--dry-run` — Show what would change without writing
Expand Down
85 changes: 84 additions & 1 deletion src/__tests__/models-cmd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand All @@ -15,6 +16,7 @@ vi.mock('../utils/prompt.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../utils/prompt.js')>()),
isInteractive: vi.fn(() => false),
askSecret: vi.fn(),
askQuestion: vi.fn(),
}));

let home: string;
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 <profile>/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();
Expand Down
6 changes: 3 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -879,12 +879,12 @@ modelsCmd
.action((profile: string, cmdOpts) => runModelsCommand((m) => m.modelsConfigure(profile, cmdOpts)));

modelsCmd
.command('switch <profile>')
.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 <name>', 'Only switch this agent. Repeatable or comma-separated.', collectRepeatable, [] as string[])
.option('--model <id>', '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 }));
});
Expand Down
66 changes: 61 additions & 5 deletions src/models-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -233,14 +233,18 @@ async function apiKeyFromOptions(options: ApiKeyOptions): Promise<StoredModelInp
return undefined;
}

async function findProfile(reference: string, options: { dryRun?: boolean } = {}): Promise<{
/**
* The profile `reference` names, or one already chosen from a list, with the
* local file and team context a command needs alongside it.
*/
async function findProfile(reference: string | ProfileRef, options: { dryRun?: boolean } = {}): Promise<{
ref: ProfileRef;
local: ModelProfilesFile;
context: TeamModelsContext;
} | null> {
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 };
}
Expand Down Expand Up @@ -483,8 +487,60 @@ interface SwitchOptions {
dryRun?: boolean;
}

export async function modelsSwitch(reference: string, options: SwitchOptions): Promise<void> {
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<ProfileRef | null | undefined> {
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 <id>`.');
if (!isInteractive()) {
throw new Error('Cannot prompt in non-interactive mode: "Select a profile". Run `teamai models switch <profile>`.');
}
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<void> {
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);
Expand Down
Loading