scripts/cmux-settings
scripts/cmux-settingsBrowse 5 files
3,364 tokens
13,856 bytes
Token encoding: o200k_base
Snapshot 6f118af
← Back to SKILL.md
1#!/usr/bin/env python32"""Inspect and edit ~/.config/cmux/cmux.json.3 4cmux watches the file and auto-reloads on save, so writes take effect5immediately. The file is JSONC (JSON with // and /* */ comments); this6script writes comment-free formatting (2-space indent, trailing newline)7and only writes when the parsed value actually changes.8 9Usage:10 cmux-settings path print the config path11 cmux-settings dump [--no-comments] print current settings (raw or stripped)12 cmux-settings get <dotted.path> print value at path (JSON)13 cmux-settings set <dotted.path> <v> set value (v parsed as JSON, falls back to string)14 cmux-settings unset <dotted.path> remove value at path15 cmux-settings list-supported print every settings path the schema recognizes16 cmux-settings validate check JSON parses and all set keys are recognized17 cmux-settings open open the file in $EDITOR (or VS Code, then TextEdit)18 19Examples:20 cmux-settings set app.appearance dark21 cmux-settings set notifications.dockBadge false22 cmux-settings set shortcuts.bindings.toggleSidebar '"cmd+b"'23 cmux-settings set shortcuts.bindings.newTab '["ctrl+b","c"]'24 cmux-settings unset app.appearance25"""26 27from __future__ import annotations28 29import argparse30import json31import os32import re33import shlex34import shutil35import subprocess36import sys37import tempfile38from pathlib import Path39from typing import Any40 41DEFAULT_PATH = Path.home() / ".config" / "cmux" / "cmux.json"42SCHEMA_URL = (43 "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux.schema.json"44)45 46 47def strip_jsonc(text: str) -> str:48 """Remove // line comments and /* block comments outside strings."""49 out: list[str] = []50 i, n = 0, len(text)51 in_string = False52 string_quote = ""53 while i < n:54 ch = text[i]55 if in_string:56 out.append(ch)57 if ch == "\\" and i + 1 < n:58 out.append(text[i + 1])59 i += 260 continue61 if ch == string_quote:62 in_string = False63 i += 164 continue65 if ch in ('"', "'"):66 in_string = True67 string_quote = ch68 out.append(ch)69 i += 170 continue71 if ch == "/" and i + 1 < n and text[i + 1] == "/":72 j = text.find("\n", i + 2)73 i = n if j == -1 else j74 continue75 if ch == "/" and i + 1 < n and text[i + 1] == "*":76 j = text.find("*/", i + 2)77 i = n if j == -1 else j + 278 continue79 out.append(ch)80 i += 181 # Drop trailing commas before ] or } (also legal in JSONC).82 return strip_trailing_commas_outside_strings("".join(out))83 84 85def strip_trailing_commas_outside_strings(text: str) -> str:86 """Remove JSONC trailing commas without touching string contents."""87 out: list[str] = []88 i, n = 0, len(text)89 in_string = False90 string_quote = ""91 while i < n:92 ch = text[i]93 if in_string:94 out.append(ch)95 if ch == "\\" and i + 1 < n:96 out.append(text[i + 1])97 i += 298 continue99 if ch == string_quote:100 in_string = False101 i += 1102 continue103 if ch in ('"', "'"):104 in_string = True105 string_quote = ch106 out.append(ch)107 i += 1108 continue109 if ch == ",":110 j = i + 1111 while j < n and text[j].isspace():112 j += 1113 if j < n and text[j] in "}]":114 i += 1115 continue116 out.append(ch)117 i += 1118 return "".join(out)119 120 121def load_settings(path: Path) -> dict[str, Any]:122 if not path.exists():123 return {"$schema": SCHEMA_URL, "schemaVersion": 1}124 raw = path.read_text()125 try:126 return json.loads(strip_jsonc(raw))127 except json.JSONDecodeError as e:128 raise SystemExit(f"error: {path} is not valid JSONC: {e}")129 130 131def atomic_write(path: Path, data: dict[str, Any]) -> None:132 path.parent.mkdir(parents=True, exist_ok=True)133 encoded = json.dumps(data, indent=2, ensure_ascii=False) + "\n"134 with tempfile.NamedTemporaryFile(135 mode="w", encoding="utf-8", dir=path.parent, delete=False, suffix=".tmp"136 ) as tmp:137 tmp.write(encoded)138 tmp_path = Path(tmp.name)139 os.replace(tmp_path, path)140 141 142def split_path(dotted: str) -> list[str]:143 if not dotted:144 raise SystemExit("error: empty key path")145 parts = dotted.split(".")146 if any(part == "" for part in parts):147 raise SystemExit("error: empty key path segment")148 return parts149 150 151def get_at(data: Any, parts: list[str]) -> Any:152 cur = data153 for p in parts:154 if not isinstance(cur, dict) or p not in cur:155 raise SystemExit(f"error: path not present: {'.'.join(parts)}")156 cur = cur[p]157 return cur158 159 160def set_at(data: dict[str, Any], parts: list[str], value: Any) -> bool:161 cur = data162 for p in parts[:-1]:163 if p not in cur:164 cur[p] = {}165 elif not isinstance(cur[p], dict):166 raise SystemExit(167 f"error: intermediate key '{p}' is not an object "168 f"(got {type(cur[p]).__name__}); cannot set nested path"169 )170 cur = cur[p]171 leaf = parts[-1]172 changed = (leaf not in cur) or cur[leaf] != value173 cur[leaf] = value174 return changed175 176 177def unset_at(data: dict[str, Any], parts: list[str]) -> bool:178 trail: list[tuple[dict[str, Any], str]] = []179 cur: Any = data180 for p in parts[:-1]:181 if not isinstance(cur, dict) or p not in cur:182 return False183 trail.append((cur, p))184 cur = cur[p]185 if not isinstance(cur, dict) or parts[-1] not in cur:186 return False187 del cur[parts[-1]]188 # Drop now-empty ancestor objects so the file stays tidy.189 for parent, key in reversed(trail):190 if isinstance(parent[key], dict) and not parent[key]:191 del parent[key]192 else:193 break194 return True195 196 197def parse_value(raw: str) -> Any:198 """Try JSON first; if it fails, fall back to a plain string."""199 try:200 return json.loads(raw)201 except json.JSONDecodeError:202 return raw203 204 205def flatten(prefix: str, value: Any) -> list[str]:206 if isinstance(value, dict):207 out: list[str] = []208 for k, v in value.items():209 child = f"{prefix}.{k}" if prefix else k210 out.extend(flatten(child, v))211 return out212 return [prefix]213 214 215def supported_paths_from_source(source: Path) -> list[str]:216 text = source.read_text()217 known_sections = (218 "app.",219 "terminal.",220 "notifications.",221 "sidebar.",222 "workspaceColors.",223 "sidebarAppearance.",224 "automation.",225 "browser.",226 "shortcuts.",227 )228 candidates = re.findall(r'"([a-zA-Z]+\.[a-zA-Z0-9_.]+)"', text)229 return sorted({p for p in candidates if p.startswith(known_sections)})230 231 232def supported_paths_from_reference(skill_root: Path | None) -> list[str]:233 if skill_root is None:234 return []235 reference = skill_root / "references" / "all-keys.md"236 if not reference.exists():237 return []238 text = reference.read_text()239 return sorted(set(re.findall(r"^\| `([^`]+)` \|", text, re.MULTILINE)))240 241 242def find_source_file() -> Path | None:243 here = Path(__file__).resolve()244 for parent in here.parents:245 direct = parent / "Sources" / "CmuxSettingsJSONPathSupport.swift"246 if direct.exists():247 return direct248 hq_checkout = parent / "repo" / "Sources" / "CmuxSettingsJSONPathSupport.swift"249 if hq_checkout.exists():250 return hq_checkout251 return None252 253 254def find_skill_root() -> Path | None:255 here = Path(__file__).resolve()256 for parent in here.parents:257 if parent.name == "cmux-settings" and (parent / "SKILL.md").exists():258 return parent259 if (parent / "references" / "all-keys.md").exists():260 return parent261 return None262 263 264def supported_paths() -> list[str]:265 source = find_source_file()266 if source is not None:267 return supported_paths_from_source(source)268 return supported_paths_from_reference(find_skill_root())269 270 271def cmd_path(args: argparse.Namespace) -> int:272 print(args.file)273 return 0274 275 276def cmd_dump(args: argparse.Namespace) -> int:277 path = Path(args.file)278 if args.no_comments:279 data = load_settings(path)280 print(json.dumps(data, indent=2, ensure_ascii=False))281 else:282 if path.exists():283 sys.stdout.write(path.read_text())284 else:285 print(f"# {path} does not exist", file=sys.stderr)286 return 1287 return 0288 289 290def cmd_get(args: argparse.Namespace) -> int:291 data = load_settings(Path(args.file))292 value = get_at(data, split_path(args.key))293 print(json.dumps(value, indent=2, ensure_ascii=False))294 return 0295 296 297def cmd_set(args: argparse.Namespace) -> int:298 path = Path(args.file)299 data = load_settings(path)300 parts = split_path(args.key)301 value = parse_value(args.value)302 changed = set_at(data, parts, value)303 if not changed:304 print(f"unchanged: {args.key} = {json.dumps(value)}")305 return 0306 atomic_write(path, data)307 print(f"set: {args.key} = {json.dumps(value)}")308 return 0309 310 311def cmd_unset(args: argparse.Namespace) -> int:312 path = Path(args.file)313 data = load_settings(path)314 if not unset_at(data, split_path(args.key)):315 print(f"unchanged: {args.key} (not present)")316 return 0317 atomic_write(path, data)318 print(f"unset: {args.key}")319 return 0320 321 322def cmd_list_supported(args: argparse.Namespace) -> int:323 paths = supported_paths()324 if not paths:325 print(326 "error: could not load the supported-paths list; "327 "run from a cmux checkout or reinstall the cmux-settings skill",328 file=sys.stderr,329 )330 return 1331 for p in paths:332 print(p)333 return 0334 335 336def cmd_validate(args: argparse.Namespace) -> int:337 path = Path(args.file)338 data = load_settings(path)339 supported = set(supported_paths())340 if not supported:341 print(342 "warn: could not load schema path list; only checked JSON parses",343 file=sys.stderr,344 )345 print(f"ok: {path} parses")346 return 0347 unknown: list[str] = []348 # Keep this in sync with top-level structural keys in web/data/cmux.schema.json.349 structural = {350 "$schema",351 "schemaVersion",352 "actions",353 "ui",354 "commands",355 "vault",356 "newWorkspaceCommand",357 "surfaceTabBarButtons",358 "rightSidebar",359 }360 for top, value in data.items():361 if top in structural:362 continue363 if not isinstance(value, dict):364 unknown.append(top)365 continue366 for full in flatten(top, value):367 if full in supported:368 continue369 if any(full.startswith(s + ".") for s in supported):370 continue371 unknown.append(full)372 if unknown:373 print("unknown settings keys:")374 for u in unknown:375 print(f" {u}")376 return 1377 print(f"ok: {path} parses and all settings keys are recognized")378 return 0379 380 381def cmd_open(args: argparse.Namespace) -> int:382 path = Path(args.file)383 path.parent.mkdir(parents=True, exist_ok=True)384 if not path.exists():385 atomic_write(path, {"$schema": SCHEMA_URL, "schemaVersion": 1})386 editor = os.environ.get("EDITOR") or os.environ.get("VISUAL")387 if editor:388 try:389 editor_args = shlex.split(editor)390 except ValueError as e:391 print(f"error: invalid editor command: {e}", file=sys.stderr)392 return 1393 if not editor_args:394 print("error: editor command is empty", file=sys.stderr)395 return 1396 try:397 return subprocess.call([*editor_args, str(path)])398 except FileNotFoundError:399 print(f"error: editor not found: {editor_args[0]}", file=sys.stderr)400 return 1401 for app in (["code", "--wait"], ["cursor", "--wait"], ["open", "-e"], ["xdg-open"]):402 if shutil.which(app[0]):403 try:404 return subprocess.call([*app, str(path)])405 except FileNotFoundError:406 continue407 print(408 "error: no suitable editor found; set $EDITOR or install code, cursor, open, or xdg-open",409 file=sys.stderr,410 )411 return 1412 413 414def main() -> int:415 parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)416 parser.add_argument(417 "--file",418 default=str(DEFAULT_PATH),419 help=f"settings file path (default: {DEFAULT_PATH})",420 )421 sub = parser.add_subparsers(dest="cmd", required=True)422 sub.add_parser("path").set_defaults(func=cmd_path)423 424 p_dump = sub.add_parser("dump")425 p_dump.add_argument("--no-comments", action="store_true")426 p_dump.set_defaults(func=cmd_dump)427 428 p_get = sub.add_parser("get")429 p_get.add_argument("key")430 p_get.set_defaults(func=cmd_get)431 432 p_set = sub.add_parser("set")433 p_set.add_argument("key")434 p_set.add_argument("value")435 p_set.set_defaults(func=cmd_set)436 437 p_unset = sub.add_parser("unset")438 p_unset.add_argument("key")439 p_unset.set_defaults(func=cmd_unset)440 441 sub.add_parser("list-supported").set_defaults(func=cmd_list_supported)442 sub.add_parser("validate").set_defaults(func=cmd_validate)443 sub.add_parser("open").set_defaults(func=cmd_open)444 445 args = parser.parse_args()446 return args.func(args)447 448 449if __name__ == "__main__":450 sys.exit(main())451 