@praeviso/code-env-switch 0.1.1 → 0.1.3
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/.github/workflows/npm-publish.yml +25 -0
- package/AGENTS.md +32 -0
- package/PLAN.md +33 -0
- package/README.md +24 -0
- package/README_zh.md +24 -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 +631 -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 +832 -0
- package/code-env.example.json +11 -0
- package/package.json +2 -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 +920 -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 +979 -0
- package/bin/codenv.js +0 -1316
- package/src/codenv.ts +0 -1478
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex CLI status line integration
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from "fs";
|
|
5
|
+
import * as os from "os";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import type { Config } from "../types";
|
|
8
|
+
import { expandEnv, resolvePath } from "../shell/utils";
|
|
9
|
+
import { askConfirm, createReadline } from "../ui";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_CODEX_CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
|
|
12
|
+
const DEFAULT_STATUSLINE_COMMAND = [
|
|
13
|
+
"codenv",
|
|
14
|
+
"statusline",
|
|
15
|
+
"--type",
|
|
16
|
+
"codex",
|
|
17
|
+
"--sync-usage",
|
|
18
|
+
];
|
|
19
|
+
const DEFAULT_SHOW_HINTS = false;
|
|
20
|
+
const DEFAULT_UPDATE_INTERVAL_MS = 300;
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 1000;
|
|
22
|
+
|
|
23
|
+
interface ParsedStatusLineConfig {
|
|
24
|
+
command: string | string[] | null;
|
|
25
|
+
showHints: boolean | null;
|
|
26
|
+
updateIntervalMs: number | null;
|
|
27
|
+
timeoutMs: number | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface DesiredStatusLineConfig {
|
|
31
|
+
command: string | string[];
|
|
32
|
+
showHints: boolean;
|
|
33
|
+
updateIntervalMs: number;
|
|
34
|
+
timeoutMs: number;
|
|
35
|
+
configPath: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface StatusLineSection {
|
|
39
|
+
start: number;
|
|
40
|
+
end: number;
|
|
41
|
+
sectionText: string;
|
|
42
|
+
config: ParsedStatusLineConfig;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseBooleanEnv(value: string | undefined): boolean | null {
|
|
46
|
+
if (value === undefined) return null;
|
|
47
|
+
const normalized = String(value).trim().toLowerCase();
|
|
48
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
49
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveCodexConfigPath(config: Config): string {
|
|
54
|
+
const envOverride = process.env.CODE_ENV_CODEX_CONFIG_PATH;
|
|
55
|
+
if (envOverride && String(envOverride).trim()) {
|
|
56
|
+
const expanded = expandEnv(String(envOverride).trim());
|
|
57
|
+
return resolvePath(expanded) || DEFAULT_CODEX_CONFIG_PATH;
|
|
58
|
+
}
|
|
59
|
+
const configOverride = config.codexStatusline?.configPath;
|
|
60
|
+
if (configOverride && String(configOverride).trim()) {
|
|
61
|
+
const expanded = expandEnv(String(configOverride).trim());
|
|
62
|
+
return resolvePath(expanded) || DEFAULT_CODEX_CONFIG_PATH;
|
|
63
|
+
}
|
|
64
|
+
return DEFAULT_CODEX_CONFIG_PATH;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readConfig(filePath: string): string {
|
|
68
|
+
if (!fs.existsSync(filePath)) return "";
|
|
69
|
+
try {
|
|
70
|
+
return fs.readFileSync(filePath, "utf8");
|
|
71
|
+
} catch {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function stripInlineComment(value: string): string {
|
|
77
|
+
let inSingle = false;
|
|
78
|
+
let inDouble = false;
|
|
79
|
+
for (let i = 0; i < value.length; i++) {
|
|
80
|
+
const ch = value[i];
|
|
81
|
+
if (ch === "\"" && !inSingle && value[i - 1] !== "\\") {
|
|
82
|
+
inDouble = !inDouble;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (ch === "'" && !inDouble && value[i - 1] !== "\\") {
|
|
86
|
+
inSingle = !inSingle;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (!inSingle && !inDouble && (ch === "#" || ch === ";")) {
|
|
90
|
+
return value.slice(0, i).trim();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return value.trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function unquote(value: string): string {
|
|
97
|
+
const trimmed = value.trim();
|
|
98
|
+
if (
|
|
99
|
+
(trimmed.startsWith("\"") && trimmed.endsWith("\"")) ||
|
|
100
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
101
|
+
) {
|
|
102
|
+
return trimmed.slice(1, -1);
|
|
103
|
+
}
|
|
104
|
+
return trimmed;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseCommandValue(raw: string): string | string[] | null {
|
|
108
|
+
const trimmed = raw.trim();
|
|
109
|
+
if (!trimmed) return null;
|
|
110
|
+
if (trimmed.startsWith("[")) {
|
|
111
|
+
const items: string[] = [];
|
|
112
|
+
const regex = /"((?:\\.|[^"\\])*)"/g;
|
|
113
|
+
let match: RegExpExecArray | null = null;
|
|
114
|
+
while ((match = regex.exec(trimmed))) {
|
|
115
|
+
const item = match[1].replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
116
|
+
items.push(item);
|
|
117
|
+
}
|
|
118
|
+
return items.length > 0 ? items : null;
|
|
119
|
+
}
|
|
120
|
+
return unquote(trimmed);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function tokenizeCommand(command: string): string[] {
|
|
124
|
+
const tokens: string[] = [];
|
|
125
|
+
let current = "";
|
|
126
|
+
let inSingle = false;
|
|
127
|
+
let inDouble = false;
|
|
128
|
+
let escape = false;
|
|
129
|
+
for (let i = 0; i < command.length; i++) {
|
|
130
|
+
const ch = command[i];
|
|
131
|
+
if (escape) {
|
|
132
|
+
current += ch;
|
|
133
|
+
escape = false;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (ch === "\\" && !inSingle) {
|
|
137
|
+
escape = true;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (ch === "'" && !inDouble) {
|
|
141
|
+
inSingle = !inSingle;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (ch === "\"" && !inSingle) {
|
|
145
|
+
inDouble = !inDouble;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (!inSingle && !inDouble && /\s/.test(ch)) {
|
|
149
|
+
if (current) {
|
|
150
|
+
tokens.push(current);
|
|
151
|
+
current = "";
|
|
152
|
+
}
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
current += ch;
|
|
156
|
+
}
|
|
157
|
+
if (current) tokens.push(current);
|
|
158
|
+
return tokens;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseBooleanValue(raw: string): boolean | null {
|
|
162
|
+
const normalized = raw.trim().toLowerCase();
|
|
163
|
+
if (normalized === "true") return true;
|
|
164
|
+
if (normalized === "false") return false;
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseNumberValue(raw: string): number | null {
|
|
169
|
+
const trimmed = raw.trim();
|
|
170
|
+
if (!trimmed) return null;
|
|
171
|
+
const parsed = Number(trimmed);
|
|
172
|
+
if (!Number.isFinite(parsed)) return null;
|
|
173
|
+
return Math.floor(parsed);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseStatusLineSection(text: string): StatusLineSection | null {
|
|
177
|
+
const headerRegex = /^\s*\[tui\.status_line\]\s*$/m;
|
|
178
|
+
const match = headerRegex.exec(text);
|
|
179
|
+
if (!match || match.index === undefined) return null;
|
|
180
|
+
const start = match.index;
|
|
181
|
+
const afterHeader = start + match[0].length;
|
|
182
|
+
const rest = text.slice(afterHeader);
|
|
183
|
+
const nextHeaderMatch = rest.match(/^\s*\[.*?\]\s*$/m);
|
|
184
|
+
const end = nextHeaderMatch
|
|
185
|
+
? afterHeader + (nextHeaderMatch.index ?? rest.length)
|
|
186
|
+
: text.length;
|
|
187
|
+
const sectionText = text.slice(start, end).trimEnd();
|
|
188
|
+
const lines = sectionText.split(/\r?\n/).slice(1);
|
|
189
|
+
|
|
190
|
+
const config: ParsedStatusLineConfig = {
|
|
191
|
+
command: null,
|
|
192
|
+
showHints: null,
|
|
193
|
+
updateIntervalMs: null,
|
|
194
|
+
timeoutMs: null,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
for (const line of lines) {
|
|
198
|
+
const trimmed = line.trim();
|
|
199
|
+
if (!trimmed) continue;
|
|
200
|
+
if (trimmed.startsWith("#") || trimmed.startsWith(";")) continue;
|
|
201
|
+
const matchLine = /^([A-Za-z0-9_]+)\s*=\s*(.+)$/.exec(trimmed);
|
|
202
|
+
if (!matchLine) continue;
|
|
203
|
+
const key = matchLine[1];
|
|
204
|
+
const rawValue = stripInlineComment(matchLine[2]);
|
|
205
|
+
if (key === "command") {
|
|
206
|
+
config.command = parseCommandValue(rawValue);
|
|
207
|
+
} else if (key === "show_hints") {
|
|
208
|
+
config.showHints = parseBooleanValue(rawValue);
|
|
209
|
+
} else if (key === "update_interval_ms") {
|
|
210
|
+
config.updateIntervalMs = parseNumberValue(rawValue);
|
|
211
|
+
} else if (key === "timeout_ms") {
|
|
212
|
+
config.timeoutMs = parseNumberValue(rawValue);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
start,
|
|
218
|
+
end,
|
|
219
|
+
sectionText,
|
|
220
|
+
config,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function commandToArray(value: string | string[] | null): string[] | null {
|
|
225
|
+
if (!value) return null;
|
|
226
|
+
if (Array.isArray(value)) {
|
|
227
|
+
return value.map((entry) => String(entry));
|
|
228
|
+
}
|
|
229
|
+
const trimmed = value.trim();
|
|
230
|
+
if (!trimmed) return null;
|
|
231
|
+
return tokenizeCommand(trimmed);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function commandMatches(
|
|
235
|
+
existing: string | string[] | null,
|
|
236
|
+
desired: string | string[]
|
|
237
|
+
): boolean {
|
|
238
|
+
if (!existing) return false;
|
|
239
|
+
const existingTokens = commandToArray(existing);
|
|
240
|
+
const desiredTokens = commandToArray(desired);
|
|
241
|
+
if (existingTokens && desiredTokens) {
|
|
242
|
+
if (existingTokens.length !== desiredTokens.length) return false;
|
|
243
|
+
for (let i = 0; i < desiredTokens.length; i++) {
|
|
244
|
+
if (existingTokens[i] !== desiredTokens[i]) return false;
|
|
245
|
+
}
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
if (typeof existing === "string" && typeof desired === "string") {
|
|
249
|
+
return existing.trim() === desired.trim();
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function configMatches(
|
|
255
|
+
config: ParsedStatusLineConfig,
|
|
256
|
+
desired: DesiredStatusLineConfig
|
|
257
|
+
): boolean {
|
|
258
|
+
if (!commandMatches(config.command, desired.command)) return false;
|
|
259
|
+
if (config.showHints !== desired.showHints) return false;
|
|
260
|
+
if (config.updateIntervalMs !== desired.updateIntervalMs) return false;
|
|
261
|
+
if (config.timeoutMs !== desired.timeoutMs) return false;
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function resolveDesiredCommand(
|
|
266
|
+
config: Config
|
|
267
|
+
): string | string[] {
|
|
268
|
+
const raw = config.codexStatusline?.command;
|
|
269
|
+
if (typeof raw === "string") {
|
|
270
|
+
const trimmed = raw.trim();
|
|
271
|
+
if (trimmed) return trimmed;
|
|
272
|
+
} else if (Array.isArray(raw)) {
|
|
273
|
+
const cleaned = raw
|
|
274
|
+
.map((entry) => String(entry).trim())
|
|
275
|
+
.filter((entry) => entry);
|
|
276
|
+
if (cleaned.length > 0) return cleaned;
|
|
277
|
+
}
|
|
278
|
+
return DEFAULT_STATUSLINE_COMMAND;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function resolveDesiredStatusLineConfig(config: Config): DesiredStatusLineConfig {
|
|
282
|
+
const showHints =
|
|
283
|
+
config.codexStatusline?.showHints ?? DEFAULT_SHOW_HINTS;
|
|
284
|
+
const updateIntervalMs =
|
|
285
|
+
config.codexStatusline?.updateIntervalMs ?? DEFAULT_UPDATE_INTERVAL_MS;
|
|
286
|
+
const timeoutMs =
|
|
287
|
+
config.codexStatusline?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
288
|
+
const command = resolveDesiredCommand(config);
|
|
289
|
+
const configPath = resolveCodexConfigPath(config);
|
|
290
|
+
return {
|
|
291
|
+
command,
|
|
292
|
+
showHints,
|
|
293
|
+
updateIntervalMs,
|
|
294
|
+
timeoutMs,
|
|
295
|
+
configPath,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function formatCommandValue(command: string | string[]): string {
|
|
300
|
+
if (Array.isArray(command)) {
|
|
301
|
+
const parts = command.map((part) => JSON.stringify(part)).join(", ");
|
|
302
|
+
return `[${parts}]`;
|
|
303
|
+
}
|
|
304
|
+
return JSON.stringify(command);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function buildStatusLineSection(desired: DesiredStatusLineConfig): string {
|
|
308
|
+
const commandText = formatCommandValue(desired.command);
|
|
309
|
+
return (
|
|
310
|
+
`[tui.status_line]\n` +
|
|
311
|
+
`command = ${commandText}\n` +
|
|
312
|
+
`show_hints = ${desired.showHints ? "true" : "false"}\n` +
|
|
313
|
+
`update_interval_ms = ${desired.updateIntervalMs}\n` +
|
|
314
|
+
`timeout_ms = ${desired.timeoutMs}\n`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function upsertSection(
|
|
319
|
+
text: string,
|
|
320
|
+
section: StatusLineSection | null,
|
|
321
|
+
newSection: string
|
|
322
|
+
): string {
|
|
323
|
+
if (!section) {
|
|
324
|
+
let base = text;
|
|
325
|
+
if (base && !base.endsWith("\n")) base += "\n";
|
|
326
|
+
if (base && !base.endsWith("\n\n")) base += "\n";
|
|
327
|
+
return `${base}${newSection}`;
|
|
328
|
+
}
|
|
329
|
+
let prefix = text.slice(0, section.start);
|
|
330
|
+
let suffix = text.slice(section.end);
|
|
331
|
+
if (prefix && !prefix.endsWith("\n")) prefix += "\n";
|
|
332
|
+
if (suffix && !suffix.startsWith("\n")) suffix = `\n${suffix}`;
|
|
333
|
+
return `${prefix}${newSection}${suffix}`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function writeConfig(filePath: string, text: string): boolean {
|
|
337
|
+
try {
|
|
338
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
339
|
+
fs.writeFileSync(filePath, text.endsWith("\n") ? text : `${text}\n`, "utf8");
|
|
340
|
+
return true;
|
|
341
|
+
} catch {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function ensureCodexStatuslineConfig(
|
|
347
|
+
config: Config,
|
|
348
|
+
enabled: boolean
|
|
349
|
+
): Promise<boolean> {
|
|
350
|
+
const disabled =
|
|
351
|
+
parseBooleanEnv(process.env.CODE_ENV_CODEX_STATUSLINE_DISABLE) === true;
|
|
352
|
+
if (!enabled || disabled) return false;
|
|
353
|
+
|
|
354
|
+
const desired = resolveDesiredStatusLineConfig(config);
|
|
355
|
+
const configPath = desired.configPath;
|
|
356
|
+
const raw = readConfig(configPath);
|
|
357
|
+
const section = parseStatusLineSection(raw);
|
|
358
|
+
|
|
359
|
+
if (section && configMatches(section.config, desired)) return false;
|
|
360
|
+
|
|
361
|
+
const force =
|
|
362
|
+
parseBooleanEnv(process.env.CODE_ENV_CODEX_STATUSLINE_FORCE) === true;
|
|
363
|
+
|
|
364
|
+
if (section && !force) {
|
|
365
|
+
console.log(`codenv: existing Codex status_line config in ${configPath}:`);
|
|
366
|
+
console.log(section.sectionText);
|
|
367
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
368
|
+
console.warn(
|
|
369
|
+
"codenv: no TTY available to confirm status_line overwrite."
|
|
370
|
+
);
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
const rl = createReadline();
|
|
374
|
+
try {
|
|
375
|
+
const confirm = await askConfirm(
|
|
376
|
+
rl,
|
|
377
|
+
"Overwrite Codex status_line config? (y/N): "
|
|
378
|
+
);
|
|
379
|
+
if (!confirm) return false;
|
|
380
|
+
} finally {
|
|
381
|
+
rl.close();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const updated = upsertSection(raw, section, buildStatusLineSection(desired));
|
|
386
|
+
if (!writeConfig(configPath, updated)) {
|
|
387
|
+
console.error(
|
|
388
|
+
"codenv: failed to write Codex config; status_line not updated."
|
|
389
|
+
);
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
return true;
|
|
393
|
+
}
|