@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.
- package/.eslintrc.cjs +18 -0
- package/.github/workflows/npm-publish.yml +25 -0
- package/.vscode/settings.json +4 -0
- package/AGENTS.md +32 -0
- package/LICENSE +21 -0
- package/PLAN.md +33 -0
- package/README.md +208 -32
- package/README_zh.md +265 -0
- package/bin/cli/args.js +303 -0
- package/bin/cli/help.js +77 -0
- package/bin/cli/index.js +13 -0
- package/bin/commands/add.js +81 -0
- package/bin/commands/index.js +21 -0
- package/bin/commands/launch.js +330 -0
- package/bin/commands/list.js +57 -0
- package/bin/commands/show.js +10 -0
- package/bin/commands/statusline.js +12 -0
- package/bin/commands/unset.js +20 -0
- package/bin/commands/use.js +92 -0
- package/bin/config/defaults.js +85 -0
- package/bin/config/index.js +20 -0
- package/bin/config/io.js +72 -0
- package/bin/constants.js +27 -0
- package/bin/index.js +279 -0
- package/bin/profile/display.js +78 -0
- package/bin/profile/index.js +26 -0
- package/bin/profile/match.js +40 -0
- package/bin/profile/resolve.js +79 -0
- package/bin/profile/type.js +90 -0
- package/bin/shell/detect.js +40 -0
- package/bin/shell/index.js +18 -0
- package/bin/shell/snippet.js +92 -0
- package/bin/shell/utils.js +35 -0
- package/bin/statusline/claude.js +153 -0
- package/bin/statusline/codex.js +356 -0
- package/bin/statusline/index.js +469 -0
- package/bin/types.js +5 -0
- package/bin/ui/index.js +16 -0
- package/bin/ui/interactive.js +189 -0
- package/bin/ui/readline.js +76 -0
- package/bin/usage/index.js +709 -0
- package/code-env.example.json +36 -23
- package/package.json +14 -2
- package/src/cli/args.ts +318 -0
- package/src/cli/help.ts +75 -0
- package/src/cli/index.ts +5 -0
- package/src/commands/add.ts +91 -0
- package/src/commands/index.ts +10 -0
- package/src/commands/launch.ts +395 -0
- package/src/commands/list.ts +91 -0
- package/src/commands/show.ts +12 -0
- package/src/commands/statusline.ts +18 -0
- package/src/commands/unset.ts +19 -0
- package/src/commands/use.ts +121 -0
- package/src/config/defaults.ts +88 -0
- package/src/config/index.ts +19 -0
- package/src/config/io.ts +69 -0
- package/src/constants.ts +28 -0
- package/src/index.ts +359 -0
- package/src/profile/display.ts +77 -0
- package/src/profile/index.ts +12 -0
- package/src/profile/match.ts +41 -0
- package/src/profile/resolve.ts +84 -0
- package/src/profile/type.ts +83 -0
- package/src/shell/detect.ts +30 -0
- package/src/shell/index.ts +6 -0
- package/src/shell/snippet.ts +92 -0
- package/src/shell/utils.ts +30 -0
- package/src/statusline/claude.ts +172 -0
- package/src/statusline/codex.ts +393 -0
- package/src/statusline/index.ts +626 -0
- package/src/types.ts +95 -0
- package/src/ui/index.ts +5 -0
- package/src/ui/interactive.ts +220 -0
- package/src/ui/readline.ts +85 -0
- package/src/usage/index.ts +833 -0
- package/tsconfig.json +12 -0
- 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
|
+
}
|