scripts/cmux-settings
3,364 tokens · o200k_base · 13,856 bytes
Source excerpt starting at line 1.#!/usr/bin/env python3"""Inspect and edit ~/.config/cmux/cmux.json. cmux watches the file and auto-reloads on save, so writes take effectimmediately. The file is JSONC (JSON with // and /* */ comments); thisscript writes comment-free formatting (2-space indent, trailing newline)and only writes when the parsed value actually changes. Usage: cmux-settings path print the config path cmux-settings dump [--no-comments] print current settings (raw or stripped) cmux-settings get <dotted.path> print value at path (JSON) cmux-settings set <dotted.path> <v> set value (v parsed as JSON, falls back to string) cmux-settings unset <dotted.path> remove value at path cmux-settings list-supported print every settings path the schema recognizes cmux-settings validate check JSON parses and all set keys are recognized cmux-settings open open the file in $EDITOR (or VS Code, then TextEdit) Examples: cmux-settings set app.appearance dark cmux-settings set notifications.dockBadge false cmux-settings set shortcuts.bindings.toggleSidebar '"cmd+b"' cmux-settings set shortcuts.bindings.newTab '["ctrl+b","c"]' cmux-settings unset app.appearance""" from __future__ import annotations import argparseimport jsonimport osimport reimport shleximport shutilimport subprocessimport sysimport tempfilefrom pathlib import Pathfrom typing import Any DEFAULT_PATH = Path.home() / ".config" / "cmux" / "cmux.json"SCHEMA_URL = ( "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux.schema.json") def strip_jsonc(text: str) -> str: """Remove // line comments and /* block comments outside strings.""" out: list[str] = [] i, n = 0, len(text) in_string = False string_quote = "" while i < n: ch = text[i] if in_string: out.append(ch) if ch == "\\" and i + 1 < n: out.append(text[i + 1]) i += 2 continue if ch == string_quote: in_string = False i += 1 continue if ch in ('"', "'"): in_string = True string_quote = ch out.append(ch) i += 1 continue if ch == "/" and i + 1 < n and text[i + 1] == "/": j = text.find("\n", i + 2) i = n if j == -1 else j continue if ch == "/" and i + 1 < n and text[i + 1] == "*": j = text.find("*/", i + 2) i = n if j == -1 else j + 2 continue out.append(ch) i += 1 # Drop trailing commas before ] or } (also legal in JSONC). return strip_trailing_commas_outside_strings("".join(out)) def strip_trailing_commas_outside_strings(text: str) -> str: """Remove JSONC trailing commas without touching string contents.""" out: list[str] = [] i, n = 0, len(text) in_string = False string_quote = "" while i < n: ch = text[i] if in_string: out.append(ch) if ch == "\\" and i + 1 < n: out.append(text[i + 1]) i += 2 continue if ch == string_quote: in_string = False i += 1 continue if ch in ('"', "'"): in_string = True string_quote = ch out.append(ch) i += 1 continue if ch == ",": j = i + 1 while j < n and text[j].isspace(): j += 1 if j < n and text[j] in "}]": i += 1 continue out.append(ch) i += 1 return "".join(out) def load_settings(path: Path) -> dict[str, Any]: if not path.exists(): return {"$schema": SCHEMA_URL, "schemaVersion": 1} raw = path.read_text() try: return json.loads(strip_jsonc(raw)) except json.JSONDecodeError as e: raise SystemExit(f"error: {path} is not valid JSONC: {e}") def atomic_write(path: Path, data: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) encoded = json.dumps(data, indent=2, ensure_ascii=False) + "\n" with tempfile.NamedTemporaryFile( mode="w", encoding="utf-8", dir=path.parent, delete=False, suffix=".tmp" ) as tmp: tmp.write(encoded) tmp_path = Path(tmp.name) os.replace(tmp_path, path) def split_path(dotted: str) -> list[str]: if not dotted: raise SystemExit("error: empty key path") parts = dotted.split(".") if any(part == "" for part in parts): raise SystemExit("error: empty key path segment") return parts def get_at(data: Any, parts: list[str]) -> Any: cur = data for p in parts: if not isinstance(cur, dict) or p not in cur: raise SystemExit(f"error: path not present: {'.'.join(parts)}") cur = cur[p] return cur def set_at(data: dict[str, Any], parts: list[str], value: Any) -> bool: cur = data for p in parts[:-1]: if p not in cur: cur[p] = {} elif not isinstance(cur[p], dict): raise SystemExit( f"error: intermediate key '{p}' is not an object " f"(got {type(cur[p]).__name__}); cannot set nested path" ) cur = cur[p] leaf = parts[-1] changed = (leaf not in cur) or cur[leaf] != value cur[leaf] = value return changed def unset_at(data: dict[str, Any], parts: list[str]) -> bool: trail: list[tuple[dict[str, Any], str]] = [] cur: Any = data for p in parts[:-1]: if not isinstance(cur, dict) or p not in cur: return False trail.append((cur, p)) cur = cur[p] if not isinstance(cur, dict) or parts[-1] not in cur: return False del cur[parts[-1]] # Drop now-empty ancestor objects so the file stays tidy. for parent, key in reversed(trail): if isinstance(parent[key], dict) and not parent[key]: del parent[key] else: break return True def parse_value(raw: str) -> Any: """Try JSON first; if it fails, fall back to a plain string.""" try: return json.loads(raw) except json.JSONDecodeError: return raw def flatten(prefix: str, value: Any) -> list[str]: if isinstance(value, dict): out: list[str] = [] for k, v in value.items(): child = f"{prefix}.{k}" if prefix else k out.extend(flatten(child, v)) return out return [prefix] def supported_paths_from_source(source: Path) -> list[str]: text = source.read_text() known_sections = ( "app.", "terminal.", "notifications.", "sidebar.", "workspaceColors.", "sidebarAppearance.", "automation.", "browser.", "shortcuts.", ) candidates = re.findall(r'"([a-zA-Z]+\.[a-zA-Z0-9_.]+)"', text) return sorted({p for p in candidates if p.startswith(known_sections)}) def supported_paths_from_reference(skill_root: Path | None) -> list[str]: if skill_root is None: return [] reference = skill_root / "references" / "all-keys.md" if not reference.exists(): return [] text = reference.read_text() return sorted(set(re.findall(r"^\| `([^`]+)` \|", text, re.MULTILINE))) def find_source_file() -> Path | None: here = Path(__file__).resolve() for parent in here.parents: direct = parent / "Sources" / "CmuxSettingsJSONPathSupport.swift" if direct.exists(): return direct hq_checkout = parent / "repo" / "Sources" / "CmuxSettingsJSONPathSupport.swift" if hq_checkout.exists(): return hq_checkout return None def find_skill_root() -> Path | None: here = Path(__file__).resolve() for parent in here.parents: if parent.name == "cmux-settings" and (parent / "SKILL.md").exists(): return parent if (parent / "references" / "all-keys.md").exists(): return parent return None def supported_paths() -> list[str]: source = find_source_file() if source is not None: return supported_paths_from_source(source) return supported_paths_from_reference(find_skill_root()) def cmd_path(args: argparse.Namespace) -> int: print(args.file) return 0 def cmd_dump(args: argparse.Namespace) -> int: path = Path(args.file) if args.no_comments: data = load_settings(path) print(json.dumps(data, indent=2, ensure_ascii=False)) else: if path.exists(): sys.stdout.write(path.read_text()) else: print(f"# {path} does not exist", file=sys.stderr) return 1 return 0 def cmd_get(args: argparse.Namespace) -> int: data = load_settings(Path(args.file)) value = get_at(data, split_path(args.key)) print(json.dumps(value, indent=2, ensure_ascii=False)) return 0 def cmd_set(args: argparse.Namespace) -> int: path = Path(args.file) data = load_settings(path) parts = split_path(args.key) value = parse_value(args.value) changed = set_at(data, parts, value) if not changed: print(f"unchanged: {args.key} = {json.dumps(value)}") return 0 atomic_write(path, data) print(f"set: {args.key} = {json.dumps(value)}") return 0 def cmd_unset(args: argparse.Namespace) -> int: path = Path(args.file) data = load_settings(path) if not unset_at(data, split_path(args.key)): print(f"unchanged: {args.key} (not present)") return 0 atomic_write(path, data) print(f"unset: {args.key}") return 0 def cmd_list_supported(args: argparse.Namespace) -> int: paths = supported_paths() if not paths: print( "error: could not load the supported-paths list; " "run from a cmux checkout or reinstall the cmux-settings skill", file=sys.stderr, ) return 1 for p in paths: print(p) return 0 def cmd_validate(args: argparse.Namespace) -> int: path = Path(args.file) data = load_settings(path) supported = set(supported_paths()) if not supported: print( "warn: could not load schema path list; only checked JSON parses", file=sys.stderr, ) print(f"ok: {path} parses") return 0 unknown: list[str] = [] # Keep this in sync with top-level structural keys in web/data/cmux.schema.json. structural = { "$schema", "schemaVersion", "actions", "ui", "commands", "vault", "newWorkspaceCommand", "surfaceTabBarButtons", "rightSidebar", } for top, value in data.items(): if top in structural: continue if not isinstance(value, dict): unknown.append(top) continue for full in flatten(top, value): if full in supported: continue if any(full.startswith(s + ".") for s in supported): continue unknown.append(full) if unknown: print("unknown settings keys:") for u in unknown: print(f" {u}") return 1 print(f"ok: {path} parses and all settings keys are recognized") return 0 def cmd_open(args: argparse.Namespace) -> int: path = Path(args.file) path.parent.mkdir(parents=True, exist_ok=True) if not path.exists(): atomic_write(path, {"$schema": SCHEMA_URL, "schemaVersion": 1}) editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") if editor: try: editor_args = shlex.split(editor) except ValueError as e: print(f"error: invalid editor command: {e}", file=sys.stderr) return 1 if not editor_args: print("error: editor command is empty", file=sys.stderr) return 1 try: return subprocess.call([*editor_args, str(path)]) except FileNotFoundError: print(f"error: editor not found: {editor_args[0]}", file=sys.stderr) return 1 for app in (["code", "--wait"], ["cursor", "--wait"], ["open", "-e"], ["xdg-open"]): if shutil.which(app[0]): try: return subprocess.call([*app, str(path)]) except FileNotFoundError: continue print( "error: no suitable editor found; set $EDITOR or install code, cursor, open, or xdg-open", file=sys.stderr, ) return 1 def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument( "--file", default=str(DEFAULT_PATH), help=f"settings file path (default: {DEFAULT_PATH})", ) sub = parser.add_subparsers(dest="cmd", required=True) sub.add_parser("path").set_defaults(func=cmd_path) p_dump = sub.add_parser("dump") p_dump.add_argument("--no-comments", action="store_true") p_dump.set_defaults(func=cmd_dump) p_get = sub.add_parser("get") p_get.add_argument("key") p_get.set_defaults(func=cmd_get) p_set = sub.add_parser("set") p_set.add_argument("key") p_set.add_argument("value") p_set.set_defaults(func=cmd_set) p_unset = sub.add_parser("unset") p_unset.add_argument("key") p_unset.set_defaults(func=cmd_unset) sub.add_parser("list-supported").set_defaults(func=cmd_list_supported) sub.add_parser("validate").set_defaults(func=cmd_validate) sub.add_parser("open").set_defaults(func=cmd_open) args = parser.parse_args() return args.func(args) if __name__ == "__main__": sys.exit(main())