@hieplp/pi-account-switcher 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -10
- package/USAGE.md +80 -40
- package/package.json +3 -2
- package/src/commands/accounts/add.ts +9 -1
- package/src/commands/accounts/edit.ts +15 -1
- package/src/commands/accounts/index.ts +8 -2
- package/src/commands/accounts/list.ts +43 -25
- package/src/commands/accounts/peers.ts +59 -0
- package/src/commands/accounts/set-subagent-account.ts +64 -0
- package/src/commands/accounts/shared/prompts.ts +35 -10
- package/src/commands/accounts/subagent.ts +50 -0
- package/src/commands/accounts/switch.ts +16 -24
- package/src/commands/dirs/dirs.ts +176 -0
- package/src/commands/dirs/index.ts +9 -0
- package/src/commands/index.ts +2 -0
- package/src/constants/commands.ts +5 -1
- package/src/constants/providers.ts +60 -5
- package/src/index.ts +8 -2
- package/src/runtime/account-switcher-runtime.ts +99 -31
- package/src/runtime/account-switcher.ts +2 -1
- package/src/schemas/accounts.ts +1 -0
- package/src/schemas/config.ts +3 -1
- package/src/services/accounts.ts +53 -6
- package/src/storage/accounts.ts +27 -9
- package/src/storage/state.ts +122 -4
- package/src/types/accounts.ts +2 -0
- package/src/types/config.ts +5 -1
- package/src/utils/accounts.ts +52 -0
- package/src/utils/common.ts +27 -0
- package/src/utils/filterable-selector.ts +10 -1
- package/src/utils/providers.ts +3 -2
package/src/services/accounts.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { accountUtil, providerUtil, uiUtil } from "@/utils";
|
|
|
4
4
|
|
|
5
5
|
export interface AccountService {
|
|
6
6
|
load(): Promise<void>;
|
|
7
|
+
setSessionKey(sessionKey: string): void;
|
|
7
8
|
getAccounts(): AccountConfig[];
|
|
8
9
|
findAccountsByProvider(provider: string, providers: ProviderConfig[]): AccountConfig[];
|
|
9
10
|
getActiveAccount(): AccountConfig | undefined;
|
|
@@ -13,6 +14,8 @@ export interface AccountService {
|
|
|
13
14
|
activateAccount(account: AccountConfig, ctx: AccountSwitcherContext, authProvider?: string): Promise<string>;
|
|
14
15
|
getActiveModelState(): { id: string; provider: string } | undefined;
|
|
15
16
|
saveActiveModel(id: string, provider: string): Promise<void>;
|
|
17
|
+
setDefaultAccountId(id: string): Promise<void>;
|
|
18
|
+
getDefaultAccountId(): Promise<string | undefined>;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export function useAccountService(accountsPath: string, statePath?: string): AccountService {
|
|
@@ -28,18 +31,51 @@ class AccountServiceImpl implements AccountService {
|
|
|
28
31
|
private activeAccountId: string | undefined;
|
|
29
32
|
private activeModelId: string | undefined;
|
|
30
33
|
private activeModelProvider: string | undefined;
|
|
34
|
+
private sessionKey: string | undefined;
|
|
31
35
|
|
|
32
36
|
constructor(
|
|
33
37
|
private readonly store: AccountStore,
|
|
34
38
|
private readonly stateStore: StateStore,
|
|
35
39
|
) {}
|
|
36
40
|
|
|
41
|
+
setSessionKey(sessionKey: string): void {
|
|
42
|
+
this.sessionKey = sessionKey;
|
|
43
|
+
}
|
|
44
|
+
|
|
37
45
|
async load(): Promise<void> {
|
|
46
|
+
// Apply config TTL to state store before loading
|
|
47
|
+
const config = await this.store.loadConfig();
|
|
48
|
+
this.stateStore.setCleanupDays(config.stateCleanupDays ?? 30);
|
|
49
|
+
|
|
38
50
|
this.accounts = await this.store.load();
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
const key = this.sessionKey ?? "default";
|
|
52
|
+
const state = await this.stateStore.loadSession(key);
|
|
53
|
+
|
|
54
|
+
// Session key state only — no fallback to defaultAccountId here.
|
|
55
|
+
// The full cascade (session → dirs → defaultAccountId) is in runtime.init()
|
|
56
|
+
// so dir-based matching runs before we fall back to the default.
|
|
57
|
+
if (state.activeAccountId) {
|
|
58
|
+
this.activeAccountId = state.activeAccountId;
|
|
59
|
+
this.activeModelId = state.activeModelId;
|
|
60
|
+
this.activeModelProvider = state.activeModelProvider;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Legacy cleanup (always runs): if sessions.default still exists from
|
|
64
|
+
// old format migration, promote its account to config-level default
|
|
65
|
+
// (if not set yet) and clear the key. This is dead data — sessions
|
|
66
|
+
// should only have per-session keys once migration completes.
|
|
67
|
+
if (await this.stateStore.sessionExists("default")) {
|
|
68
|
+
const legacyDefault = await this.stateStore.loadSession("default");
|
|
69
|
+
if (legacyDefault.activeAccountId && !(await this.getDefaultAccountId())) {
|
|
70
|
+
await this.setDefaultAccountId(legacyDefault.activeAccountId);
|
|
71
|
+
}
|
|
72
|
+
await this.stateStore.deleteSession("default");
|
|
73
|
+
if (!this.activeAccountId && legacyDefault.activeAccountId) {
|
|
74
|
+
this.activeAccountId = legacyDefault.activeAccountId;
|
|
75
|
+
this.activeModelId = legacyDefault.activeModelId;
|
|
76
|
+
this.activeModelProvider = legacyDefault.activeModelProvider;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
43
79
|
}
|
|
44
80
|
|
|
45
81
|
getAccounts(): AccountConfig[] {
|
|
@@ -100,17 +136,28 @@ class AccountServiceImpl implements AccountService {
|
|
|
100
136
|
applied = accountUtil.applyResolvedAccountEnv(account, resolved, ctx.modelRegistry, authProvider);
|
|
101
137
|
}
|
|
102
138
|
this.activeAccountId = account.id;
|
|
139
|
+
// Persist active account ID for subagent (cross-process) inheritance
|
|
140
|
+
process.env.PI_ACCOUNT_SWITCHER_ACTIVE_ID = account.id;
|
|
103
141
|
await this.flushState();
|
|
104
142
|
uiUtil.setAccountStatus(ctx.ui, account.label);
|
|
105
143
|
if (account.piAuth) return "via OAuth";
|
|
106
144
|
return applied.length > 0 ? applied.join(", ") : "";
|
|
107
145
|
}
|
|
108
146
|
|
|
147
|
+
async setDefaultAccountId(id: string): Promise<void> {
|
|
148
|
+
await this.store.setDefaultAccountId(id);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async getDefaultAccountId(): Promise<string | undefined> {
|
|
152
|
+
const config = await this.store.loadConfig();
|
|
153
|
+
return config.defaultAccountId;
|
|
154
|
+
}
|
|
155
|
+
|
|
109
156
|
private async flushState(): Promise<void> {
|
|
110
|
-
await this.stateStore.
|
|
157
|
+
await this.stateStore.saveSession(this.sessionKey ?? "default", {
|
|
111
158
|
activeAccountId: this.activeAccountId,
|
|
112
159
|
activeModelId: this.activeModelId,
|
|
113
160
|
activeModelProvider: this.activeModelProvider,
|
|
114
161
|
});
|
|
115
162
|
}
|
|
116
|
-
}
|
|
163
|
+
}
|
package/src/storage/accounts.ts
CHANGED
|
@@ -5,9 +5,11 @@ import { errorUtil, fileUtil } from "@/utils";
|
|
|
5
5
|
|
|
6
6
|
export interface AccountStore {
|
|
7
7
|
load(): Promise<AccountConfig[]>;
|
|
8
|
+
loadConfig(): Promise<AccountSwitcherConfig>;
|
|
8
9
|
addAccount(account: AccountConfig): Promise<AccountConfig[]>;
|
|
9
10
|
replaceAccount(originalId: string, account: AccountConfig): Promise<AccountConfig[]>;
|
|
10
11
|
removeAccount(id: string): Promise<AccountConfig[]>;
|
|
12
|
+
setDefaultAccountId(id: string): Promise<void>;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export function useAccountStore(path: string) {
|
|
@@ -22,18 +24,20 @@ class AccountStoreImpl implements AccountStore {
|
|
|
22
24
|
constructor(private readonly path: string) {}
|
|
23
25
|
|
|
24
26
|
async load(): Promise<AccountConfig[]> {
|
|
27
|
+
const config = await this.loadConfig();
|
|
28
|
+
return config.accounts;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async loadConfig(): Promise<AccountSwitcherConfig> {
|
|
25
32
|
try {
|
|
26
33
|
const raw = await readFile(this.path, "utf8");
|
|
27
34
|
const parsed = configSchema.parse(JSON.parse(raw));
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
accountValidator.assertAccountsHaveCredentials(accounts);
|
|
32
|
-
|
|
33
|
-
return accounts;
|
|
35
|
+
accountValidator.assertNoDuplicateAccounts(parsed.accounts);
|
|
36
|
+
accountValidator.assertAccountsHaveCredentials(parsed.accounts);
|
|
37
|
+
return parsed;
|
|
34
38
|
} catch (error) {
|
|
35
39
|
if (fileUtil.isMissingFileError(error)) {
|
|
36
|
-
return [];
|
|
40
|
+
return { accounts: [] };
|
|
37
41
|
}
|
|
38
42
|
throw new Error(`Failed to load accounts at ${this.path}: ${errorUtil.format(error)}`);
|
|
39
43
|
}
|
|
@@ -67,9 +71,23 @@ class AccountStoreImpl implements AccountStore {
|
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
private async save(accounts: AccountConfig[]): Promise<void> {
|
|
70
|
-
const
|
|
74
|
+
const { defaultAccountId, stateCleanupDays } = await this.loadConfig();
|
|
75
|
+
const config: AccountSwitcherConfig = {
|
|
76
|
+
accounts,
|
|
77
|
+
...(defaultAccountId ? { defaultAccountId } : {}),
|
|
78
|
+
...(stateCleanupDays !== undefined ? { stateCleanupDays } : {}),
|
|
79
|
+
};
|
|
71
80
|
await fileUtil.writePrivateJson(this.path, config);
|
|
72
81
|
}
|
|
82
|
+
|
|
83
|
+
async setDefaultAccountId(id: string): Promise<void> {
|
|
84
|
+
const config = await this.loadConfig();
|
|
85
|
+
if (!config.accounts.some((a) => a.id === id)) {
|
|
86
|
+
throw new Error(`Account not found: ${id}`);
|
|
87
|
+
}
|
|
88
|
+
const updated: AccountSwitcherConfig = { ...config, defaultAccountId: id };
|
|
89
|
+
await fileUtil.writePrivateJson(this.path, updated);
|
|
90
|
+
}
|
|
73
91
|
}
|
|
74
92
|
|
|
75
93
|
// ===============================================================================================
|
|
@@ -106,4 +124,4 @@ const accountValidator = {
|
|
|
106
124
|
throw new Error(`Accounts missing credentials: ${missing.sort().join(", ")}`);
|
|
107
125
|
}
|
|
108
126
|
},
|
|
109
|
-
};
|
|
127
|
+
};
|
package/src/storage/state.ts
CHANGED
|
@@ -3,36 +3,77 @@ import z from "zod";
|
|
|
3
3
|
import { STATE_PATH } from "@/constants";
|
|
4
4
|
import { fileUtil } from "@/utils";
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const sessionStateSchema = z.object({
|
|
7
7
|
activeAccountId: z.string().optional(),
|
|
8
8
|
activeModelId: z.string().optional(),
|
|
9
9
|
activeModelProvider: z.string().optional(),
|
|
10
|
+
lastActive: z.string().optional(),
|
|
10
11
|
});
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
const appStateSchema = z.preprocess(
|
|
14
|
+
(raw) => {
|
|
15
|
+
// Migrate legacy flat format: { activeAccountId, ... } → { sessions: { default: { ... } } }
|
|
16
|
+
if (
|
|
17
|
+
raw &&
|
|
18
|
+
typeof raw === "object" &&
|
|
19
|
+
!("sessions" in raw) &&
|
|
20
|
+
("activeAccountId" in raw || "activeModelId" in raw)
|
|
21
|
+
) {
|
|
22
|
+
return { sessions: { default: raw } };
|
|
23
|
+
}
|
|
24
|
+
return raw;
|
|
25
|
+
},
|
|
26
|
+
z.object({
|
|
27
|
+
sessions: z.record(z.string(), sessionStateSchema).default({}),
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export interface SessionState {
|
|
13
32
|
activeAccountId?: string;
|
|
14
33
|
activeModelId?: string;
|
|
15
34
|
activeModelProvider?: string;
|
|
35
|
+
lastActive?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AppState {
|
|
39
|
+
sessions: Record<string, SessionState>;
|
|
16
40
|
}
|
|
17
41
|
|
|
18
42
|
export interface StateStore {
|
|
19
43
|
load(): Promise<AppState>;
|
|
20
44
|
save(state: AppState): Promise<void>;
|
|
45
|
+
loadSession(sessionKey: string): Promise<SessionState>;
|
|
46
|
+
saveSession(sessionKey: string, state: SessionState): Promise<void>;
|
|
47
|
+
deleteSession(sessionKey: string): Promise<void>;
|
|
48
|
+
sessionExists(sessionKey: string): Promise<boolean>;
|
|
49
|
+
setCleanupDays(days: number): void;
|
|
21
50
|
}
|
|
22
51
|
|
|
23
52
|
export function useStateStore(path = STATE_PATH): StateStore {
|
|
24
53
|
return new StateStoreImpl(path);
|
|
25
54
|
}
|
|
26
55
|
|
|
56
|
+
// ===============================================================================================
|
|
57
|
+
// State Store Implementation
|
|
58
|
+
// ===============================================================================================
|
|
59
|
+
|
|
60
|
+
const HARD_CAP = 500;
|
|
61
|
+
|
|
27
62
|
class StateStoreImpl implements StateStore {
|
|
63
|
+
private cleanupDays = 30;
|
|
64
|
+
|
|
28
65
|
constructor(private readonly path: string) {}
|
|
29
66
|
|
|
67
|
+
setCleanupDays(days: number): void {
|
|
68
|
+
this.cleanupDays = days;
|
|
69
|
+
}
|
|
70
|
+
|
|
30
71
|
async load(): Promise<AppState> {
|
|
31
72
|
try {
|
|
32
73
|
const raw = await readFile(this.path, "utf8");
|
|
33
74
|
return appStateSchema.parse(JSON.parse(raw));
|
|
34
75
|
} catch (error) {
|
|
35
|
-
if (fileUtil.isMissingFileError(error)) return {};
|
|
76
|
+
if (fileUtil.isMissingFileError(error)) return { sessions: {} };
|
|
36
77
|
throw error;
|
|
37
78
|
}
|
|
38
79
|
}
|
|
@@ -40,4 +81,81 @@ class StateStoreImpl implements StateStore {
|
|
|
40
81
|
async save(state: AppState): Promise<void> {
|
|
41
82
|
await fileUtil.writePrivateJson(this.path, state);
|
|
42
83
|
}
|
|
43
|
-
|
|
84
|
+
|
|
85
|
+
async loadSession(sessionKey: string): Promise<SessionState> {
|
|
86
|
+
const appState = await this.load();
|
|
87
|
+
return appState.sessions[sessionKey] ?? {};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async saveSession(sessionKey: string, state: SessionState): Promise<void> {
|
|
91
|
+
const appState = await this.load();
|
|
92
|
+
|
|
93
|
+
// Migration: timestamp any pre-upgrade entries so they become TTL-eligible
|
|
94
|
+
for (const [key, entry] of Object.entries(appState.sessions)) {
|
|
95
|
+
if (!entry.lastActive) {
|
|
96
|
+
appState.sessions[key] = { ...entry, lastActive: new Date().toISOString() };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Stamp lastActive on the session being written
|
|
101
|
+
const stampedState: SessionState = { ...state, lastActive: new Date().toISOString() };
|
|
102
|
+
appState.sessions[sessionKey] = stampedState;
|
|
103
|
+
|
|
104
|
+
// Prune before saving
|
|
105
|
+
const pruned = this.pruneSessions(appState.sessions);
|
|
106
|
+
appState.sessions = pruned;
|
|
107
|
+
|
|
108
|
+
await this.save(appState);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async deleteSession(sessionKey: string): Promise<void> {
|
|
112
|
+
const appState = await this.load();
|
|
113
|
+
delete appState.sessions[sessionKey];
|
|
114
|
+
await this.save(appState);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async sessionExists(sessionKey: string): Promise<boolean> {
|
|
118
|
+
const appState = await this.load();
|
|
119
|
+
return sessionKey in appState.sessions;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Prune sessions in two passes:
|
|
124
|
+
* 1. TTL eviction: remove entries where lastActive exists AND entry is older than cleanupDays
|
|
125
|
+
* 2. Hard cap: if entries > 500, keep only the 500 most recent
|
|
126
|
+
*
|
|
127
|
+
* Entries without lastActive are preserved (pre-upgrade safety).
|
|
128
|
+
* Entries with invalid lastActive are treated as "recent enough" (not evicted).
|
|
129
|
+
*/
|
|
130
|
+
private pruneSessions(sessions: Record<string, SessionState>): Record<string, SessionState> {
|
|
131
|
+
const entries = Object.entries(sessions);
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
const ttlMs = this.cleanupDays * 86400000;
|
|
134
|
+
|
|
135
|
+
// TTL pass — keep entries that are either:
|
|
136
|
+
// - without lastActive (pre-upgrade safety)
|
|
137
|
+
// - with valid lastActive that is within TTL
|
|
138
|
+
const ttlFiltered = entries.filter(([, state]) => {
|
|
139
|
+
if (!state.lastActive) return true; // Preserve pre-upgrade entries
|
|
140
|
+
const ts = new Date(state.lastActive).getTime();
|
|
141
|
+
if (isNaN(ts)) return true; // Preserve invalid timestamps
|
|
142
|
+
return now - ts <= ttlMs;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Hard cap pass — keep only the 500 most recent
|
|
146
|
+
if (ttlFiltered.length <= HARD_CAP) {
|
|
147
|
+
return Object.fromEntries(ttlFiltered);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Sort by lastActive ascending (oldest first), treating undefined as 0
|
|
151
|
+
const sorted = [...ttlFiltered].sort(([, a], [, b]) => {
|
|
152
|
+
const aTs = a.lastActive ? new Date(a.lastActive).getTime() : 0;
|
|
153
|
+
const bTs = b.lastActive ? new Date(b.lastActive).getTime() : 0;
|
|
154
|
+
return aTs - bTs;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Keep the last 500 (most recent)
|
|
158
|
+
const trimmed = sorted.slice(-HARD_CAP);
|
|
159
|
+
return Object.fromEntries(trimmed);
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/types/accounts.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface AccountConfig {
|
|
|
29
29
|
usesProviderApiKey?: boolean;
|
|
30
30
|
/** Optional model to switch to when this account is activated. */
|
|
31
31
|
model?: string;
|
|
32
|
+
/** Directories where this account should be the default (CWD-based auto-select). */
|
|
33
|
+
dirs?: string[];
|
|
32
34
|
/** Captured Pi /login credentials for built-in OAuth/subscription providers. */
|
|
33
35
|
piAuth?: {
|
|
34
36
|
provider: ProviderId;
|
package/src/types/config.ts
CHANGED
|
@@ -3,4 +3,8 @@ import { AccountConfig } from "./accounts";
|
|
|
3
3
|
export interface AccountSwitcherConfig {
|
|
4
4
|
accounts: AccountConfig[];
|
|
5
5
|
switchMode?: "env";
|
|
6
|
-
|
|
6
|
+
/** Config-level fallback account when no session state or dirs match. */
|
|
7
|
+
defaultAccountId?: string;
|
|
8
|
+
/** Number of days after which inactive sessions are eligible for GC. Default: 30. */
|
|
9
|
+
stateCleanupDays?: number;
|
|
10
|
+
}
|
package/src/utils/accounts.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { providerUtil } from "./providers";
|
|
|
7
7
|
|
|
8
8
|
export const accountUtil = {
|
|
9
9
|
clearAccountEnv: async (account: AccountConfig, modelRegistry?: ModelRegistry): Promise<void> => {
|
|
10
|
+
// Clear the cross-process inheritance env var
|
|
11
|
+
delete process.env.PI_ACCOUNT_SWITCHER_ACTIVE_ID;
|
|
10
12
|
const authProvider = account.piAuth?.provider ?? providerUtil.normalizeProvider(account.provider);
|
|
11
13
|
if (!account.piAuth && account.env) {
|
|
12
14
|
for (const envName of Object.keys(account.env)) {
|
|
@@ -88,6 +90,56 @@ export const accountUtil = {
|
|
|
88
90
|
},
|
|
89
91
|
};
|
|
90
92
|
|
|
93
|
+
function normalizeDir(dir: string): string {
|
|
94
|
+
return dir.replace(/\/$/, "");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Check if an account has a specific directory.
|
|
99
|
+
* Normalizes trailing slashes before comparison.
|
|
100
|
+
*/
|
|
101
|
+
export function hasDir<T extends { dirs?: string[] }>(account: T, dir: string): boolean {
|
|
102
|
+
const dirs = account.dirs;
|
|
103
|
+
if (!dirs || dirs.length === 0) return false;
|
|
104
|
+
const normalized = normalizeDir(dir);
|
|
105
|
+
return dirs.some((d) => normalizeDir(d) === normalized);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Add a directory to an account.
|
|
110
|
+
* Returns a new AccountConfig with the dir added, or null if the dir already exists.
|
|
111
|
+
* Dirs are kept sorted for consistent display.
|
|
112
|
+
*/
|
|
113
|
+
export function addDirToAccount<T extends { id: string; label: string; provider: string; dirs?: string[] }>(
|
|
114
|
+
account: T,
|
|
115
|
+
dir: string,
|
|
116
|
+
): T | null {
|
|
117
|
+
if (hasDir(account, dir)) return null;
|
|
118
|
+
|
|
119
|
+
const existing = account.dirs ?? [];
|
|
120
|
+
const newDirs = [...existing, dir].sort();
|
|
121
|
+
return { ...account, dirs: newDirs };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Remove a directory from an account.
|
|
126
|
+
* Returns a new AccountConfig with the dir removed, or null if the dir does not exist.
|
|
127
|
+
*/
|
|
128
|
+
export function removeDirFromAccount<T extends { id: string; label: string; provider: string; dirs?: string[] }>(
|
|
129
|
+
account: T,
|
|
130
|
+
dir: string,
|
|
131
|
+
): T | null {
|
|
132
|
+
const dirs = account.dirs;
|
|
133
|
+
if (!dirs || dirs.length === 0) return null;
|
|
134
|
+
|
|
135
|
+
const normalized = normalizeDir(dir);
|
|
136
|
+
const filtered = dirs.filter((d) => normalizeDir(d) !== normalized);
|
|
137
|
+
|
|
138
|
+
if (filtered.length === dirs.length) return null;
|
|
139
|
+
if (filtered.length === 0) return { ...account, dirs: undefined };
|
|
140
|
+
return { ...account, dirs: filtered };
|
|
141
|
+
}
|
|
142
|
+
|
|
91
143
|
function closeCachedSessions(): void {
|
|
92
144
|
// Dynamic import so the module is not required at load time — @earendil-works/pi-ai
|
|
93
145
|
// is a peerDependency provided by the pi agent host, not bundled with this package.
|
package/src/utils/common.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { exec, execFile } from "node:child_process";
|
|
2
|
+
import { homedir } from "node:os";
|
|
2
3
|
import { promisify } from "node:util";
|
|
3
4
|
|
|
4
5
|
const execAsync = promisify(exec);
|
|
@@ -87,3 +88,29 @@ export const commonUtil = {
|
|
|
87
88
|
return results;
|
|
88
89
|
},
|
|
89
90
|
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Find the account whose dirs contain the longest prefix of cwd.
|
|
94
|
+
* Returns the account id, or undefined if no match.
|
|
95
|
+
* On tie (same dir length on two accounts), first-in-array wins.
|
|
96
|
+
*/
|
|
97
|
+
export function findLongestMatchingDir<T extends { id: string; dirs?: string[] }>(
|
|
98
|
+
accounts: T[],
|
|
99
|
+
cwd: string,
|
|
100
|
+
): string | undefined {
|
|
101
|
+
let bestId: string | undefined;
|
|
102
|
+
let bestLen = -1;
|
|
103
|
+
for (const account of accounts) {
|
|
104
|
+
const dirs = account.dirs;
|
|
105
|
+
if (!dirs || dirs.length === 0) continue;
|
|
106
|
+
for (const dir of dirs) {
|
|
107
|
+
const resolved = dir.startsWith("~") ? dir.replace("~", homedir()) : dir;
|
|
108
|
+
const normalized = resolved.replace(/\/+$/, "");
|
|
109
|
+
if ((cwd === normalized || cwd.startsWith(normalized + "/")) && normalized.length > bestLen) {
|
|
110
|
+
bestLen = normalized.length;
|
|
111
|
+
bestId = account.id;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return bestId;
|
|
116
|
+
}
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Container,
|
|
3
|
+
fuzzyFilter,
|
|
4
|
+
getKeybindings,
|
|
5
|
+
Input,
|
|
6
|
+
Spacer,
|
|
7
|
+
Text,
|
|
8
|
+
type Focusable,
|
|
9
|
+
type Component,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
2
11
|
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
3
12
|
|
|
4
13
|
/**
|
package/src/utils/providers.ts
CHANGED
|
@@ -22,9 +22,10 @@ export const providerUtil = {
|
|
|
22
22
|
);
|
|
23
23
|
},
|
|
24
24
|
|
|
25
|
-
providerChoices: (customProviders: ProviderConfig[] = []): string[] => {
|
|
25
|
+
providerChoices: (customProviders: ProviderConfig[] = [], piProviderIds: string[] = []): string[] => {
|
|
26
26
|
const customIds = customProviders.map((p) => providerUtil.normalizeProvider(p.id)).sort();
|
|
27
|
-
|
|
27
|
+
const dynamicIds = [...new Set(piProviderIds.map(providerUtil.normalizeProvider))].sort();
|
|
28
|
+
return [...new Set([...BUILT_IN_PROVIDER_IDS, ...dynamicIds, ...customIds, "custom"])];
|
|
28
29
|
},
|
|
29
30
|
|
|
30
31
|
hasProvider: (provider: string, providers: ProviderConfig[]): boolean => {
|