@praeviso/code-env-switch 0.1.8 → 0.1.10

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.
@@ -1,45 +1,32 @@
1
1
  /**
2
- * Codex CLI status line integration
2
+ * Codex CLI status line integration (official schema)
3
3
  */
4
4
  import * as fs from "fs";
5
- import * as os from "os";
6
5
  import * as path from "path";
7
6
  import type { Config } from "../types";
8
- import { expandEnv, resolvePath } from "../shell/utils";
7
+ import { resolveCodexConfigPath } from "../codex/config";
9
8
  import { askConfirm, createReadline } from "../ui";
10
9
 
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;
10
+ interface ParsedTuiConfig {
11
+ statusLineItems: string[] | null;
28
12
  }
29
13
 
30
14
  interface DesiredStatusLineConfig {
31
- command: string | string[];
32
- showHints: boolean;
33
- updateIntervalMs: number;
34
- timeoutMs: number;
15
+ statusLineItems: string[];
35
16
  configPath: string;
36
17
  }
37
18
 
38
- interface StatusLineSection {
19
+ interface TuiSection {
20
+ start: number;
21
+ end: number;
22
+ sectionText: string;
23
+ config: ParsedTuiConfig;
24
+ }
25
+
26
+ interface TomlSectionRange {
39
27
  start: number;
40
28
  end: number;
41
29
  sectionText: string;
42
- config: ParsedStatusLineConfig;
43
30
  }
44
31
 
45
32
  function parseBooleanEnv(value: string | undefined): boolean | null {
@@ -50,18 +37,23 @@ function parseBooleanEnv(value: string | undefined): boolean | null {
50
37
  return null;
51
38
  }
52
39
 
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;
40
+ function resolveDesiredStatusLineItems(config: Config): string[] | null {
41
+ const raw = config.codexStatusline?.items;
42
+ if (!Array.isArray(raw)) return null;
43
+ return raw
44
+ .map((entry) => String(entry).trim())
45
+ .filter((entry) => entry);
46
+ }
47
+
48
+ function resolveDesiredStatusLineConfig(
49
+ config: Config
50
+ ): DesiredStatusLineConfig | null {
51
+ const statusLineItems = resolveDesiredStatusLineItems(config);
52
+ if (statusLineItems === null) return null;
53
+ return {
54
+ statusLineItems,
55
+ configPath: resolveCodexConfigPath(config),
56
+ };
65
57
  }
66
58
 
67
59
  function readConfig(filePath: string): string {
@@ -93,90 +85,84 @@ function stripInlineComment(value: string): string {
93
85
  return value.trim();
94
86
  }
95
87
 
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 = "";
88
+ function hasUnquotedClosingBracket(value: string): boolean {
126
89
  let inSingle = false;
127
90
  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;
91
+ for (let i = 0; i < value.length; i++) {
92
+ const ch = value[i];
93
+ if (ch === "\"" && !inSingle && value[i - 1] !== "\\") {
94
+ inDouble = !inDouble;
138
95
  continue;
139
96
  }
140
- if (ch === "'" && !inDouble) {
97
+ if (ch === "'" && !inDouble && value[i - 1] !== "\\") {
141
98
  inSingle = !inSingle;
142
99
  continue;
143
100
  }
144
- if (ch === "\"" && !inSingle) {
145
- inDouble = !inDouble;
146
- continue;
101
+ if (!inSingle && !inDouble && ch === "]") {
102
+ return true;
147
103
  }
148
- if (!inSingle && !inDouble && /\s/.test(ch)) {
149
- if (current) {
150
- tokens.push(current);
151
- current = "";
152
- }
104
+ }
105
+ return false;
106
+ }
107
+
108
+ function parseTomlStringArray(raw: string): string[] | null {
109
+ const trimmed = raw.trim();
110
+ if (!trimmed.startsWith("[") || !hasUnquotedClosingBracket(trimmed)) {
111
+ return null;
112
+ }
113
+ if (/^\[\s*\]$/.test(trimmed)) return [];
114
+
115
+ const items: string[] = [];
116
+ const regex = /"((?:\\.|[^"\\])*)"|'([^']*)'/g;
117
+ let match: RegExpExecArray | null = null;
118
+ while ((match = regex.exec(trimmed))) {
119
+ if (match[0].startsWith("\"")) {
120
+ items.push(match[1].replace(/\\"/g, "\"").replace(/\\\\/g, "\\"));
153
121
  continue;
154
122
  }
155
- current += ch;
123
+ items.push(match[2] || "");
156
124
  }
157
- if (current) tokens.push(current);
158
- return tokens;
159
- }
160
125
 
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;
126
+ if (items.length === 0) return null;
127
+ return items;
166
128
  }
167
129
 
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);
130
+ function parseStatusLineItems(sectionText: string): string[] | null {
131
+ const lines = sectionText.split(/\r?\n/).slice(1);
132
+
133
+ for (let i = 0; i < lines.length; i++) {
134
+ const trimmed = lines[i].trim();
135
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) continue;
136
+
137
+ const matchLine = /^status_line\s*=\s*(.*)$/.exec(trimmed);
138
+ if (!matchLine) continue;
139
+
140
+ const value = stripInlineComment(matchLine[1]);
141
+ if (!value.startsWith("[")) return null;
142
+
143
+ const parts = [value];
144
+ if (!hasUnquotedClosingBracket(value)) {
145
+ for (let j = i + 1; j < lines.length; j++) {
146
+ const next = stripInlineComment(lines[j]);
147
+ if (!next) continue;
148
+ parts.push(next);
149
+ if (hasUnquotedClosingBracket(next)) break;
150
+ }
151
+ }
152
+
153
+ return parseTomlStringArray(parts.join(" "));
154
+ }
155
+
156
+ return null;
174
157
  }
175
158
 
176
- function parseStatusLineSection(text: string): StatusLineSection | null {
177
- const headerRegex = /^\s*\[tui\.status_line\]\s*$/m;
159
+ function parseSectionByHeader(
160
+ text: string,
161
+ headerRegex: RegExp
162
+ ): TomlSectionRange | null {
178
163
  const match = headerRegex.exec(text);
179
164
  if (!match || match.index === undefined) return null;
165
+
180
166
  const start = match.index;
181
167
  const afterHeader = start + match[0].length;
182
168
  const rest = text.slice(afterHeader);
@@ -184,140 +170,110 @@ function parseStatusLineSection(text: string): StatusLineSection | null {
184
170
  const end = nextHeaderMatch
185
171
  ? afterHeader + (nextHeaderMatch.index ?? rest.length)
186
172
  : 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,
173
+ return {
174
+ start,
175
+ end,
176
+ sectionText: text.slice(start, end).trimEnd(),
195
177
  };
178
+ }
196
179
 
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
- }
180
+ function parseTuiSection(text: string): TuiSection | null {
181
+ const section = parseSectionByHeader(text, /^\s*\[tui\]\s*$/m);
182
+ if (!section) return null;
215
183
 
216
184
  return {
217
- start,
218
- end,
219
- sectionText,
220
- config,
185
+ start: section.start,
186
+ end: section.end,
187
+ sectionText: section.sectionText,
188
+ config: {
189
+ statusLineItems: parseStatusLineItems(section.sectionText),
190
+ },
221
191
  };
222
192
  }
223
193
 
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);
194
+ function parseLegacyStatusLineSection(text: string): TomlSectionRange | null {
195
+ return parseSectionByHeader(text, /^\s*\[tui\.status_line\]\s*$/m);
232
196
  }
233
197
 
234
- function commandMatches(
235
- existing: string | string[] | null,
236
- desired: string | string[]
198
+ function statusLineItemsMatch(
199
+ existing: string[] | null,
200
+ desired: string[]
237
201
  ): boolean {
238
202
  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();
203
+ if (existing.length !== desired.length) return false;
204
+ for (let i = 0; i < desired.length; i++) {
205
+ if (existing[i] !== desired[i]) return false;
250
206
  }
251
- return false;
207
+ return true;
252
208
  }
253
209
 
254
210
  function configMatches(
255
- config: ParsedStatusLineConfig,
211
+ config: ParsedTuiConfig,
256
212
  desired: DesiredStatusLineConfig
257
213
  ): 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;
214
+ return statusLineItemsMatch(config.statusLineItems, desired.statusLineItems);
263
215
  }
264
216
 
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;
217
+ function formatStatusLineItems(items: string[]): string {
218
+ const parts = items.map((item) => JSON.stringify(item)).join(", ");
219
+ return `[${parts}]`;
279
220
  }
280
221
 
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
- }
222
+ function removeStatusLineSettingLines(lines: string[]): string[] {
223
+ const kept: string[] = [];
224
+ let inMultilineArray = false;
225
+
226
+ for (const line of lines) {
227
+ const trimmed = line.trim();
228
+
229
+ if (inMultilineArray) {
230
+ const value = stripInlineComment(trimmed);
231
+ if (hasUnquotedClosingBracket(value)) {
232
+ inMultilineArray = false;
233
+ }
234
+ continue;
235
+ }
236
+
237
+ const matchLine = /^status_line\s*=\s*(.*)$/.exec(trimmed);
238
+ if (!matchLine) {
239
+ kept.push(line);
240
+ continue;
241
+ }
298
242
 
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}]`;
243
+ const value = stripInlineComment(matchLine[1]);
244
+ if (value.startsWith("[") && !hasUnquotedClosingBracket(value)) {
245
+ inMultilineArray = true;
246
+ }
303
247
  }
304
- return JSON.stringify(command);
248
+
249
+ return kept;
305
250
  }
306
251
 
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
- );
252
+ function buildTuiSection(
253
+ section: TuiSection | null,
254
+ desired: DesiredStatusLineConfig
255
+ ): string {
256
+ const statusLine = `status_line = ${formatStatusLineItems(desired.statusLineItems)}`;
257
+
258
+ if (!section) {
259
+ return `[tui]\n${statusLine}\n`;
260
+ }
261
+
262
+ const lines = section.sectionText.split(/\r?\n/);
263
+ const header = lines[0].trim() === "[tui]" ? lines[0] : "[tui]";
264
+ const body = removeStatusLineSettingLines(lines.slice(1));
265
+
266
+ while (body.length > 0 && body[body.length - 1].trim() === "") {
267
+ body.pop();
268
+ }
269
+
270
+ body.push(statusLine);
271
+ return `${header}\n${body.join("\n")}\n`;
316
272
  }
317
273
 
318
274
  function upsertSection(
319
275
  text: string,
320
- section: StatusLineSection | null,
276
+ section: TuiSection | null,
321
277
  newSection: string
322
278
  ): string {
323
279
  if (!section) {
@@ -326,6 +282,7 @@ function upsertSection(
326
282
  if (base && !base.endsWith("\n\n")) base += "\n";
327
283
  return `${base}${newSection}`;
328
284
  }
285
+
329
286
  let prefix = text.slice(0, section.start);
330
287
  let suffix = text.slice(section.end);
331
288
  if (prefix && !prefix.endsWith("\n")) prefix += "\n";
@@ -333,6 +290,17 @@ function upsertSection(
333
290
  return `${prefix}${newSection}${suffix}`;
334
291
  }
335
292
 
293
+ function removeSection(
294
+ text: string,
295
+ section: TomlSectionRange
296
+ ): string {
297
+ let prefix = text.slice(0, section.start);
298
+ let suffix = text.slice(section.end);
299
+ if (prefix && !prefix.endsWith("\n")) prefix += "\n";
300
+ if (suffix && !suffix.startsWith("\n")) suffix = `\n${suffix}`;
301
+ return `${prefix}${suffix}`;
302
+ }
303
+
336
304
  function writeConfig(filePath: string, text: string): boolean {
337
305
  try {
338
306
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -352,21 +320,32 @@ export async function ensureCodexStatuslineConfig(
352
320
  if (!enabled || disabled) return false;
353
321
 
354
322
  const desired = resolveDesiredStatusLineConfig(config);
323
+ if (!desired) return false;
324
+
355
325
  const configPath = desired.configPath;
356
326
  const raw = readConfig(configPath);
357
- const section = parseStatusLineSection(raw);
327
+ const legacySection = parseLegacyStatusLineSection(raw);
328
+ const base = legacySection ? removeSection(raw, legacySection) : raw;
329
+ const section = parseTuiSection(base);
358
330
 
359
331
  if (section && configMatches(section.config, desired)) return false;
360
332
 
361
333
  const force =
362
334
  parseBooleanEnv(process.env.CODE_ENV_CODEX_STATUSLINE_FORCE) === true;
335
+ const hasExistingStatusLine = Boolean(
336
+ (section && Array.isArray(section.config.statusLineItems)) || legacySection
337
+ );
363
338
 
364
- if (section && !force) {
365
- console.log(`codenv: existing Codex status_line config in ${configPath}:`);
366
- console.log(section.sectionText);
339
+ if (hasExistingStatusLine && !force) {
340
+ console.log(`codenv: existing Codex tui.status_line config in ${configPath}:`);
341
+ if (section && Array.isArray(section.config.statusLineItems)) {
342
+ console.log(section.sectionText);
343
+ } else if (legacySection) {
344
+ console.log(legacySection.sectionText);
345
+ }
367
346
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
368
347
  console.warn(
369
- "codenv: no TTY available to confirm status_line overwrite."
348
+ "codenv: no TTY available to confirm tui.status_line overwrite."
370
349
  );
371
350
  return false;
372
351
  }
@@ -374,7 +353,7 @@ export async function ensureCodexStatuslineConfig(
374
353
  try {
375
354
  const confirm = await askConfirm(
376
355
  rl,
377
- "Overwrite Codex status_line config? (y/N): "
356
+ "Overwrite Codex tui.status_line config? (y/N): "
378
357
  );
379
358
  if (!confirm) return false;
380
359
  } finally {
@@ -382,10 +361,10 @@ export async function ensureCodexStatuslineConfig(
382
361
  }
383
362
  }
384
363
 
385
- const updated = upsertSection(raw, section, buildStatusLineSection(desired));
364
+ const updated = upsertSection(base, section, buildTuiSection(section, desired));
386
365
  if (!writeConfig(configPath, updated)) {
387
366
  console.error(
388
- "codenv: failed to write Codex config; status_line not updated."
367
+ "codenv: failed to write Codex config; tui.status_line not updated."
389
368
  );
390
369
  return false;
391
370
  }
@@ -6,6 +6,7 @@ import { normalizeType, inferProfileType, getProfileDisplayName } from "../profi
6
6
  import {
7
7
  readUsageCostIndex,
8
8
  readUsageSessionCost,
9
+ resolveProfileFromLog,
9
10
  resolveUsageCostForProfile,
10
11
  syncUsageFromStatuslineInput,
11
12
  } from "../usage";
@@ -62,7 +63,7 @@ export function buildStatuslineResult(
62
63
  let type = normalizeTypeValue(typeCandidate);
63
64
  const envProfile = resolveEnvProfile(type);
64
65
 
65
- const profileKey = firstNonEmpty(
66
+ let profileKey = firstNonEmpty(
66
67
  args.profileKey,
67
68
  envProfile.key,
68
69
  inputProfile ? inputProfile.key : null
@@ -73,6 +74,21 @@ export function buildStatuslineResult(
73
74
  inputProfile ? inputProfile.name : null
74
75
  );
75
76
 
77
+ const terminalTag = process.env.CODE_ENV_TERMINAL_TAG || null;
78
+ if (!profileKey && !profileName) {
79
+ const fallback = resolveProfileFromLog(
80
+ config,
81
+ configPath,
82
+ normalizeType(type || ""),
83
+ terminalTag
84
+ );
85
+ if (fallback) {
86
+ profileKey = fallback.profileKey;
87
+ profileName = fallback.profileName;
88
+ }
89
+ }
90
+
91
+ const sessionId = getSessionId(stdinInput);
76
92
  if (profileKey && !profileName && config.profiles && config.profiles[profileKey]) {
77
93
  const profile = config.profiles[profileKey];
78
94
  profileName = getProfileDisplayName(profileKey, profile, type || undefined);
@@ -96,7 +112,6 @@ export function buildStatuslineResult(
96
112
  process.cwd()
97
113
  )!;
98
114
 
99
- const sessionId = getSessionId(stdinInput);
100
115
  const usageType = normalizeType(type || "");
101
116
  const stdinUsageTotals = getUsageTotalsFromInput(stdinInput, usageType);
102
117
  const shouldSyncUsageFromSessions =
package/src/types.ts CHANGED
@@ -19,10 +19,7 @@ export interface Profile {
19
19
  }
20
20
 
21
21
  export interface CodexStatuslineConfig {
22
- command?: string | string[];
23
- showHints?: boolean;
24
- updateIntervalMs?: number;
25
- timeoutMs?: number;
22
+ items?: string[];
26
23
  configPath?: string;
27
24
  }
28
25