Skip to content

Commit cf879e2

Browse files
Merge pull request #1297 from corbitsdev/feat/remove-provider-1294
feat(providers): remove connected provider from model picker
2 parents 1ba2599 + b223f4c commit cf879e2

17 files changed

Lines changed: 1839 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: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { mkdtemp, rm } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, 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 {
17+
clearSourceCredentials,
18+
registerSourceCredentialRecord,
19+
} from "../config/source-credentials.js";
20+
import { oauthStoreForProvider } from "./remove-provider.js";
21+
22+
function registerOAuthProvider(
23+
providerName: string,
24+
provider: "codex" | "xai",
25+
profile: string,
26+
): void {
27+
registerSourceCredentialRecord(providerName, {
28+
provenance: { kind: "oauth", provider, profile },
29+
material: { secret: "token" },
30+
});
31+
}
32+
33+
async function withHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
34+
const home = await mkdtemp(join(tmpdir(), "remove-provider-"));
35+
try {
36+
return await fn(home);
37+
} finally {
38+
await rm(home, { recursive: true, force: true });
39+
}
40+
}
41+
42+
describe("oauthStoreForProvider", () => {
43+
afterEach(() => {
44+
clearSourceCredentials();
45+
});
46+
47+
test("maps xai/ and codex/ provider names to their store and profile", () => {
48+
clearSourceCredentials();
49+
registerOAuthProvider("xai/work", "xai", "work");
50+
registerOAuthProvider("codex/personal", "codex", "personal");
51+
expect(oauthStoreForProvider("xai/work")?.profile).toBe("work");
52+
expect(oauthStoreForProvider("codex/personal")?.profile).toBe("personal");
53+
});
54+
55+
test("rejects namespaced custom providers without OAuth ownership", () => {
56+
clearSourceCredentials();
57+
registerSourceCredentialRecord("codex/manual", {
58+
provenance: { kind: "api-key" },
59+
material: { secret: "manual-key" },
60+
});
61+
registerSourceCredentialRecord("xai/manual", {
62+
provenance: { kind: "api-key" },
63+
material: { secret: "manual-key" },
64+
});
65+
66+
expect(oauthStoreForProvider("codex/manual")).toBeNull();
67+
expect(oauthStoreForProvider("xai/manual")).toBeNull();
68+
});
69+
70+
test("returns null for API-key, keyless, and degenerate names", () => {
71+
expect(oauthStoreForProvider("openai")).toBeNull();
72+
expect(oauthStoreForProvider("ollama")).toBeNull();
73+
expect(oauthStoreForProvider("my-custom")).toBeNull();
74+
expect(oauthStoreForProvider("xai/")).toBeNull();
75+
expect(oauthStoreForProvider("codex/")).toBeNull();
76+
});
77+
78+
test("removing an xai profile leaves sibling profiles untouched", async () => {
79+
await withHome(async (home) => {
80+
clearSourceCredentials();
81+
registerOAuthProvider("xai/work", "xai", "work");
82+
const tokens = { access: "a", refresh: "r", expiresAt: 10_000_000 };
83+
await saveXaiProfile({ name: "work", createdAt: 0, tokens }, home);
84+
await saveXaiProfile({ name: "personal", createdAt: 0, tokens }, home);
85+
86+
const target = oauthStoreForProvider("xai/work");
87+
expect(target?.profile).toBe("work");
88+
expect(await target?.removeProfile(target.profile, home)).toEqual([
89+
"work",
90+
]);
91+
expect(await loadXaiProfile("work", home)).toBeUndefined();
92+
expect(
93+
(await listXaiProfiles(home)).map((profile) => profile.name),
94+
).toEqual(["personal"]);
95+
});
96+
});
97+
98+
test("removing a codex profile leaves sibling profiles untouched", async () => {
99+
await withHome(async (home) => {
100+
clearSourceCredentials();
101+
registerOAuthProvider("codex/night", "codex", "night");
102+
const tokens = { access: "a", refresh: "r", expiresAt: 10_000_000 };
103+
await saveCodexProfile({ name: "day", createdAt: 0, tokens }, home);
104+
await saveCodexProfile({ name: "night", createdAt: 0, tokens }, home);
105+
106+
const target = oauthStoreForProvider("codex/night");
107+
expect(await target?.removeProfile(target.profile, home)).toEqual([
108+
"night",
109+
]);
110+
expect(await loadCodexProfile("night", home)).toBeUndefined();
111+
expect(
112+
(await listCodexProfiles(home)).map((profile) => profile.name),
113+
).toEqual(["day"]);
114+
});
115+
});
116+
117+
test("removing an already-absent profile is a no-op", async () => {
118+
await withHome(async (home) => {
119+
clearSourceCredentials();
120+
registerOAuthProvider("xai/ghost", "xai", "ghost");
121+
const target = oauthStoreForProvider("xai/ghost");
122+
expect(await target?.removeProfile(target.profile, home)).toEqual([]);
123+
});
124+
});
125+
126+
test("missing auth files are tolerated", async () => {
127+
await withHome(async (home) => {
128+
clearSourceCredentials();
129+
registerOAuthProvider("xai/work", "xai", "work");
130+
registerOAuthProvider("codex/work", "codex", "work");
131+
// No profiles ever saved: the store treats a missing file as empty.
132+
const xai = oauthStoreForProvider("xai/work");
133+
expect(await xai?.removeProfile(xai.profile, home)).toEqual([]);
134+
const codex = oauthStoreForProvider("codex/work");
135+
expect(await codex?.removeProfile(codex.profile, home)).toEqual([]);
136+
});
137+
});
138+
});

‎src/auth/remove-provider.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { codexProfileFromProviderName } from "../config/codex-providers.js";
2+
import {
3+
removeCodexProfile,
4+
removeXaiProfile,
5+
} from "../config/oauth-stores.js";
6+
import { findSourceCredentialRecord } from "../config/source-credentials.js";
7+
import { xaiProfileFromProviderName } from "../config/xai-providers.js";
8+
9+
/**
10+
* The auth-store side of removing an OAuth-projected provider. Owns no UI
11+
* and performs no settings edits — the caller deletes the catalog row and
12+
* only then drops the credential through this target.
13+
*/
14+
export interface OAuthStoreTarget {
15+
/** Profile name embedded in the "<prefix><profile>" provider name. */
16+
readonly profile: string;
17+
/** Bound store removal; sibling profiles in the same file are untouched. */
18+
readonly removeProfile: (
19+
name: string | undefined,
20+
home?: string,
21+
) => Promise<string[]>;
22+
}
23+
24+
// Maps a catalog provider to its OAuth auth store only when the live source
25+
// credential record proves ownership of the same provider family and profile.
26+
// Names alone are user-controlled and cannot authorize credential deletion.
27+
export function oauthStoreForProvider(
28+
providerName: string,
29+
): OAuthStoreTarget | null {
30+
const provenance = findSourceCredentialRecord(providerName)?.provenance;
31+
if (provenance?.kind !== "oauth") return null;
32+
const codexProfile = codexProfileFromProviderName(providerName);
33+
if (
34+
provenance.provider === "codex" &&
35+
codexProfile === provenance.profile &&
36+
codexProfile.length > 0
37+
) {
38+
return { profile: codexProfile, removeProfile: removeCodexProfile };
39+
}
40+
const xaiProfile = xaiProfileFromProviderName(providerName);
41+
if (
42+
provenance.provider === "xai" &&
43+
xaiProfile === provenance.profile &&
44+
xaiProfile.length > 0
45+
) {
46+
return { profile: xaiProfile, removeProfile: removeXaiProfile };
47+
}
48+
return null;
49+
}

‎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)