@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,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile resolution utilities
|
|
3
|
+
*/
|
|
4
|
+
import type { Config, ProfileType } from "../types";
|
|
5
|
+
import { normalizeType } from "./type";
|
|
6
|
+
import { profileMatchesType, findProfileKeysByName } from "./match";
|
|
7
|
+
|
|
8
|
+
export function generateProfileKey(config: Config): string {
|
|
9
|
+
const profiles = config && config.profiles ? config.profiles : {};
|
|
10
|
+
for (let i = 0; i < 10; i++) {
|
|
11
|
+
const key = `p_${Date.now().toString(36)}_${Math.random()
|
|
12
|
+
.toString(36)
|
|
13
|
+
.slice(2, 8)}`;
|
|
14
|
+
if (!profiles[key]) return key;
|
|
15
|
+
}
|
|
16
|
+
let idx = 0;
|
|
17
|
+
while (true) {
|
|
18
|
+
const key = `p_${Date.now().toString(36)}_${idx}`;
|
|
19
|
+
if (!profiles[key]) return key;
|
|
20
|
+
idx++;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resolveProfileName(config: Config, params: string[]): string {
|
|
25
|
+
if (!params || params.length === 0) {
|
|
26
|
+
throw new Error("Missing profile name.");
|
|
27
|
+
}
|
|
28
|
+
if (params.length >= 2) {
|
|
29
|
+
const maybeType = normalizeType(params[0]);
|
|
30
|
+
if (maybeType) {
|
|
31
|
+
const name = params[1];
|
|
32
|
+
return resolveProfileByType(config, maybeType, name, params[0]);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const name = params[0];
|
|
36
|
+
const profiles = config && config.profiles ? config.profiles : {};
|
|
37
|
+
if (profiles[name]) return name;
|
|
38
|
+
const matches = findProfileKeysByName(config, name, null);
|
|
39
|
+
if (matches.length === 1) return matches[0];
|
|
40
|
+
if (matches.length > 1) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Multiple profiles named "${name}". ` +
|
|
43
|
+
`Use: codenv <type> ${name} (or profile key: ${matches.join(", ")})`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return name;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveProfileByType(
|
|
50
|
+
config: Config,
|
|
51
|
+
type: ProfileType,
|
|
52
|
+
name: string,
|
|
53
|
+
rawType: string
|
|
54
|
+
): string {
|
|
55
|
+
if (!name) throw new Error("Missing profile name.");
|
|
56
|
+
const profiles = config && config.profiles ? config.profiles : {};
|
|
57
|
+
|
|
58
|
+
if (profiles[name] && profileMatchesType(profiles[name], type)) {
|
|
59
|
+
return name;
|
|
60
|
+
}
|
|
61
|
+
const matches = findProfileKeysByName(config, name, type);
|
|
62
|
+
if (matches.length === 1) return matches[0];
|
|
63
|
+
if (matches.length > 1) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Multiple profiles named "${name}" for type "${type}". ` +
|
|
66
|
+
`Use profile key: ${matches.join(", ")}`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (rawType) {
|
|
70
|
+
const prefixes: string[] = [];
|
|
71
|
+
const raw = String(rawType).trim();
|
|
72
|
+
if (raw && raw.toLowerCase() !== type) prefixes.push(raw);
|
|
73
|
+
prefixes.push(type);
|
|
74
|
+
for (const prefix of prefixes) {
|
|
75
|
+
for (const sep of ["-", "_", "."]) {
|
|
76
|
+
const candidate = `${prefix}${sep}${name}`;
|
|
77
|
+
if (profiles[candidate] && profileMatchesType(profiles[candidate], type)) {
|
|
78
|
+
return candidate;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
throw new Error(`Unknown profile for type "${type}": ${name}`);
|
|
84
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile type handling
|
|
3
|
+
*/
|
|
4
|
+
import type { Profile, ProfileType } from "../types";
|
|
5
|
+
|
|
6
|
+
export function normalizeType(value: string | null | undefined): ProfileType | null {
|
|
7
|
+
if (!value) return null;
|
|
8
|
+
const raw = String(value).trim().toLowerCase();
|
|
9
|
+
if (!raw) return null;
|
|
10
|
+
const compact = raw.replace(/[\s_-]+/g, "");
|
|
11
|
+
if (compact === "codex") return "codex";
|
|
12
|
+
if (compact === "claude" || compact === "claudecode" || compact === "cc") {
|
|
13
|
+
return "claude";
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function hasTypePrefix(name: string, type: ProfileType): boolean {
|
|
19
|
+
if (!name) return false;
|
|
20
|
+
const lowered = String(name).toLowerCase();
|
|
21
|
+
const prefixes = type === "claude" ? [type, "cc"] : [type];
|
|
22
|
+
for (const prefix of prefixes) {
|
|
23
|
+
for (const sep of ["-", "_", "."]) {
|
|
24
|
+
if (lowered.startsWith(`${prefix}${sep}`)) return true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function hasEnvKeyPrefix(profile: Profile | undefined, prefix: string): boolean {
|
|
31
|
+
if (!profile || !profile.env) return false;
|
|
32
|
+
const normalized = prefix.toUpperCase();
|
|
33
|
+
for (const key of Object.keys(profile.env)) {
|
|
34
|
+
if (key.toUpperCase().startsWith(normalized)) return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function inferProfileType(
|
|
40
|
+
profileName: string,
|
|
41
|
+
profile: Profile | undefined,
|
|
42
|
+
requestedType: ProfileType | null
|
|
43
|
+
): ProfileType | null {
|
|
44
|
+
if (requestedType) return requestedType;
|
|
45
|
+
const fromProfile = profile ? normalizeType(profile.type) : null;
|
|
46
|
+
if (fromProfile) return fromProfile;
|
|
47
|
+
if (hasEnvKeyPrefix(profile, "OPENAI_")) return "codex";
|
|
48
|
+
if (hasEnvKeyPrefix(profile, "ANTHROPIC_")) return "claude";
|
|
49
|
+
if (hasTypePrefix(profileName, "codex")) return "codex";
|
|
50
|
+
if (hasTypePrefix(profileName, "claude")) return "claude";
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function stripTypePrefixFromName(name: string, type: string): string {
|
|
55
|
+
if (!name) return name;
|
|
56
|
+
const normalizedType = normalizeType(type);
|
|
57
|
+
if (!normalizedType) return name;
|
|
58
|
+
const lowered = String(name).toLowerCase();
|
|
59
|
+
const prefixes = normalizedType === "claude" ? [normalizedType, "cc"] : [normalizedType];
|
|
60
|
+
for (const prefix of prefixes) {
|
|
61
|
+
for (const sep of ["-", "_", "."]) {
|
|
62
|
+
const candidate = `${prefix}${sep}`;
|
|
63
|
+
if (lowered.startsWith(candidate)) {
|
|
64
|
+
const stripped = String(name).slice(candidate.length);
|
|
65
|
+
return stripped || name;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return name;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function getProfileDisplayName(
|
|
73
|
+
profileKey: string,
|
|
74
|
+
profile: Profile,
|
|
75
|
+
requestedType?: string | null
|
|
76
|
+
): string {
|
|
77
|
+
if (profile.name) return String(profile.name);
|
|
78
|
+
const rawType = profile.type ? String(profile.type) : "";
|
|
79
|
+
if (rawType) return stripTypePrefixFromName(profileKey, rawType);
|
|
80
|
+
if (requestedType) return stripTypePrefixFromName(profileKey, requestedType);
|
|
81
|
+
return profileKey;
|
|
82
|
+
}
|
|
83
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell detection utilities
|
|
3
|
+
*/
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import * as os from "os";
|
|
6
|
+
|
|
7
|
+
export function normalizeShell(value: string | null | undefined): string | null {
|
|
8
|
+
if (!value) return null;
|
|
9
|
+
const raw = String(value).trim().toLowerCase();
|
|
10
|
+
if (!raw) return null;
|
|
11
|
+
if (raw === "bash") return "bash";
|
|
12
|
+
if (raw === "zsh") return "zsh";
|
|
13
|
+
if (raw === "fish") return "fish";
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function detectShell(explicitShell: string | null | undefined): string | null {
|
|
18
|
+
if (explicitShell) return normalizeShell(explicitShell);
|
|
19
|
+
const envShell = process.env.SHELL ? path.basename(process.env.SHELL) : "";
|
|
20
|
+
return normalizeShell(envShell);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getShellRcPath(shellName: string | null): string | null {
|
|
24
|
+
if (shellName === "bash") return path.join(os.homedir(), ".bashrc");
|
|
25
|
+
if (shellName === "zsh") return path.join(os.homedir(), ".zshrc");
|
|
26
|
+
if (shellName === "fish") {
|
|
27
|
+
return path.join(os.homedir(), ".config", "fish", "config.fish");
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell snippet generation
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from "fs";
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
|
|
7
|
+
export function getShellSnippet(shellName: string | null): string {
|
|
8
|
+
if (shellName === "fish") {
|
|
9
|
+
return [
|
|
10
|
+
"if not set -q CODE_ENV_TERMINAL_TAG",
|
|
11
|
+
" if type -q uuidgen",
|
|
12
|
+
" set -gx CODE_ENV_TERMINAL_TAG (uuidgen)",
|
|
13
|
+
" else",
|
|
14
|
+
" set -gx CODE_ENV_TERMINAL_TAG (date +%s)-$fish_pid-(random)",
|
|
15
|
+
" end",
|
|
16
|
+
"end",
|
|
17
|
+
"function codenv",
|
|
18
|
+
" if test (count $argv) -ge 1",
|
|
19
|
+
" switch $argv[1]",
|
|
20
|
+
" case use unset auto",
|
|
21
|
+
" command codenv $argv | source",
|
|
22
|
+
" case '*'",
|
|
23
|
+
" command codenv $argv",
|
|
24
|
+
" end",
|
|
25
|
+
" else",
|
|
26
|
+
" command codenv",
|
|
27
|
+
" end",
|
|
28
|
+
"end",
|
|
29
|
+
"function codex",
|
|
30
|
+
" command codenv launch codex -- $argv",
|
|
31
|
+
"end",
|
|
32
|
+
"function claude",
|
|
33
|
+
" command codenv launch claude -- $argv",
|
|
34
|
+
"end",
|
|
35
|
+
"codenv auto",
|
|
36
|
+
].join("\n");
|
|
37
|
+
}
|
|
38
|
+
return [
|
|
39
|
+
'if [ -z "$CODE_ENV_TERMINAL_TAG" ]; then',
|
|
40
|
+
' if command -v uuidgen >/dev/null 2>&1; then',
|
|
41
|
+
' CODE_ENV_TERMINAL_TAG="$(uuidgen)"',
|
|
42
|
+
" else",
|
|
43
|
+
' CODE_ENV_TERMINAL_TAG="$(date +%s)-$$-$RANDOM"',
|
|
44
|
+
" fi",
|
|
45
|
+
" export CODE_ENV_TERMINAL_TAG",
|
|
46
|
+
"fi",
|
|
47
|
+
"codenv() {",
|
|
48
|
+
' if [ "$1" = "use" ] || [ "$1" = "unset" ] || [ "$1" = "auto" ]; then',
|
|
49
|
+
' source <(command codenv "$@")',
|
|
50
|
+
" else",
|
|
51
|
+
' command codenv "$@"',
|
|
52
|
+
" fi",
|
|
53
|
+
"}",
|
|
54
|
+
"codex() {",
|
|
55
|
+
' command codenv launch codex -- "$@"',
|
|
56
|
+
"}",
|
|
57
|
+
"claude() {",
|
|
58
|
+
' command codenv launch claude -- "$@"',
|
|
59
|
+
"}",
|
|
60
|
+
"codenv auto",
|
|
61
|
+
].join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function escapeRegExp(value: string): string {
|
|
65
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function upsertShellSnippet(rcPath: string, snippet: string): void {
|
|
69
|
+
const markerStart = "# >>> codenv >>>";
|
|
70
|
+
const markerEnd = "# <<< codenv <<<";
|
|
71
|
+
const block = `${markerStart}\n${snippet}\n${markerEnd}`;
|
|
72
|
+
const existing = fs.existsSync(rcPath) ? fs.readFileSync(rcPath, "utf8") : "";
|
|
73
|
+
let updated = "";
|
|
74
|
+
|
|
75
|
+
if (existing.includes(markerStart) && existing.includes(markerEnd)) {
|
|
76
|
+
const re = new RegExp(
|
|
77
|
+
`${escapeRegExp(markerStart)}[\\s\\S]*?${escapeRegExp(markerEnd)}`
|
|
78
|
+
);
|
|
79
|
+
updated = existing.replace(re, block);
|
|
80
|
+
} else if (existing.trim().length === 0) {
|
|
81
|
+
updated = `${block}\n`;
|
|
82
|
+
} else {
|
|
83
|
+
const sep = existing.endsWith("\n") ? "\n" : "\n\n";
|
|
84
|
+
updated = `${existing}${sep}${block}\n`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const dir = path.dirname(rcPath);
|
|
88
|
+
if (!fs.existsSync(dir)) {
|
|
89
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
90
|
+
}
|
|
91
|
+
fs.writeFileSync(rcPath, updated, "utf8");
|
|
92
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell utility functions
|
|
3
|
+
*/
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import * as os from "os";
|
|
6
|
+
|
|
7
|
+
export function shellEscape(value: string | number | boolean): string {
|
|
8
|
+
const str = String(value);
|
|
9
|
+
return `'${str.replace(/'/g, `'\\''`)}'`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function expandEnv(input: string | null | undefined): string | null | undefined {
|
|
13
|
+
if (!input) return input;
|
|
14
|
+
let out = String(input);
|
|
15
|
+
if (out.startsWith("~")) {
|
|
16
|
+
out = path.join(os.homedir(), out.slice(1));
|
|
17
|
+
}
|
|
18
|
+
out = out.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || "");
|
|
19
|
+
out = out.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, key) => process.env[key] || "");
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolvePath(p: string | null | undefined): string | null {
|
|
24
|
+
if (!p) return null;
|
|
25
|
+
if (p.startsWith("~")) {
|
|
26
|
+
return path.join(os.homedir(), p.slice(1));
|
|
27
|
+
}
|
|
28
|
+
if (path.isAbsolute(p)) return p;
|
|
29
|
+
return path.resolve(process.cwd(), p);
|
|
30
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code statusline 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_CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
|
|
12
|
+
const DEFAULT_STATUSLINE_COMMAND = "codenv statusline --type claude --sync-usage";
|
|
13
|
+
const DEFAULT_STATUSLINE_TYPE = "command";
|
|
14
|
+
const DEFAULT_STATUSLINE_PADDING = 0;
|
|
15
|
+
|
|
16
|
+
interface DesiredStatusLineConfig {
|
|
17
|
+
type: string;
|
|
18
|
+
command: string;
|
|
19
|
+
padding: number;
|
|
20
|
+
settingsPath: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseBooleanEnv(value: string | undefined): boolean | null {
|
|
24
|
+
if (value === undefined) return null;
|
|
25
|
+
const normalized = String(value).trim().toLowerCase();
|
|
26
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
27
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveClaudeSettingsPath(config: Config): string {
|
|
32
|
+
const override = process.env.CODE_ENV_CLAUDE_SETTINGS_PATH;
|
|
33
|
+
if (override && String(override).trim()) {
|
|
34
|
+
const expanded = expandEnv(String(override).trim());
|
|
35
|
+
return resolvePath(expanded) || DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
36
|
+
}
|
|
37
|
+
const configOverride = config.claudeStatusline?.settingsPath;
|
|
38
|
+
if (configOverride && String(configOverride).trim()) {
|
|
39
|
+
const expanded = expandEnv(String(configOverride).trim());
|
|
40
|
+
return resolvePath(expanded) || DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
41
|
+
}
|
|
42
|
+
return DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readSettings(filePath: string): Record<string, unknown> | null {
|
|
46
|
+
if (!fs.existsSync(filePath)) return {};
|
|
47
|
+
try {
|
|
48
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
49
|
+
const trimmed = raw.trim();
|
|
50
|
+
if (!trimmed) return {};
|
|
51
|
+
const parsed = JSON.parse(trimmed);
|
|
52
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
53
|
+
return parsed as Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
62
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isCommandStatusLine(
|
|
66
|
+
value: unknown
|
|
67
|
+
): value is { type: string; command: string; padding?: number } {
|
|
68
|
+
if (!isPlainObject(value)) return false;
|
|
69
|
+
const type = value.type;
|
|
70
|
+
const command = value.command;
|
|
71
|
+
return typeof type === "string" && typeof command === "string";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveCommand(command: string | string[] | undefined): string {
|
|
75
|
+
if (typeof command === "string") {
|
|
76
|
+
const trimmed = command.trim();
|
|
77
|
+
if (trimmed) return trimmed;
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(command)) {
|
|
80
|
+
const cleaned = command
|
|
81
|
+
.map((entry) => String(entry).trim())
|
|
82
|
+
.filter((entry) => entry);
|
|
83
|
+
if (cleaned.length > 0) return cleaned.join(" ");
|
|
84
|
+
}
|
|
85
|
+
return DEFAULT_STATUSLINE_COMMAND;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveDesiredStatusLineConfig(config: Config): DesiredStatusLineConfig {
|
|
89
|
+
const type = config.claudeStatusline?.type || DEFAULT_STATUSLINE_TYPE;
|
|
90
|
+
const command = resolveCommand(config.claudeStatusline?.command);
|
|
91
|
+
const paddingRaw = config.claudeStatusline?.padding;
|
|
92
|
+
const padding =
|
|
93
|
+
typeof paddingRaw === "number" && Number.isFinite(paddingRaw)
|
|
94
|
+
? Math.floor(paddingRaw)
|
|
95
|
+
: DEFAULT_STATUSLINE_PADDING;
|
|
96
|
+
const settingsPath = resolveClaudeSettingsPath(config);
|
|
97
|
+
return { type, command, padding, settingsPath };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function statusLineMatches(
|
|
101
|
+
existing: unknown,
|
|
102
|
+
desired: DesiredStatusLineConfig
|
|
103
|
+
): boolean {
|
|
104
|
+
if (!isCommandStatusLine(existing)) return false;
|
|
105
|
+
if (existing.type !== desired.type) return false;
|
|
106
|
+
if (existing.command !== desired.command) return false;
|
|
107
|
+
const existingPadding =
|
|
108
|
+
typeof existing.padding === "number" ? existing.padding : undefined;
|
|
109
|
+
if (existingPadding !== desired.padding) return false;
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function ensureClaudeStatusline(
|
|
114
|
+
config: Config,
|
|
115
|
+
enabled: boolean
|
|
116
|
+
): Promise<boolean> {
|
|
117
|
+
const disabled =
|
|
118
|
+
parseBooleanEnv(process.env.CODE_ENV_CLAUDE_STATUSLINE_DISABLE) === true;
|
|
119
|
+
if (!enabled || disabled) return false;
|
|
120
|
+
const desired = resolveDesiredStatusLineConfig(config);
|
|
121
|
+
const settingsPath = desired.settingsPath;
|
|
122
|
+
const force =
|
|
123
|
+
parseBooleanEnv(process.env.CODE_ENV_CLAUDE_STATUSLINE_FORCE) === true;
|
|
124
|
+
|
|
125
|
+
const settings = readSettings(settingsPath);
|
|
126
|
+
if (!settings) {
|
|
127
|
+
console.error(
|
|
128
|
+
"codenv: unable to read Claude settings; skipping statusLine update."
|
|
129
|
+
);
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const existing = settings.statusLine;
|
|
134
|
+
if (existing && statusLineMatches(existing, desired)) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (typeof existing !== "undefined" && !force) {
|
|
139
|
+
console.log(`codenv: existing Claude statusLine config in ${settingsPath}:`);
|
|
140
|
+
console.log(JSON.stringify(existing, null, 2));
|
|
141
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
142
|
+
console.warn("codenv: no TTY available to confirm statusLine overwrite.");
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
const rl = createReadline();
|
|
146
|
+
try {
|
|
147
|
+
const confirm = await askConfirm(
|
|
148
|
+
rl,
|
|
149
|
+
"Overwrite Claude statusLine config? (y/N): "
|
|
150
|
+
);
|
|
151
|
+
if (!confirm) return false;
|
|
152
|
+
} finally {
|
|
153
|
+
rl.close();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
settings.statusLine = {
|
|
158
|
+
type: desired.type,
|
|
159
|
+
command: desired.command,
|
|
160
|
+
padding: desired.padding,
|
|
161
|
+
};
|
|
162
|
+
try {
|
|
163
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
164
|
+
fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
165
|
+
return true;
|
|
166
|
+
} catch {
|
|
167
|
+
console.error(
|
|
168
|
+
"codenv: failed to write Claude settings; statusLine not updated."
|
|
169
|
+
);
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|