Skip to content

Commit 9099b64

Browse files
committed
feat(providers): remove connected provider from model picker
1 parent 3a1d5f8 commit 9099b64

17 files changed

Lines changed: 1657 additions & 6 deletions

‎docs/TUI.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -568,7 +568,12 @@ pair as the default (global `defaultProvider` + that provider's `defaultModel`
568568

569569
- project-local selection) without switching the live session or closing the
570570
picker. Alt+F on a model row
571-
still toggles favorite when a favorite hook is wired. While type-to-filter is
571+
still toggles favorite when a favorite hook is wired. **Alt+R** on a model
572+
row removes that whole provider after a two-press armed confirm: the first
573+
press names what will be forgotten (catalog entry, stored key or auth
574+
profile, and default repair), the second press on the same row deletes it;
575+
Esc, moving focus, or the arm timeout cancels. The live session's own
576+
provider is refused rather than orphaned. While type-to-filter is
572577
active, bare `j`/`k` type into the filter rather than moving the highlight —
573578
use arrow keys (or the filtered list's navigation) to move.
574579

‎src/auth/remove-provider.test.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { mkdtemp, rm } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { describe, expect, test } from "bun:test";
5+
6+
import {
7+
listCodexProfiles,
8+
loadCodexProfile,
9+
saveCodexProfile,
10+
} from "../config/oauth-stores.js";
11+
import {
12+
listXaiProfiles,
13+
loadXaiProfile,
14+
saveXaiProfile,
15+
} from "../config/oauth-stores.js";
16+
import { oauthStoreForProvider } from "./remove-provider.js";
17+
18+
async function withHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
19+
const home = await mkdtemp(join(tmpdir(), "remove-provider-"));
20+
try {
21+
return await fn(home);
22+
} finally {
23+
await rm(home, { recursive: true, force: true });
24+
}
25+
}
26+
27+
describe("oauthStoreForProvider", () => {
28+
test("maps xai/ and codex/ provider names to their store and profile", () => {
29+
expect(oauthStoreForProvider("xai/work")?.profile).toBe("work");
30+
expect(oauthStoreForProvider("codex/personal")?.profile).toBe("personal");
31+
});
32+
33+
test("returns null for API-key, keyless, and degenerate names", () => {
34+
expect(oauthStoreForProvider("openai")).toBeNull();
35+
expect(oauthStoreForProvider("ollama")).toBeNull();
36+
expect(oauthStoreForProvider("my-custom")).toBeNull();
37+
expect(oauthStoreForProvider("xai/")).toBeNull();
38+
expect(oauthStoreForProvider("codex/")).toBeNull();
39+
});
40+
41+
test("removing an xai profile leaves sibling profiles untouched", async () => {
42+
await withHome(async (home) => {
43+
const tokens = { access: "a", refresh: "r", expiresAt: 10_000_000 };
44+
await saveXaiProfile({ name: "work", createdAt: 0, tokens }, home);
45+
await saveXaiProfile({ name: "personal", createdAt: 0, tokens }, home);
46+
47+
const target = oauthStoreForProvider("xai/work");
48+
expect(target?.profile).toBe("work");
49+
expect(await target?.removeProfile(target.profile, home)).toEqual([
50+
"work",
51+
]);
52+
expect(await loadXaiProfile("work", home)).toBeUndefined();
53+
expect(
54+
(await listXaiProfiles(home)).map((profile) => profile.name),
55+
).toEqual(["personal"]);
56+
});
57+
});
58+
59+
test("removing a codex profile leaves sibling profiles untouched", async () => {
60+
await withHome(async (home) => {
61+
const tokens = { access: "a", refresh: "r", expiresAt: 10_000_000 };
62+
await saveCodexProfile({ name: "day", createdAt: 0, tokens }, home);
63+
await saveCodexProfile({ name: "night", createdAt: 0, tokens }, home);
64+
65+
const target = oauthStoreForProvider("codex/night");
66+
expect(await target?.removeProfile(target.profile, home)).toEqual([
67+
"night",
68+
]);
69+
expect(await loadCodexProfile("night", home)).toBeUndefined();
70+
expect(
71+
(await listCodexProfiles(home)).map((profile) => profile.name),
72+
).toEqual(["day"]);
73+
});
74+
});
75+
76+
test("removing an already-absent profile is a no-op", async () => {
77+
await withHome(async (home) => {
78+
const target = oauthStoreForProvider("xai/ghost");
79+
expect(await target?.removeProfile(target.profile, home)).toEqual([]);
80+
});
81+
});
82+
83+
test("missing auth files are tolerated", async () => {
84+
await withHome(async (home) => {
85+
// No profiles ever saved: the store treats a missing file as empty.
86+
const xai = oauthStoreForProvider("xai/work");
87+
expect(await xai?.removeProfile(xai.profile, home)).toEqual([]);
88+
const codex = oauthStoreForProvider("codex/work");
89+
expect(await codex?.removeProfile(codex.profile, home)).toEqual([]);
90+
});
91+
});
92+
});

‎src/auth/remove-provider.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { codexProfileFromProviderName } from "../config/codex-providers.js";
2+
import {
3+
removeCodexProfile,
4+
removeXaiProfile,
5+
} from "../config/oauth-stores.js";
6+
import { xaiProfileFromProviderName } from "../config/xai-providers.js";
7+
8+
/**
9+
* The auth-store side of removing an OAuth-projected provider. Owns no UI
10+
* and performs no settings edits — the caller deletes the catalog row and
11+
* only then drops the credential through this target.
12+
*/
13+
export interface OAuthStoreTarget {
14+
/** Profile name embedded in the "<prefix><profile>" provider name. */
15+
readonly profile: string;
16+
/** Bound store removal; sibling profiles in the same file are untouched. */
17+
readonly removeProfile: (
18+
name: string | undefined,
19+
home?: string,
20+
) => Promise<string[]>;
21+
}
22+
23+
// Maps a catalog provider name to its OAuth auth store + profile using the
24+
// projection helpers (never string-splitting at the call site). Returns null
25+
// for API-key, Custom, Ollama/keyless, and degenerate empty-profile names —
26+
// those delete settings-side only.
27+
export function oauthStoreForProvider(
28+
providerName: string,
29+
): OAuthStoreTarget | null {
30+
const codexProfile = codexProfileFromProviderName(providerName);
31+
if (codexProfile !== undefined && codexProfile.length > 0) {
32+
return { profile: codexProfile, removeProfile: removeCodexProfile };
33+
}
34+
const xaiProfile = xaiProfileFromProviderName(providerName);
35+
if (xaiProfile !== undefined && xaiProfile.length > 0) {
36+
return { profile: xaiProfile, removeProfile: removeXaiProfile };
37+
}
38+
return null;
39+
}

‎src/config/settings.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,86 @@ export function listFavoriteModels(settings: Settings): ModelRef[] {
285285
return settings.favoriteModels ?? [];
286286
}
287287

288+
// Removal report for removeProviderFromSettings, so the caller can build a
289+
// truthful notice without re-deriving what changed.
290+
export interface ProviderRemovalRepair {
291+
/** True when defaultProvider pointed at the removed provider. */
292+
removedDefault: boolean;
293+
/** Repointed default; absent when the default was unset instead. */
294+
newDefaultProvider?: string;
295+
droppedRecents: number;
296+
droppedFavorites: number;
297+
}
298+
299+
// Delete one provider catalog entry plus every reference to it (recents,
300+
// favorites, default). Unknown names are a no-op returning the input
301+
// unchanged. Default repair is deterministic: the live session's provider if
302+
// still configured, else the newest surviving recent whose provider still
303+
// exists, else the sole remaining provider, else unset (valid — optional).
304+
// Pure: no disk I/O, no auth-store knowledge.
305+
export function removeProviderFromSettings(
306+
settings: Settings,
307+
providerName: string,
308+
liveProvider?: string,
309+
): {
310+
settings: Settings;
311+
removed: boolean;
312+
repair: ProviderRemovalRepair;
313+
} {
314+
const emptyRepair: ProviderRemovalRepair = {
315+
removedDefault: false,
316+
droppedRecents: 0,
317+
droppedFavorites: 0,
318+
};
319+
if (settings.providers[providerName] === undefined) {
320+
return { settings, removed: false, repair: emptyRepair };
321+
}
322+
const remaining = Object.fromEntries(
323+
Object.entries(settings.providers).filter(
324+
([name]) => name !== providerName,
325+
),
326+
);
327+
const recents = settings.recentModels ?? [];
328+
const keptRecents = recents.filter((r) => r.provider !== providerName);
329+
const favorites = settings.favoriteModels ?? [];
330+
const keptFavorites = favorites.filter((r) => r.provider !== providerName);
331+
let next: Settings = {
332+
...settings,
333+
providers: remaining,
334+
...(settings.recentModels !== undefined
335+
? { recentModels: keptRecents }
336+
: {}),
337+
...(settings.favoriteModels !== undefined
338+
? { favoriteModels: keptFavorites }
339+
: {}),
340+
};
341+
const repair: ProviderRemovalRepair = {
342+
removedDefault: settings.defaultProvider === providerName,
343+
droppedRecents: recents.length - keptRecents.length,
344+
droppedFavorites: favorites.length - keptFavorites.length,
345+
};
346+
if (settings.defaultProvider === providerName) {
347+
const remainingNames = Object.keys(remaining);
348+
const newestSurvivor = keptRecents.find(
349+
(r) => remaining[r.provider] !== undefined,
350+
)?.provider;
351+
const nextDefault =
352+
liveProvider !== undefined && remaining[liveProvider] !== undefined
353+
? liveProvider
354+
: (newestSurvivor ??
355+
(remainingNames.length === 1 ? remainingNames[0] : undefined));
356+
if (nextDefault === undefined) {
357+
const { defaultProvider: _dropped, ...withoutDefault } = next;
358+
void _dropped;
359+
next = withoutDefault;
360+
} else {
361+
next = { ...next, defaultProvider: nextDefault };
362+
repair.newDefaultProvider = nextDefault;
363+
}
364+
}
365+
return { settings: next, removed: true, repair };
366+
}
367+
288368
// Maps the settings shell block to the shape the shell-guard plugin expects.
289369
// Returns undefined when unset so the plugin applies the 120s foreground
290370
// default itself. timeoutMs overrides that default; maxTimeoutMs clamps the

0 commit comments

Comments
 (0)