@praeviso/code-env-switch 0.1.0 → 0.1.2

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.
Files changed (78) hide show
  1. package/.eslintrc.cjs +18 -0
  2. package/.github/workflows/npm-publish.yml +25 -0
  3. package/.vscode/settings.json +4 -0
  4. package/AGENTS.md +32 -0
  5. package/LICENSE +21 -0
  6. package/PLAN.md +33 -0
  7. package/README.md +208 -32
  8. package/README_zh.md +265 -0
  9. package/bin/cli/args.js +303 -0
  10. package/bin/cli/help.js +77 -0
  11. package/bin/cli/index.js +13 -0
  12. package/bin/commands/add.js +81 -0
  13. package/bin/commands/index.js +21 -0
  14. package/bin/commands/launch.js +330 -0
  15. package/bin/commands/list.js +57 -0
  16. package/bin/commands/show.js +10 -0
  17. package/bin/commands/statusline.js +12 -0
  18. package/bin/commands/unset.js +20 -0
  19. package/bin/commands/use.js +92 -0
  20. package/bin/config/defaults.js +85 -0
  21. package/bin/config/index.js +20 -0
  22. package/bin/config/io.js +72 -0
  23. package/bin/constants.js +27 -0
  24. package/bin/index.js +279 -0
  25. package/bin/profile/display.js +78 -0
  26. package/bin/profile/index.js +26 -0
  27. package/bin/profile/match.js +40 -0
  28. package/bin/profile/resolve.js +79 -0
  29. package/bin/profile/type.js +90 -0
  30. package/bin/shell/detect.js +40 -0
  31. package/bin/shell/index.js +18 -0
  32. package/bin/shell/snippet.js +92 -0
  33. package/bin/shell/utils.js +35 -0
  34. package/bin/statusline/claude.js +153 -0
  35. package/bin/statusline/codex.js +356 -0
  36. package/bin/statusline/index.js +469 -0
  37. package/bin/types.js +5 -0
  38. package/bin/ui/index.js +16 -0
  39. package/bin/ui/interactive.js +189 -0
  40. package/bin/ui/readline.js +76 -0
  41. package/bin/usage/index.js +709 -0
  42. package/code-env.example.json +36 -23
  43. package/package.json +14 -2
  44. package/src/cli/args.ts +318 -0
  45. package/src/cli/help.ts +75 -0
  46. package/src/cli/index.ts +5 -0
  47. package/src/commands/add.ts +91 -0
  48. package/src/commands/index.ts +10 -0
  49. package/src/commands/launch.ts +395 -0
  50. package/src/commands/list.ts +91 -0
  51. package/src/commands/show.ts +12 -0
  52. package/src/commands/statusline.ts +18 -0
  53. package/src/commands/unset.ts +19 -0
  54. package/src/commands/use.ts +121 -0
  55. package/src/config/defaults.ts +88 -0
  56. package/src/config/index.ts +19 -0
  57. package/src/config/io.ts +69 -0
  58. package/src/constants.ts +28 -0
  59. package/src/index.ts +359 -0
  60. package/src/profile/display.ts +77 -0
  61. package/src/profile/index.ts +12 -0
  62. package/src/profile/match.ts +41 -0
  63. package/src/profile/resolve.ts +84 -0
  64. package/src/profile/type.ts +83 -0
  65. package/src/shell/detect.ts +30 -0
  66. package/src/shell/index.ts +6 -0
  67. package/src/shell/snippet.ts +92 -0
  68. package/src/shell/utils.ts +30 -0
  69. package/src/statusline/claude.ts +172 -0
  70. package/src/statusline/codex.ts +393 -0
  71. package/src/statusline/index.ts +626 -0
  72. package/src/types.ts +95 -0
  73. package/src/ui/index.ts +5 -0
  74. package/src/ui/interactive.ts +220 -0
  75. package/src/ui/readline.ts +85 -0
  76. package/src/usage/index.ts +833 -0
  77. package/tsconfig.json +12 -0
  78. package/bin/codenv.js +0 -377
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Launch codex/claude with session binding
3
+ */
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import { spawn } from "child_process";
7
+ import type { Config, ProfileType } from "../types";
8
+ import { CODEX_AUTH_PATH } from "../constants";
9
+ import { normalizeType } from "../profile/type";
10
+ import {
11
+ getCodexSessionsPath,
12
+ getClaudeSessionsPath,
13
+ logProfileUse,
14
+ logSessionBinding,
15
+ readSessionBindingIndex,
16
+ } from "../usage";
17
+ import { ensureClaudeStatusline } from "../statusline/claude";
18
+ import { ensureCodexStatuslineConfig } from "../statusline/codex";
19
+
20
+ const SESSION_BINDING_POLL_MS = 1000;
21
+ const SESSION_BINDING_START_GRACE_MS = 5000;
22
+
23
+ interface SessionMeta {
24
+ filePath: string;
25
+ sessionId: string | null;
26
+ timestamp: string | null;
27
+ fileTimestampMs: number | null;
28
+ cwd: string | null;
29
+ }
30
+
31
+ function isRecord(value: unknown): value is Record<string, unknown> {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+
35
+ function collectSessionFiles(root: string | null): string[] {
36
+ if (!root || !fs.existsSync(root)) return [];
37
+ const files: string[] = [];
38
+ const stack = [root];
39
+ while (stack.length > 0) {
40
+ const current = stack.pop();
41
+ if (!current) continue;
42
+ let entries: fs.Dirent[] = [];
43
+ try {
44
+ entries = fs.readdirSync(current, { withFileTypes: true });
45
+ } catch {
46
+ continue;
47
+ }
48
+ for (const entry of entries) {
49
+ if (entry.name.startsWith(".")) continue;
50
+ const full = path.join(current, entry.name);
51
+ if (entry.isDirectory()) {
52
+ stack.push(full);
53
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
54
+ files.push(full);
55
+ }
56
+ }
57
+ }
58
+ return files;
59
+ }
60
+
61
+ function readFirstJsonLine(filePath: string): unknown | null {
62
+ let fd: number | null = null;
63
+ try {
64
+ fd = fs.openSync(filePath, "r");
65
+ const buffer = Buffer.alloc(64 * 1024);
66
+ const bytes = fs.readSync(fd, buffer, 0, buffer.length, 0);
67
+ if (bytes <= 0) return null;
68
+ const text = buffer.slice(0, bytes).toString("utf8");
69
+ const line = text.split(/\r?\n/)[0];
70
+ if (!line) return null;
71
+ return JSON.parse(line);
72
+ } catch {
73
+ return null;
74
+ } finally {
75
+ if (fd !== null) {
76
+ try {
77
+ fs.closeSync(fd);
78
+ } catch {
79
+ // ignore
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ function parseCodexFilenameInfo(filePath: string): {
86
+ timestamp: string | null;
87
+ timestampMs: number | null;
88
+ sessionId: string | null;
89
+ } {
90
+ const base = path.basename(filePath);
91
+ const match = base.match(
92
+ /(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-([0-9a-fA-F-]+)\.jsonl$/
93
+ );
94
+ if (!match) {
95
+ return { timestamp: null, timestampMs: null, sessionId: null };
96
+ }
97
+ const [, date, hour, minute, second, sessionId] = match;
98
+ const timestamp = `${date}T${hour}:${minute}:${second}`;
99
+ const parsedMs = new Date(timestamp).getTime();
100
+ return {
101
+ timestamp,
102
+ timestampMs: Number.isNaN(parsedMs) ? null : parsedMs,
103
+ sessionId: sessionId ? String(sessionId) : null,
104
+ };
105
+ }
106
+
107
+ function readSessionMeta(filePath: string, type: ProfileType): SessionMeta | null {
108
+ const first = readFirstJsonLine(filePath);
109
+ if (!isRecord(first)) return null;
110
+ if (type === "codex") {
111
+ const fromName = parseCodexFilenameInfo(filePath);
112
+ const payload = isRecord(first.payload) ? first.payload : null;
113
+ const ts = payload && payload.timestamp ? payload.timestamp : first.timestamp;
114
+ const timestamp = ts ? String(ts) : fromName.timestamp;
115
+ return {
116
+ filePath,
117
+ sessionId:
118
+ payload && payload.id
119
+ ? String(payload.id)
120
+ : fromName.sessionId
121
+ ? String(fromName.sessionId)
122
+ : null,
123
+ timestamp,
124
+ fileTimestampMs: fromName.timestampMs,
125
+ cwd: payload && payload.cwd ? String(payload.cwd) : null,
126
+ };
127
+ }
128
+ return {
129
+ filePath,
130
+ sessionId: first.sessionId ? String(first.sessionId) : null,
131
+ timestamp: first.timestamp ? String(first.timestamp) : null,
132
+ fileTimestampMs: null,
133
+ cwd: first.cwd ? String(first.cwd) : null,
134
+ };
135
+ }
136
+
137
+ function getSessionCandidateTimestamp(meta: SessionMeta, stat: fs.Stats): number | null {
138
+ const times: number[] = [];
139
+ if (Number.isFinite(stat.mtimeMs)) times.push(stat.mtimeMs);
140
+ if (meta.timestamp) {
141
+ const tsMs = new Date(meta.timestamp).getTime();
142
+ if (Number.isFinite(tsMs)) times.push(tsMs);
143
+ }
144
+ if (Number.isFinite(meta.fileTimestampMs)) times.push(meta.fileTimestampMs);
145
+ if (times.length === 0) return null;
146
+ return Math.max(...times);
147
+ }
148
+
149
+ function findLatestUnboundSessionMeta(
150
+ root: string | null,
151
+ type: ProfileType,
152
+ bound: { byFile: Set<string>; byId: Set<string> },
153
+ minTimestampMs: number | null,
154
+ cwd: string | null,
155
+ skipFiles: Set<string> | null
156
+ ): SessionMeta | null {
157
+ const files = collectSessionFiles(root);
158
+ let bestMeta: SessionMeta | null = null;
159
+ let bestTs = Number.NEGATIVE_INFINITY;
160
+ let bestCwdMatch = false;
161
+
162
+ for (const filePath of files) {
163
+ if (bound.byFile.has(filePath)) continue;
164
+ if (skipFiles && skipFiles.has(filePath)) continue;
165
+ let stat: fs.Stats | null = null;
166
+ try {
167
+ stat = fs.statSync(filePath);
168
+ } catch {
169
+ continue;
170
+ }
171
+ if (!stat || !stat.isFile()) continue;
172
+ const meta =
173
+ readSessionMeta(filePath, type) || {
174
+ filePath,
175
+ sessionId: null,
176
+ timestamp: null,
177
+ fileTimestampMs: null,
178
+ cwd: null,
179
+ };
180
+ if (meta.sessionId && bound.byId.has(meta.sessionId)) continue;
181
+ const tsMs = getSessionCandidateTimestamp(meta, stat);
182
+ if (tsMs === null || Number.isNaN(tsMs)) continue;
183
+ if (minTimestampMs !== null && tsMs < minTimestampMs) continue;
184
+ const cwdMatch = Boolean(cwd && meta.cwd && meta.cwd === cwd);
185
+ if (
186
+ !bestMeta ||
187
+ (cwdMatch && !bestCwdMatch) ||
188
+ (cwdMatch === bestCwdMatch && tsMs > bestTs)
189
+ ) {
190
+ bestMeta = meta;
191
+ bestTs = tsMs;
192
+ bestCwdMatch = cwdMatch;
193
+ }
194
+ }
195
+
196
+ return bestMeta;
197
+ }
198
+
199
+ function getProfileEnv(type: ProfileType): { key: string | null; name: string | null } {
200
+ const suffix = type.toUpperCase();
201
+ const key = process.env[`CODE_ENV_PROFILE_KEY_${suffix}`] || null;
202
+ const name = process.env[`CODE_ENV_PROFILE_NAME_${suffix}`] || key;
203
+ return { key, name };
204
+ }
205
+
206
+ function writeCodexAuthFromEnv(): void {
207
+ const apiKey = process.env.OPENAI_API_KEY;
208
+ try {
209
+ fs.mkdirSync(path.dirname(CODEX_AUTH_PATH), { recursive: true });
210
+ } catch {
211
+ // ignore
212
+ }
213
+ const authJson =
214
+ apiKey === null || apiKey === undefined || apiKey === ""
215
+ ? "null"
216
+ : JSON.stringify({ OPENAI_API_KEY: String(apiKey) });
217
+ try {
218
+ fs.writeFileSync(CODEX_AUTH_PATH, `${authJson}\n`, "utf8");
219
+ } catch {
220
+ // ignore
221
+ }
222
+ }
223
+
224
+ function parseBooleanEnv(value: string | undefined): boolean | null {
225
+ if (value === undefined) return null;
226
+ const normalized = String(value).trim().toLowerCase();
227
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
228
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
229
+ return null;
230
+ }
231
+
232
+ function isStatuslineEnabled(type: ProfileType): boolean {
233
+ if (!process.stdout.isTTY || process.env.TERM === "dumb") return false;
234
+ const disable = parseBooleanEnv(process.env.CODE_ENV_STATUSLINE_DISABLE);
235
+ if (disable === true) return false;
236
+ const typeFlag = parseBooleanEnv(
237
+ process.env[`CODE_ENV_STATUSLINE_${type.toUpperCase()}`]
238
+ );
239
+ if (typeFlag !== null) return typeFlag;
240
+ const genericFlag = parseBooleanEnv(process.env.CODE_ENV_STATUSLINE);
241
+ if (genericFlag !== null) return genericFlag;
242
+ return true;
243
+ }
244
+
245
+ async function ensureClaudeStatuslineConfig(
246
+ config: Config,
247
+ enabled: boolean
248
+ ): Promise<void> {
249
+ await ensureClaudeStatusline(config, enabled);
250
+ }
251
+
252
+ export async function runLaunch(
253
+ config: Config,
254
+ configPath: string | null,
255
+ target: string,
256
+ args: string[]
257
+ ): Promise<number> {
258
+ const type = normalizeType(target);
259
+ if (!type) {
260
+ throw new Error(`Unknown launch target: ${target}`);
261
+ }
262
+ if (type === "codex") {
263
+ writeCodexAuthFromEnv();
264
+ }
265
+
266
+ const { key: profileKey, name: profileName } = getProfileEnv(type);
267
+ const terminalTag = process.env.CODE_ENV_TERMINAL_TAG || null;
268
+ const cwd = process.cwd();
269
+ const startMs = Date.now();
270
+ const minBindingTimestampMs = startMs - SESSION_BINDING_START_GRACE_MS;
271
+ const sessionRoot =
272
+ type === "codex" ? getCodexSessionsPath(config) : getClaudeSessionsPath(config);
273
+ const initialBindingIndex = readSessionBindingIndex(config, configPath);
274
+ const initialUnboundFiles = new Set<string>();
275
+ for (const filePath of collectSessionFiles(sessionRoot)) {
276
+ if (!initialBindingIndex.byFile.has(filePath)) {
277
+ initialUnboundFiles.add(filePath);
278
+ }
279
+ }
280
+
281
+ const statuslineEnabled = isStatuslineEnabled(type);
282
+ if (type === "claude") {
283
+ await ensureClaudeStatuslineConfig(config, statuslineEnabled);
284
+ } else if (type === "codex") {
285
+ await ensureCodexStatuslineConfig(config, statuslineEnabled);
286
+ }
287
+ if (profileKey) {
288
+ logProfileUse(config, configPath, profileKey, type, terminalTag, cwd);
289
+ }
290
+ const child = spawn(target, args, { stdio: "inherit", env: process.env });
291
+ const canBindSession = Boolean(profileKey || profileName);
292
+ let boundSession: SessionMeta | null = null;
293
+ let bindingTimer: NodeJS.Timeout | null = null;
294
+
295
+ const tryBindSession = () => {
296
+ if (!canBindSession || boundSession) return;
297
+ const bindingIndex = readSessionBindingIndex(config, configPath);
298
+ const candidate = findLatestUnboundSessionMeta(
299
+ sessionRoot,
300
+ type,
301
+ bindingIndex,
302
+ minBindingTimestampMs,
303
+ cwd,
304
+ initialUnboundFiles
305
+ );
306
+ if (!candidate) return;
307
+ boundSession = candidate;
308
+ logSessionBinding(
309
+ config,
310
+ configPath,
311
+ type,
312
+ profileKey,
313
+ profileName,
314
+ terminalTag,
315
+ cwd,
316
+ boundSession.filePath,
317
+ boundSession.sessionId,
318
+ boundSession.timestamp
319
+ );
320
+ if (bindingTimer) {
321
+ clearInterval(bindingTimer);
322
+ bindingTimer = null;
323
+ }
324
+ };
325
+
326
+ if (canBindSession) {
327
+ tryBindSession();
328
+ bindingTimer = setInterval(tryBindSession, SESSION_BINDING_POLL_MS);
329
+ }
330
+
331
+ const forwardSignal = (signal: NodeJS.Signals) => {
332
+ try {
333
+ child.kill(signal);
334
+ } catch {
335
+ // ignore
336
+ }
337
+ };
338
+ process.on("SIGINT", forwardSignal);
339
+ process.on("SIGTERM", forwardSignal);
340
+
341
+ const exitCode = await new Promise<number>((resolve) => {
342
+ child.on("error", (err) => {
343
+ process.off("SIGINT", forwardSignal);
344
+ process.off("SIGTERM", forwardSignal);
345
+ if (bindingTimer) {
346
+ clearInterval(bindingTimer);
347
+ bindingTimer = null;
348
+ }
349
+ console.error(`codenv: failed to launch ${target}: ${err.message}`);
350
+ resolve(1);
351
+ });
352
+ child.on("exit", (code, signal) => {
353
+ process.off("SIGINT", forwardSignal);
354
+ process.off("SIGTERM", forwardSignal);
355
+ if (bindingTimer) {
356
+ clearInterval(bindingTimer);
357
+ bindingTimer = null;
358
+ }
359
+ const bindingIndex = readSessionBindingIndex(config, configPath);
360
+ const sessionMeta = findLatestUnboundSessionMeta(
361
+ sessionRoot,
362
+ type,
363
+ bindingIndex,
364
+ minBindingTimestampMs,
365
+ cwd,
366
+ initialUnboundFiles
367
+ );
368
+ if (!boundSession && sessionMeta && (profileKey || profileName)) {
369
+ logSessionBinding(
370
+ config,
371
+ configPath,
372
+ type,
373
+ profileKey,
374
+ profileName,
375
+ terminalTag,
376
+ cwd,
377
+ sessionMeta.filePath,
378
+ sessionMeta.sessionId,
379
+ sessionMeta.timestamp
380
+ );
381
+ }
382
+ if (typeof code === "number") {
383
+ resolve(code);
384
+ return;
385
+ }
386
+ if (signal) {
387
+ resolve(1);
388
+ return;
389
+ }
390
+ resolve(0);
391
+ });
392
+ });
393
+
394
+ return exitCode;
395
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * List command - show all profiles
3
+ */
4
+ import type { Config } from "../types";
5
+ import { buildListRows } from "../profile/display";
6
+ import { getResolvedDefaultProfileKeys } from "../config/defaults";
7
+ import {
8
+ formatTokenCount,
9
+ readUsageTotalsIndex,
10
+ resolveUsageTotalsForProfile,
11
+ } from "../usage";
12
+
13
+ export function printList(config: Config, configPath: string | null): void {
14
+ const rows = buildListRows(config, getResolvedDefaultProfileKeys);
15
+ if (rows.length === 0) {
16
+ console.log("(no profiles found)");
17
+ return;
18
+ }
19
+ try {
20
+ const usageTotals = readUsageTotalsIndex(config, configPath, true);
21
+ if (usageTotals) {
22
+ for (const row of rows) {
23
+ if (!row.usageType) continue;
24
+ const usage = resolveUsageTotalsForProfile(
25
+ usageTotals,
26
+ row.usageType,
27
+ row.key,
28
+ row.name
29
+ );
30
+ if (!usage) continue;
31
+ row.todayTokens = usage.today;
32
+ row.totalTokens = usage.total;
33
+ }
34
+ }
35
+ } catch {
36
+ // ignore usage sync errors
37
+ }
38
+ const headerName = "PROFILE";
39
+ const headerType = "TYPE";
40
+ const headerToday = "TODAY";
41
+ const headerTotal = "TOTAL";
42
+ const headerNote = "NOTE";
43
+ const todayTexts = rows.map((row) => formatTokenCount(row.todayTokens));
44
+ const totalTexts = rows.map((row) => formatTokenCount(row.totalTokens));
45
+ const nameWidth = Math.max(
46
+ headerName.length,
47
+ ...rows.map((row) => row.name.length)
48
+ );
49
+ const typeWidth = Math.max(
50
+ headerType.length,
51
+ ...rows.map((row) => row.type.length)
52
+ );
53
+ const todayWidth = Math.max(headerToday.length, ...todayTexts.map((v) => v.length));
54
+ const totalWidth = Math.max(headerTotal.length, ...totalTexts.map((v) => v.length));
55
+ const noteWidth = Math.max(
56
+ headerNote.length,
57
+ ...rows.map((row) => row.note.length)
58
+ );
59
+ const formatRow = (
60
+ name: string,
61
+ type: string,
62
+ today: string,
63
+ total: string,
64
+ note: string
65
+ ) =>
66
+ `${name.padEnd(nameWidth)} ${type.padEnd(typeWidth)} ${today.padStart(
67
+ todayWidth
68
+ )} ${total.padStart(totalWidth)} ${note.padEnd(noteWidth)}`;
69
+
70
+ console.log(formatRow(headerName, headerType, headerToday, headerTotal, headerNote));
71
+ console.log(
72
+ formatRow(
73
+ "-".repeat(nameWidth),
74
+ "-".repeat(typeWidth),
75
+ "-".repeat(todayWidth),
76
+ "-".repeat(totalWidth),
77
+ "-".repeat(noteWidth)
78
+ )
79
+ );
80
+ for (let i = 0; i < rows.length; i++) {
81
+ const row = rows[i];
82
+ const todayText = todayTexts[i] || "-";
83
+ const totalText = totalTexts[i] || "-";
84
+ const line = formatRow(row.name, row.type, todayText, totalText, row.note);
85
+ if (row.active) {
86
+ console.log(`\x1b[32m${line}\x1b[0m`);
87
+ } else {
88
+ console.log(line);
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Show command - display profile details
3
+ */
4
+ import type { Config } from "../types";
5
+
6
+ export function printShow(config: Config, profileName: string): void {
7
+ const profile = config.profiles && config.profiles[profileName];
8
+ if (!profile) {
9
+ throw new Error(`Unknown profile: ${profileName}`);
10
+ }
11
+ console.log(JSON.stringify(profile, null, 2));
12
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Statusline command - output statusline text or JSON
3
+ */
4
+ import type { Config, StatuslineArgs } from "../types";
5
+ import { buildStatuslineResult } from "../statusline";
6
+
7
+ export function printStatusline(
8
+ config: Config,
9
+ configPath: string | null,
10
+ args: StatuslineArgs
11
+ ): void {
12
+ const result = buildStatuslineResult(args, config, configPath);
13
+ if (args.format === "json") {
14
+ console.log(JSON.stringify(result.json));
15
+ return;
16
+ }
17
+ console.log(result.text);
18
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Unset command - print unset statements
3
+ */
4
+ import type { Config } from "../types";
5
+ import { DEFAULT_PROFILE_TYPES } from "../constants";
6
+ import { getTypeDefaultUnsetKeys } from "../config/defaults";
7
+
8
+ export function printUnset(config: Config): void {
9
+ const keySet = new Set<string>();
10
+ if (Array.isArray(config.unset)) {
11
+ for (const key of config.unset) keySet.add(key);
12
+ }
13
+ for (const type of DEFAULT_PROFILE_TYPES) {
14
+ for (const key of getTypeDefaultUnsetKeys(type)) keySet.add(key);
15
+ }
16
+ if (keySet.size === 0) return;
17
+ const lines = Array.from(keySet, (key) => `unset ${key}`);
18
+ console.log(lines.join("\n"));
19
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Use command - apply profile environment
3
+ */
4
+ import * as path from "path";
5
+ import type { Config, ProfileType } from "../types";
6
+ import { CODEX_AUTH_PATH } from "../constants";
7
+ import { shellEscape, expandEnv } from "../shell/utils";
8
+ import { inferProfileType, getProfileDisplayName } from "../profile/type";
9
+ import { shouldRemoveCodexAuth } from "../profile/match";
10
+ import { buildEffectiveEnv } from "../profile/display";
11
+ import { getFilteredUnsetKeys, getTypeDefaultUnsetKeys } from "../config/defaults";
12
+
13
+ export function buildUseLines(
14
+ config: Config,
15
+ profileName: string,
16
+ requestedType: ProfileType | null,
17
+ includeGlobalUnset: boolean,
18
+ configPath: string | null = null
19
+ ): string[] {
20
+ const profile = config.profiles && config.profiles[profileName];
21
+ if (!profile) {
22
+ throw new Error(`Unknown profile: ${profileName}`);
23
+ }
24
+
25
+ const unsetLines: string[] = [];
26
+ const exportLines: string[] = [];
27
+ const postLines: string[] = [];
28
+ const unsetKeys = new Set<string>();
29
+ const activeType = inferProfileType(profileName, profile, requestedType);
30
+ const effectiveEnv = buildEffectiveEnv(profile, activeType);
31
+
32
+ const addUnset = (key: string) => {
33
+ if (unsetKeys.has(key)) return;
34
+ if (Object.prototype.hasOwnProperty.call(effectiveEnv, key)) return;
35
+ unsetKeys.add(key);
36
+ unsetLines.push(`unset ${key}`);
37
+ };
38
+
39
+ if (includeGlobalUnset) {
40
+ for (const key of getFilteredUnsetKeys(config, activeType)) {
41
+ addUnset(key);
42
+ }
43
+ }
44
+
45
+ if (activeType) {
46
+ for (const key of getTypeDefaultUnsetKeys(activeType)) {
47
+ addUnset(key);
48
+ }
49
+ }
50
+
51
+ for (const key of Object.keys(effectiveEnv)) {
52
+ const value = effectiveEnv[key];
53
+ if (value === null || value === undefined || value === "") {
54
+ if (!unsetKeys.has(key)) {
55
+ unsetKeys.add(key);
56
+ unsetLines.push(`unset ${key}`);
57
+ }
58
+ } else {
59
+ exportLines.push(`export ${key}=${shellEscape(value)}`);
60
+ }
61
+ }
62
+
63
+ if (activeType) {
64
+ const typeSuffix = activeType.toUpperCase();
65
+ const displayName = getProfileDisplayName(profileName, profile, activeType);
66
+ exportLines.push(
67
+ `export CODE_ENV_PROFILE_KEY_${typeSuffix}=${shellEscape(profileName)}`
68
+ );
69
+ exportLines.push(
70
+ `export CODE_ENV_PROFILE_NAME_${typeSuffix}=${shellEscape(displayName)}`
71
+ );
72
+ }
73
+ if (configPath) {
74
+ exportLines.push(`export CODE_ENV_CONFIG_PATH=${shellEscape(configPath)}`);
75
+ }
76
+
77
+ if (shouldRemoveCodexAuth(profileName, profile, requestedType)) {
78
+ const codexApiKey = effectiveEnv.OPENAI_API_KEY;
79
+ const authDir = path.dirname(CODEX_AUTH_PATH);
80
+ const authJson =
81
+ codexApiKey === null || codexApiKey === undefined || codexApiKey === ""
82
+ ? "null"
83
+ : JSON.stringify({ OPENAI_API_KEY: String(codexApiKey) });
84
+ postLines.push(`mkdir -p ${shellEscape(authDir)}`);
85
+ postLines.push(
86
+ `printf '%s\\n' ${shellEscape(authJson)} > ${shellEscape(CODEX_AUTH_PATH)}`
87
+ );
88
+ }
89
+
90
+ if (Array.isArray(profile.removeFiles)) {
91
+ for (const p of profile.removeFiles) {
92
+ const expanded = expandEnv(p);
93
+ if (expanded) postLines.push(`rm -f ${shellEscape(expanded)}`);
94
+ }
95
+ }
96
+
97
+ if (Array.isArray(profile.commands)) {
98
+ for (const cmd of profile.commands) {
99
+ if (cmd && String(cmd).trim()) postLines.push(String(cmd));
100
+ }
101
+ }
102
+
103
+ return [...unsetLines, ...exportLines, ...postLines];
104
+ }
105
+
106
+ export function printUse(
107
+ config: Config,
108
+ profileName: string,
109
+ requestedType: ProfileType | null = null,
110
+ includeGlobalUnset = true,
111
+ configPath: string | null = null
112
+ ): void {
113
+ const lines = buildUseLines(
114
+ config,
115
+ profileName,
116
+ requestedType,
117
+ includeGlobalUnset,
118
+ configPath
119
+ );
120
+ console.log(lines.join("\n"));
121
+ }