mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 11:02:20 +00:00
* Add lefthook formatter checks * Fix SDK mode hydration * Stabilize SDK mode integration test
792 lines
20 KiB
TypeScript
792 lines
20 KiB
TypeScript
import { existsSync, readFileSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
import { cwd } from "node:process";
|
|
import * as toml from "@iarna/toml";
|
|
import type { AppConfig } from "@openhandoff/shared";
|
|
import opencodeThemePackJson from "./themes/opencode-pack.json" with { type: "json" };
|
|
|
|
export type ThemeMode = "dark" | "light";
|
|
|
|
export interface TuiTheme {
|
|
background: string;
|
|
text: string;
|
|
muted: string;
|
|
header: string;
|
|
status: string;
|
|
highlightBg: string;
|
|
highlightFg: string;
|
|
selectionBorder: string;
|
|
success: string;
|
|
warning: string;
|
|
error: string;
|
|
info: string;
|
|
diffAdd: string;
|
|
diffDel: string;
|
|
diffSep: string;
|
|
agentRunning: string;
|
|
agentIdle: string;
|
|
agentNone: string;
|
|
agentError: string;
|
|
prUnpushed: string;
|
|
author: string;
|
|
ciRunning: string;
|
|
ciPass: string;
|
|
ciFail: string;
|
|
ciNone: string;
|
|
reviewApproved: string;
|
|
reviewChanges: string;
|
|
reviewPending: string;
|
|
reviewNone: string;
|
|
}
|
|
|
|
export interface TuiThemeResolution {
|
|
theme: TuiTheme;
|
|
name: string;
|
|
source: string;
|
|
mode: ThemeMode;
|
|
}
|
|
|
|
interface ThemeCandidate {
|
|
theme: TuiTheme;
|
|
name: string;
|
|
}
|
|
|
|
type JsonObject = Record<string, unknown>;
|
|
|
|
type ConfigLike = AppConfig & { theme?: string };
|
|
|
|
const DEFAULT_THEME: TuiTheme = {
|
|
background: "#282828",
|
|
text: "#ffffff",
|
|
muted: "#6b7280",
|
|
header: "#6b7280",
|
|
status: "#6b7280",
|
|
highlightBg: "#282828",
|
|
highlightFg: "#ffffff",
|
|
selectionBorder: "#d946ef",
|
|
success: "#22c55e",
|
|
warning: "#eab308",
|
|
error: "#ef4444",
|
|
info: "#22d3ee",
|
|
diffAdd: "#22c55e",
|
|
diffDel: "#ef4444",
|
|
diffSep: "#6b7280",
|
|
agentRunning: "#22c55e",
|
|
agentIdle: "#eab308",
|
|
agentNone: "#6b7280",
|
|
agentError: "#ef4444",
|
|
prUnpushed: "#eab308",
|
|
author: "#22d3ee",
|
|
ciRunning: "#eab308",
|
|
ciPass: "#22c55e",
|
|
ciFail: "#ef4444",
|
|
ciNone: "#6b7280",
|
|
reviewApproved: "#22c55e",
|
|
reviewChanges: "#ef4444",
|
|
reviewPending: "#eab308",
|
|
reviewNone: "#6b7280",
|
|
};
|
|
|
|
const OPENCODE_THEME_PACK = opencodeThemePackJson as Record<string, unknown>;
|
|
|
|
export function resolveTuiTheme(config: AppConfig, baseDir = cwd()): TuiThemeResolution {
|
|
const mode = opencodeStateThemeMode() ?? "dark";
|
|
const configWithTheme = config as ConfigLike;
|
|
const override = typeof configWithTheme.theme === "string" ? configWithTheme.theme.trim() : "";
|
|
|
|
if (override) {
|
|
const candidate = loadFromSpec(override, [], mode, baseDir);
|
|
if (candidate) {
|
|
return {
|
|
theme: candidate.theme,
|
|
name: candidate.name,
|
|
source: "openhandoff config",
|
|
mode,
|
|
};
|
|
}
|
|
}
|
|
|
|
const fromConfig = loadOpencodeThemeFromConfig(mode, baseDir);
|
|
if (fromConfig) {
|
|
return fromConfig;
|
|
}
|
|
|
|
const fromState = loadOpencodeThemeFromState(mode, baseDir);
|
|
if (fromState) {
|
|
return fromState;
|
|
}
|
|
|
|
return {
|
|
theme: DEFAULT_THEME,
|
|
name: "opencode-default",
|
|
source: "default",
|
|
mode,
|
|
};
|
|
}
|
|
|
|
function loadOpencodeThemeFromConfig(mode: ThemeMode, baseDir: string): TuiThemeResolution | null {
|
|
for (const path of opencodeConfigPaths(baseDir)) {
|
|
if (!existsSync(path)) {
|
|
continue;
|
|
}
|
|
|
|
const value = readJsonWithComments(path);
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
|
|
const themeValue = findOpencodeThemeValue(value);
|
|
if (themeValue === undefined) {
|
|
continue;
|
|
}
|
|
|
|
const candidate = themeFromOpencodeValue(themeValue, opencodeThemeDirs(dirname(path), baseDir), mode, baseDir);
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
|
|
return {
|
|
theme: candidate.theme,
|
|
name: candidate.name,
|
|
source: `opencode config (${path})`,
|
|
mode,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function loadOpencodeThemeFromState(mode: ThemeMode, baseDir: string): TuiThemeResolution | null {
|
|
const path = opencodeStatePath();
|
|
if (!path || !existsSync(path)) {
|
|
return null;
|
|
}
|
|
|
|
const value = readJsonWithComments(path);
|
|
if (!isObject(value)) {
|
|
return null;
|
|
}
|
|
|
|
const spec = value.theme;
|
|
if (typeof spec !== "string" || !spec.trim()) {
|
|
return null;
|
|
}
|
|
|
|
const candidate = loadFromSpec(spec.trim(), opencodeThemeDirs(undefined, baseDir), mode, baseDir);
|
|
if (!candidate) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
theme: candidate.theme,
|
|
name: candidate.name,
|
|
source: `opencode state (${path})`,
|
|
mode,
|
|
};
|
|
}
|
|
|
|
function loadFromSpec(spec: string, searchDirs: string[], mode: ThemeMode, baseDir: string): ThemeCandidate | null {
|
|
if (isDefaultThemeName(spec)) {
|
|
return {
|
|
theme: DEFAULT_THEME,
|
|
name: "opencode-default",
|
|
};
|
|
}
|
|
|
|
if (isPathLike(spec)) {
|
|
const resolved = resolvePath(spec, baseDir);
|
|
if (existsSync(resolved)) {
|
|
const candidate = loadThemeFromPath(resolved, mode);
|
|
if (candidate) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const dir of searchDirs) {
|
|
for (const ext of ["json", "toml"]) {
|
|
const path = join(dir, `${spec}.${ext}`);
|
|
if (!existsSync(path)) {
|
|
continue;
|
|
}
|
|
|
|
const candidate = loadThemeFromPath(path, mode);
|
|
if (candidate) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
const builtIn = OPENCODE_THEME_PACK[spec];
|
|
if (builtIn !== undefined) {
|
|
const theme = themeFromOpencodeJson(builtIn, mode);
|
|
if (theme) {
|
|
return {
|
|
theme,
|
|
name: spec,
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function loadThemeFromPath(path: string, mode: ThemeMode): ThemeCandidate | null {
|
|
const content = safeReadText(path);
|
|
if (!content) {
|
|
return null;
|
|
}
|
|
|
|
const lower = path.toLowerCase();
|
|
if (lower.endsWith(".toml")) {
|
|
try {
|
|
const parsed = toml.parse(content);
|
|
const theme = themeFromAny(parsed);
|
|
if (!theme) {
|
|
return null;
|
|
}
|
|
return {
|
|
theme,
|
|
name: themeNameFromPath(path),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const value = parseJsonWithComments(content);
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
const opencodeTheme = themeFromOpencodeJson(value, mode);
|
|
if (opencodeTheme) {
|
|
return {
|
|
theme: opencodeTheme,
|
|
name: themeNameFromPath(path),
|
|
};
|
|
}
|
|
|
|
const paletteTheme = themeFromAny(value);
|
|
if (!paletteTheme) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
theme: paletteTheme,
|
|
name: themeNameFromPath(path),
|
|
};
|
|
}
|
|
|
|
function themeNameFromPath(path: string): string {
|
|
const base = path.split(/[\\/]/).pop() ?? path;
|
|
if (base.endsWith(".json") || base.endsWith(".toml")) {
|
|
return base.replace(/\.(json|toml)$/i, "");
|
|
}
|
|
return base;
|
|
}
|
|
|
|
function themeFromOpencodeValue(value: unknown, searchDirs: string[], mode: ThemeMode, baseDir: string): ThemeCandidate | null {
|
|
if (typeof value === "string") {
|
|
return loadFromSpec(value, searchDirs, mode, baseDir);
|
|
}
|
|
|
|
if (!isObject(value)) {
|
|
return null;
|
|
}
|
|
|
|
if (value.theme !== undefined) {
|
|
const theme = themeFromOpencodeJson(value, mode);
|
|
if (theme) {
|
|
return {
|
|
theme,
|
|
name: typeof value.name === "string" ? value.name : "inline",
|
|
};
|
|
}
|
|
}
|
|
|
|
const paletteTheme = themeFromAny(value.colors ?? value.palette ?? value);
|
|
if (paletteTheme) {
|
|
return {
|
|
theme: paletteTheme,
|
|
name: typeof value.name === "string" ? value.name : "inline",
|
|
};
|
|
}
|
|
|
|
if (typeof value.name === "string") {
|
|
const named = loadFromSpec(value.name, searchDirs, mode, baseDir);
|
|
if (named) {
|
|
return named;
|
|
}
|
|
}
|
|
|
|
const pathLike = value.path ?? value.file;
|
|
if (typeof pathLike === "string") {
|
|
const resolved = resolvePath(pathLike, baseDir);
|
|
const candidate = loadThemeFromPath(resolved, mode);
|
|
if (candidate) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function themeFromOpencodeJson(value: unknown, mode: ThemeMode): TuiTheme | null {
|
|
if (!isObject(value)) {
|
|
return null;
|
|
}
|
|
|
|
const themeMap = value.theme;
|
|
if (!isObject(themeMap)) {
|
|
return null;
|
|
}
|
|
|
|
const defs = isObject(value.defs) ? value.defs : {};
|
|
|
|
const background =
|
|
opencodeColor(themeMap, defs, mode, "background") ??
|
|
opencodeColor(themeMap, defs, mode, "backgroundPanel") ??
|
|
opencodeColor(themeMap, defs, mode, "backgroundElement") ??
|
|
DEFAULT_THEME.background;
|
|
|
|
const text = opencodeColor(themeMap, defs, mode, "text") ?? DEFAULT_THEME.text;
|
|
const muted = opencodeColor(themeMap, defs, mode, "textMuted") ?? DEFAULT_THEME.muted;
|
|
const highlightBg = opencodeColor(themeMap, defs, mode, "text") ?? text;
|
|
const highlightFg =
|
|
opencodeColor(themeMap, defs, mode, "backgroundElement") ??
|
|
opencodeColor(themeMap, defs, mode, "backgroundPanel") ??
|
|
opencodeColor(themeMap, defs, mode, "background") ??
|
|
DEFAULT_THEME.highlightFg;
|
|
|
|
const selectionBorder =
|
|
opencodeColor(themeMap, defs, mode, "secondary") ??
|
|
opencodeColor(themeMap, defs, mode, "accent") ??
|
|
opencodeColor(themeMap, defs, mode, "primary") ??
|
|
DEFAULT_THEME.selectionBorder;
|
|
|
|
const success = opencodeColor(themeMap, defs, mode, "success") ?? DEFAULT_THEME.success;
|
|
const warning = opencodeColor(themeMap, defs, mode, "warning") ?? DEFAULT_THEME.warning;
|
|
const error = opencodeColor(themeMap, defs, mode, "error") ?? DEFAULT_THEME.error;
|
|
const info = opencodeColor(themeMap, defs, mode, "info") ?? DEFAULT_THEME.info;
|
|
const diffAdd = opencodeColor(themeMap, defs, mode, "diffAdded") ?? success;
|
|
const diffDel = opencodeColor(themeMap, defs, mode, "diffRemoved") ?? error;
|
|
const diffSep = opencodeColor(themeMap, defs, mode, "diffContext") ?? opencodeColor(themeMap, defs, mode, "diffHunkHeader") ?? muted;
|
|
|
|
return {
|
|
background,
|
|
text,
|
|
muted,
|
|
header: muted,
|
|
status: muted,
|
|
highlightBg,
|
|
highlightFg,
|
|
selectionBorder,
|
|
success,
|
|
warning,
|
|
error,
|
|
info,
|
|
diffAdd,
|
|
diffDel,
|
|
diffSep,
|
|
agentRunning: success,
|
|
agentIdle: warning,
|
|
agentNone: muted,
|
|
agentError: error,
|
|
prUnpushed: warning,
|
|
author: info,
|
|
ciRunning: warning,
|
|
ciPass: success,
|
|
ciFail: error,
|
|
ciNone: muted,
|
|
reviewApproved: success,
|
|
reviewChanges: error,
|
|
reviewPending: warning,
|
|
reviewNone: muted,
|
|
};
|
|
}
|
|
|
|
function opencodeColor(themeMap: JsonObject, defs: JsonObject, mode: ThemeMode, key: string): string | null {
|
|
const raw = themeMap[key];
|
|
if (raw === undefined) {
|
|
return null;
|
|
}
|
|
return resolveOpencodeColor(raw, themeMap, defs, mode, 0);
|
|
}
|
|
|
|
function resolveOpencodeColor(value: unknown, themeMap: JsonObject, defs: JsonObject, mode: ThemeMode, depth: number): string | null {
|
|
if (depth > 12) {
|
|
return null;
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
if (!trimmed || trimmed.toLowerCase() === "transparent" || trimmed.toLowerCase() === "none") {
|
|
return null;
|
|
}
|
|
|
|
const fromDefs = defs[trimmed];
|
|
if (fromDefs !== undefined) {
|
|
return resolveOpencodeColor(fromDefs, themeMap, defs, mode, depth + 1);
|
|
}
|
|
|
|
const fromTheme = themeMap[trimmed];
|
|
if (fromTheme !== undefined) {
|
|
return resolveOpencodeColor(fromTheme, themeMap, defs, mode, depth + 1);
|
|
}
|
|
|
|
if (isColorLike(trimmed)) {
|
|
return trimmed;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
if (isObject(value)) {
|
|
const nested = value[mode];
|
|
if (nested !== undefined) {
|
|
return resolveOpencodeColor(nested, themeMap, defs, mode, depth + 1);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function themeFromAny(value: unknown): TuiTheme | null {
|
|
const palette = extractPalette(value);
|
|
if (!palette) {
|
|
return null;
|
|
}
|
|
|
|
const pick = (keys: string[], fallback: string): string => {
|
|
for (const key of keys) {
|
|
const v = palette[normalizeKey(key)];
|
|
if (v && isColorLike(v)) {
|
|
return v;
|
|
}
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
const background = pick(["background", "bg", "base", "background_color"], DEFAULT_THEME.background);
|
|
const text = pick(["text", "foreground", "fg", "primary"], DEFAULT_THEME.text);
|
|
const muted = pick(["muted", "subtle", "secondary", "dim"], DEFAULT_THEME.muted);
|
|
const header = pick(["header", "header_text"], muted);
|
|
const status = pick(["status", "status_text"], muted);
|
|
const highlightBg = pick(["highlight_bg", "selection", "highlight", "accent_bg"], DEFAULT_THEME.highlightBg);
|
|
const highlightFg = pick(["highlight_fg", "selection_fg", "accent_fg"], text);
|
|
const selectionBorder = pick(["selection_border", "highlight_border", "accent", "secondary"], DEFAULT_THEME.selectionBorder);
|
|
const success = pick(["success", "green"], DEFAULT_THEME.success);
|
|
const warning = pick(["warning", "yellow"], DEFAULT_THEME.warning);
|
|
const error = pick(["error", "red"], DEFAULT_THEME.error);
|
|
const info = pick(["info", "cyan", "blue"], DEFAULT_THEME.info);
|
|
const diffAdd = pick(["diff_add", "diff_addition", "add"], success);
|
|
const diffDel = pick(["diff_del", "diff_deletion", "delete"], error);
|
|
const diffSep = pick(["diff_sep", "diff_separator", "separator"], muted);
|
|
|
|
return {
|
|
background,
|
|
text,
|
|
muted,
|
|
header,
|
|
status,
|
|
highlightBg,
|
|
highlightFg,
|
|
selectionBorder,
|
|
success,
|
|
warning,
|
|
error,
|
|
info,
|
|
diffAdd,
|
|
diffDel,
|
|
diffSep,
|
|
agentRunning: pick(["agent_running", "running"], success),
|
|
agentIdle: pick(["agent_idle", "idle"], warning),
|
|
agentNone: pick(["agent_none", "none"], muted),
|
|
agentError: pick(["agent_error", "agent_failed"], error),
|
|
prUnpushed: pick(["pr_unpushed", "unpushed"], warning),
|
|
author: pick(["author"], info),
|
|
ciRunning: pick(["ci_running"], warning),
|
|
ciPass: pick(["ci_pass", "ci_success"], success),
|
|
ciFail: pick(["ci_fail", "ci_error"], error),
|
|
ciNone: pick(["ci_none", "ci_unknown"], muted),
|
|
reviewApproved: pick(["review_approved", "approved"], success),
|
|
reviewChanges: pick(["review_changes", "changes"], error),
|
|
reviewPending: pick(["review_pending", "pending"], warning),
|
|
reviewNone: pick(["review_none", "review_unknown"], muted),
|
|
};
|
|
}
|
|
|
|
function extractPalette(value: unknown): Record<string, string> | null {
|
|
if (!isObject(value)) {
|
|
return null;
|
|
}
|
|
|
|
const colors = isObject(value.colors) ? value.colors : undefined;
|
|
const palette = isObject(value.palette) ? value.palette : undefined;
|
|
const source = colors ?? palette ?? value;
|
|
if (!isObject(source)) {
|
|
return null;
|
|
}
|
|
|
|
const out: Record<string, string> = {};
|
|
for (const [key, raw] of Object.entries(source)) {
|
|
if (typeof raw !== "string") {
|
|
continue;
|
|
}
|
|
out[normalizeKey(key)] = raw;
|
|
}
|
|
|
|
return Object.keys(out).length > 0 ? out : null;
|
|
}
|
|
|
|
function normalizeKey(key: string): string {
|
|
return key.toLowerCase().replace(/[\-\s.]/g, "_");
|
|
}
|
|
|
|
function isColorLike(value: string): boolean {
|
|
const lower = value.trim().toLowerCase();
|
|
if (!lower) {
|
|
return false;
|
|
}
|
|
|
|
if (/^#[0-9a-f]{3}$/.test(lower) || /^#[0-9a-f]{6}$/.test(lower) || /^#[0-9a-f]{8}$/.test(lower)) {
|
|
return true;
|
|
}
|
|
|
|
if (/^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+(\s*,\s*[\d.]+)?\s*\)$/.test(lower)) {
|
|
return true;
|
|
}
|
|
|
|
return /^[a-z_\-]+$/.test(lower);
|
|
}
|
|
|
|
function findOpencodeThemeValue(value: unknown): unknown {
|
|
if (!isObject(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
if (value.theme !== undefined) {
|
|
return value.theme;
|
|
}
|
|
|
|
return pointer(value, ["ui", "theme"]) ?? pointer(value, ["tui", "theme"]) ?? pointer(value, ["options", "theme"]);
|
|
}
|
|
|
|
function pointer(obj: JsonObject, parts: string[]): unknown {
|
|
let current: unknown = obj;
|
|
for (const part of parts) {
|
|
if (!isObject(current)) {
|
|
return undefined;
|
|
}
|
|
current = current[part];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function opencodeConfigPaths(baseDir: string): string[] {
|
|
const paths: string[] = [];
|
|
|
|
const rootish = opencodeProjectConfigPaths(baseDir);
|
|
paths.push(...rootish);
|
|
|
|
const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
const opencodeDir = join(configDir, "opencode");
|
|
paths.push(join(opencodeDir, "opencode.json"));
|
|
paths.push(join(opencodeDir, "opencode.jsonc"));
|
|
paths.push(join(opencodeDir, "config.json"));
|
|
|
|
return paths;
|
|
}
|
|
|
|
function opencodeThemeDirs(configDir: string | undefined, baseDir: string): string[] {
|
|
const dirs: string[] = [];
|
|
|
|
if (configDir) {
|
|
dirs.push(join(configDir, "themes"));
|
|
}
|
|
|
|
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
dirs.push(join(xdgConfig, "opencode", "themes"));
|
|
dirs.push(join(homedir(), ".opencode", "themes"));
|
|
|
|
dirs.push(...opencodeProjectThemeDirs(baseDir));
|
|
|
|
return dirs;
|
|
}
|
|
|
|
function opencodeProjectConfigPaths(baseDir: string): string[] {
|
|
const dirs = ancestorDirs(baseDir);
|
|
const out: string[] = [];
|
|
for (const dir of dirs) {
|
|
out.push(join(dir, "opencode.json"));
|
|
out.push(join(dir, "opencode.jsonc"));
|
|
out.push(join(dir, ".opencode", "opencode.json"));
|
|
out.push(join(dir, ".opencode", "opencode.jsonc"));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function opencodeProjectThemeDirs(baseDir: string): string[] {
|
|
const dirs = ancestorDirs(baseDir);
|
|
const out: string[] = [];
|
|
for (const dir of dirs) {
|
|
out.push(join(dir, ".opencode", "themes"));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function ancestorDirs(start: string): string[] {
|
|
const out: string[] = [];
|
|
let current = resolve(start);
|
|
|
|
while (true) {
|
|
out.push(current);
|
|
const parent = dirname(current);
|
|
if (parent === current) {
|
|
break;
|
|
}
|
|
current = parent;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function opencodeStatePath(): string | null {
|
|
const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
|
|
return join(stateHome, "opencode", "kv.json");
|
|
}
|
|
|
|
function opencodeStateThemeMode(): ThemeMode | null {
|
|
const path = opencodeStatePath();
|
|
if (!path || !existsSync(path)) {
|
|
return null;
|
|
}
|
|
|
|
const value = readJsonWithComments(path);
|
|
if (!isObject(value)) {
|
|
return null;
|
|
}
|
|
|
|
const mode = value.theme_mode;
|
|
if (typeof mode !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const lower = mode.toLowerCase();
|
|
if (lower === "dark" || lower === "light") {
|
|
return lower;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseJsonWithComments(content: string): unknown {
|
|
try {
|
|
return JSON.parse(content);
|
|
} catch {
|
|
// Fall through.
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(stripJsoncComments(content));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readJsonWithComments(path: string): unknown {
|
|
const content = safeReadText(path);
|
|
if (!content) {
|
|
return null;
|
|
}
|
|
return parseJsonWithComments(content);
|
|
}
|
|
|
|
function stripJsoncComments(input: string): string {
|
|
let output = "";
|
|
let i = 0;
|
|
let inString = false;
|
|
let escaped = false;
|
|
|
|
while (i < input.length) {
|
|
const ch = input[i];
|
|
|
|
if (inString) {
|
|
output += ch;
|
|
if (escaped) {
|
|
escaped = false;
|
|
} else if (ch === "\\") {
|
|
escaped = true;
|
|
} else if (ch === '"') {
|
|
inString = false;
|
|
}
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '"') {
|
|
inString = true;
|
|
output += ch;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (ch === "/" && input[i + 1] === "/") {
|
|
i += 2;
|
|
while (i < input.length && input[i] !== "\n") {
|
|
i += 1;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (ch === "/" && input[i + 1] === "*") {
|
|
i += 2;
|
|
while (i < input.length) {
|
|
if (input[i] === "*" && input[i + 1] === "/") {
|
|
i += 2;
|
|
break;
|
|
}
|
|
i += 1;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
output += ch;
|
|
i += 1;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
function safeReadText(path: string): string | null {
|
|
try {
|
|
return readFileSync(path, "utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolvePath(path: string, baseDir: string): string {
|
|
if (path.startsWith("~/")) {
|
|
return join(homedir(), path.slice(2));
|
|
}
|
|
if (isAbsolute(path)) {
|
|
return path;
|
|
}
|
|
return resolve(baseDir, path);
|
|
}
|
|
|
|
function isPathLike(spec: string): boolean {
|
|
return spec.includes("/") || spec.includes("\\") || spec.endsWith(".json") || spec.endsWith(".toml");
|
|
}
|
|
|
|
function isDefaultThemeName(spec: string): boolean {
|
|
const lower = spec.toLowerCase();
|
|
return lower === "default" || lower === "opencode" || lower === "opencode-default" || lower === "system";
|
|
}
|
|
|
|
function isObject(value: unknown): value is JsonObject {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|