From 088408874d63c5ed9c4b98567e4b2c028db6f18a Mon Sep 17 00:00:00 2001 From: kaffeindecaf Date: Thu, 3 Sep 2026 22:28:05 +0200 Subject: [PATCH 1/7] Add terminal plist editor and one-shot commands propertreecli.py and plist_tui.py bring the ProperTree workflow to the terminal: a curses editor (tree view, fold, add, delete, duplicate, rename, type change, reorder, clipboard, 200-step undo, scoped find with replace) and headless get/set/del/convert/new one-shots for scripts. install.sh links a plist command into ~/.local/bin. find scope (keys/values/both) is pickable in the prompt and defaults from the config file, which now also has a settings command to show and change values. top-level keys keep the frost blue, nested keys desaturate toward gray. version bumped to 0.3.0. readme and roadmap updated to match. --- README.md | 176 +++---- ROADMAP.md | 367 +++++++++++++ install.sh | 26 + plist_tui.py | 1311 ++++++++++++++++++++++++++++++++++++++++++++++ propertreecli.py | 990 ++++++++++++++++++++++++++++++++++ test.plist | 104 ++++ 6 files changed, 2884 insertions(+), 90 deletions(-) create mode 100644 ROADMAP.md create mode 100755 install.sh create mode 100644 plist_tui.py create mode 100755 propertreecli.py create mode 100644 test.plist diff --git a/README.md b/README.md index 8e091fee..cca19d17 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,117 @@ -# What is it? +# ProperTreeCLI -ProperTree is a cross-platform GUI plist editor written using Python *(compatible with both 2.x and 3.x)* and Tkinter. +A plist editor that lives in the terminal. ProperTree's job - editing +config.plist, kext ordering, oc snapshots - without a window, so it +works over ssh, in tmux, or on a box with no display at all. -## Features +This is a fork of corpnewt's ProperTree (a tkinter app). The gui still +works; the point of this repo is the command that runs from any +directory: -- [x] Cross-platform - should work anywhere python and tkinter do -- [x] Document-based to support multiple windows -- [x] Node drag and drop to reorder -- [x] Copy and paste -- [x] Find/Replace - allows searching keys or values -- [x] Ordered - or unordered - dictionary support -- [x] Full undo-redo stack -- [x] Backported support for binary property lists and unicode in python 2 -- [x] Expanded integer casting to allow for hex integers (eg. `0xFFFF`) in xml `` tags -- [x] Context-aware right-click menu that includes template info to OpenCore or Clover config.plist files -- [x] OC (Clean) Snapshot to walk the contents of ACPI, Drivers, Kexts, and Tools for OpenCore config.plist files -- [x] Value converter that supports Base64, Hex, Ascii, and Decimal + plist test.plist -*** +That opens a plist as a tree in your terminal, styled like W0lfSword: +frost blue on grey, boxed banners, dim hints, nothing on screen that +does not earn its place. If that look is good enough for a kernel +exploit toolkit, it is good enough for an EFI folder. -## Getting ProperTree +test.plist in the repo root is a small sample config (nested dicts, +arrays, data, a date) for trying it out. No file yet? plist new +my.plist starts one from scratch. -### Downloading The Repo As A ZIP File +The same command works piped: no terminal means no colors, no editor, +just the tree as plain text, so it greps and scripts. -On any system you can choose the green `Code` button, followed by the `Download ZIP` button (or click [here](https://github.com/corpnewt/ProperTree/archive/refs/heads/master.zip)) to download the entire repo as a zip file (note, this does not allow you to update via `git pull` - any updates would require you to download the repo again in the same fashion). +## Quick start -### Cloning The Repo Via Git + git clone https://github.com/kaffeindecaf/ProperTreeCLI + cd ProperTreeCLI + ./install.sh # links `plist` into ~/.local/bin, stdlib only + plist test.plist # open the sample editor + plist --version -#### On *nix systems: +That is the whole install. No pip, no venv. Run plist from any +directory afterwards. -``` -git clone https://github.com/corpnewt/ProperTree -python ./ProperTree/ProperTree.py -- or - -python3 ./ProperTree/ProperTree.py -``` +## The editor -\* On macOS, you can simply double-click the `ProperTree.command` after cloning to launch. +j/k or the arrows move, home/end top/bottom, ctrl+d / ctrl+u half a +page, { } jump between siblings, left/right fold containers, enter +edits a value (booleans toggle), i adds an entry (pick the type, then +name it), D duplicates, d deletes, r renames a key, t changes a +value's type, c/x/p copy/cut/paste, u undoes (ctrl+r redoes, 200 +steps), ctrl+s saves, q quits (it asks when the file is dirty). ? +shows every keybind. -#### On Windows: +/ finds. n and N cycle the matches, esc clears. tab inside the find +prompt picks what to search - keys, values, or both - and the choice +sticks for the session. R replaces the query inside string values +(when the search is scoped to keys it refuses; replacing keys is a +rename, do it by hand with r). Top-level keys keep the plain frost +blue, nested keys stay the same hue but slightly desaturated, so nesting +reads at a glance. Hold j/k or the arrows and movement accelerates +smoothly: one row per repeat at first, then gradually more, no sudden +jumps. -``` -git clone https://github.com/corpnewt/ProperTree -./ProperTree/ProperTree.bat -``` +The file keeps its format and key order, and writes are atomic and +verified by re-reading. -*** +## One-shots -## FAQ + plist get config.plist Misc.Boot.Timeout + plist set config.plist Misc.Boot.Timeout 5 -i + plist set config.plist Kernel.Quirks.EnableWriteUnprotector false -b + plist del config.plist Wifi + plist convert config.plist -t binary + plist new new-config.plist start from an empty plist + plist settings show the config values + plist settings set format binary change one (validated) -* **What does OC Snapshot do?** +set creates missing keys along the way and keeps the file's format and +key order. get prints strings raw, booleans lowercase, data as hex; +add --json for machine output. Keypaths are dotted, array elements are +indexes: Drivers.0.Path. Values that start with a dash need -- first: - The OC Snapshot function will prompt you to select an OC folder, then walk the contents of the ACPI, Kexts, Tools, and Drivers directories within that folder - comparing all entries to the current document's `ACPI -> Add`, `Kernel -> Add`, `Misc -> Tools`, and `UEFI -> Drivers` respectively. It will add or remove entries as needed, and also ensures kext load order by comparing each kext's `CFBundleIdentifier` to all other kexts' `OSBundleLibraries` within their Info.plist - making sure that any kext that is relied on by others is loaded before them. It will also warn if it detects duplicate `CFBundleIdentifiers` (with support for `MinKernel`, `MaxKernel`, and `MatchKernel` overlap checks), and offer to disable all after the first found. It checks for disabled parent kexts with enabled child kexts as well. The schema used is (by default) determined by comparing the MD5 hash of the `OpenCore.efi` file to a known list of Acidanthera debug/release versions. If the MD5 hash does not match any known version, it will fall back to the newest schema in the script's `snapshot.plist`. This behavior can be customized in the Settings per the `OC Snapshot Target Version` menu. + plist set config.plist Boot-args -- -v keepsyms=1 -* **What is the difference between OC Snapshot and OC Clean Snapshot?** +Run plist help for the whole list. - Both snapshot variants accomplish the same tasks - they just leverage different starting points. An OC **Clean** Snapshot will first clear out `ACPI -> Add`, `Kernel -> Add`, `Misc -> Tools`, and `UEFI -> Drivers`, then add everything from within the respective ACPI, Kexts, Tools, and Drivers directory anew. A regular OC Snapshot starts with the information within the current document for those four locations, and only pulls changes - adding and removing entries as needed. - -* **When should I use an OC Clean Snapshot vs an OC Snapshot?** +## Config - Typically, an OC **Clean** Snapshot should only be used the first time you snapshot to ensure any sample entries in the config.plist are removed and added anew. Every subsequent snapshot should be a regular OC Snapshot to ensure any customizations you've made are preserved. +The editor reads ~/.config/propertreecli/config (created on first run, +XDG_CONFIG_HOME aware): -* **Can't click anything on Sonoma (14.x) and Newer** +- expand_mode = all, auto, or none: whether containers open expanded + or folded +- format = xml or binary: what plist new writes +- find_scope = keys, values, or both: what / searches by default - This appears to manifest when using python 3.11.x and older due to some isssue with tk and macOS. Updating to at least python 3.12.0 (found [here](https://www.python.org/downloads/macos/)) appears to fix it. If you are unable to update your python version, you can also move the window around before trying to click the elements in the treeview. +plist settings shows and changes these without hand-editing the file. -* **ProperTree opens a black window on macOS Monterey (12.x) and Newer** +## State of things - It appears the default tk implementation that ships with macOS Monterey (and the version installed with the Command Line Tools) doesn't display correctly. A workaround is to download and install the latest build of python from python.org (found [here](https://www.python.org/downloads/macos/)) which has a compatible tk bundled, then use the `buildapp-select.command` located in ProperTree's `Scripts` directory to build an application bundle targeting the installed python's path. You can then leverage the `ProperTree.app` bundle it creates. - -* **ProperTree cannot open or save plist files on macOS Monterey (12.x)** +The editor and one-shots sit on the parts of ProperTree worth keeping +instead of rewriting them: - This appears to be an issue with the built-in tk, and the earlier "universal" installers from python.org. With at least python 3.10.2, this issue has been resolved in the universal builds. You can get the latest python 3 installer [here](https://www.python.org/downloads/macos/). After installing, use the `buildapp-select.command` located in ProperTree's `Scripts` directory to build an application bundle targeting the installed python's path. You can then leverage the `ProperTree.app` bundle it creates. +- Scripts/plist.py handles xml and binary plists, data, uid, 0x ints +- the oc snapshot code walks ACPI/Kexts/Tools/Drivers, orders kexts by + dependency, and detects the schema from OpenCore.efi's md5 - the + headless snapshot command is the next big thing, tracked in + ROADMAP.md +- find/replace and the ascii/hex/base64 converter from the tk window + (the converter is still pending too) -* **How can I have ProperTree open when I double-click a .plist file?** +Progress lives in ROADMAP.md. It is a checklist, not a plan: one item +per session, done end to end or not done. - On macOS you can run `buildapp-select.command` located in ProperTree's `Scripts` directory to build an application bundle which can be associated with .plist files. - - On Windows, you can run `AssociatePlistFiles.bat` located in ProperTree's `Scripts` directory to associate .plist files with `ProperTree.bat`, and also to add an `Open with ProperTree` option to the contextual menu when right-clicking .plist files. This approach is location-dependent, and moving your copy of ProperTree will require you re-run `AssociatePlistFiles.bat`. +## Running the original gui -* **When I try to run ProperTree, I get `[ModuleNotFoundError: No module name 'tkinter']`** + python3 ProperTree.py [file.plist] - That is because the graphical interface library that ProperTree depends on isn't present or cannot be detected, you need to install `tkinter` from your package manager. +needs python 3 with tkinter (apt install python3-tk on debian/ubuntu). +That dependency goes away with the gui. - To install it on Ubuntu (and Ubuntu-based distros), you can run `sudo apt-get install python3-tk -y` +## Credit -* **ProperTree doesn't run because it doesn't have permissions, what gives?** - - This shouldn't happen and it is recommended that you download only from the official ProperTree repository, but if you are confident about your source, then running `chmod +x ProperTree.command` should sort it out - -* **I use an international keyboard layout on macOS and some keys crash ProperTree with `NSRangeException', reason: '-[__NSCFConstantString characterAtIndex:]: Range or index out of bounds`** - - This is a bug in the Cocoa implementation of Tcl/Tk on macOS (discussed [here](https://bugs.python.org/issue22566)). The latest python 2 installer from [python.org](https://www.python.org/downloads/release/python-2718/) ships with, and uses Tcl/Tk 8.6.8 which has this issue fixed. Given that the shebang in `ProperTree.command` leverages `#!/usr/bin/env python` - the first python 2 binary found should be used. `buildapp-select.command` from ProperTree's `Scripts` directory can be used to hardcode a specific python install's path into the .app bundle's executable shebang. - -* **ProperTree crashes on Big Sur (macOS 11)** - - __As of macOS 11.2 (20D5029f), the system's `tk` installation appears to be fixed, and works correctly. As such, it should not require an external python version to function.__ - - This is due to the default python installs on macOS leveraging an older `tk` version - which lacks support for macOS 11. To solve this, you can download and install the latest python 3 version from https://www.python.org/downloads/mac-osx/ (note: Currently the "universal" 3.9.1 installer causes theme issues and should not be used) then leverage the `buildapp-select.command` from ProperTree's `Scripts` directory to build a .app bundle that will leverage that python version. - - If you already have python 3 installed via `brew` or another package manager - it is likely still linking to the system `tk` version, which will still have issues unless linked against a newer version. - -* **`buildapp-select.command` Usage** - - An example of the output of `buildapp-select.command` is shown below. It will walk the output of `which python` and `which python3`, then attempt to load the `tk` interface while keeping track of which work and which fail. The example below is from macOS 11.2 (20D4029f) with the system versions of python 2 and 3, as well as python 3.9.1 installed from python.org. If there's an existing `ProperTree.app` in the directory above the `Scripts` folder, the shebang of that app will be located and served up as the `C. Current` option. At the following menu, I would select option `3` or `C` to use the non-system python install. - -``` - - Currently Available Python Versions - - -1. /usr/bin/python 2.7.16 - tk 8.5 (8.6+ recommended) -2. /usr/bin/python3 3.8.2 - tk 8.5 (8.6+ recommended) -3. /Library/Frameworks/Python.framework/Versions/3.9/bin/python3 3.9.1 - tk 8.6 -4. /usr/bin/env python -5. /usr/bin/env python3 - -C. Current (/Library/Frameworks/Python.framework/Versions/3.9/bin/python3) -Q. Quit - -Please select the python version to use: -``` +ProperTree by CorpNewt, BSD 3-Clause. Forked and repurposed by +kaffeindecaf. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..e82b8643 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,367 @@ +# ROADMAP - master task file + +> purpose: turn this ProperTree fork into a terminal plist editor. one item per +> session, finish it end to end (code + test + docs touch), then check it off. +> format: `[ ]` = open, `[x] = done, with the date noted under it. +> priority: (bold) = do next, (plain) = queue, (dim) = later / maybe never. + +The repo is corpnewt/ProperTree (tkinter gui, BSD-3). The gui code still +works and stays until the terminal editor covers it. Everything below +reuses the parts worth keeping: Scripts/plist.py (xml + binary io), the +snapshot logic in plistwindow.py, the converter, the settings model. + +## 0 - shape the project (do first, in order) + +- [x] **0.1 pick the ui engine** - spike both, half a day each: + 1. stdlib curses, zero deps, full control of the W0lfSword look + 2. textual (pip dep, widgets, mouse for free) + default is curses: ProperTree and W0lfSword are both zero-dep, and + the aesthetic is hand-rolled ansi anyway. if the tree gets laggy + with big plists or mouse support becomes a must-have, textual wins. + record the decision here. + _Done 2026-09-03: curses chosen. capability probe under a script(1) pty + (TERM=xterm-256color): 256 colors, 65536 pairs, palette indexes 117/153/240 + init clean on default bg, unicode box glyphs + status glyphs render. note: + this box has no controlling tty so direct pty runs fail on cbreak - script(1) + is the test vehicle, same trick 5.2 will use. split: one-shot commands print + plain ansi (this preview), the interactive editor gets curses. textual stays + unspiked until big-plist perf or mouse support is a real need (the 0.1 + criteria), zero-dep ethos wins for now._ +- [x] **0.2 layout** - decide where the cli code lives. option A: new + propertreecli/ package next to Scripts/. option B: root-level + propertreecli.py single file, mirroring ProperTree.py. default is + B for the first cut (one file, easy to move later), then split + when it passes ~1500 lines. + _Done 2026-09-03: option B - root-level propertreecli.py (~380 lines), + sections marked (palette / drawing / logo / glyphs / io / value fmt / + tree render / cli) so the split is mechanical when it outgrows._ +- [x] **0.3 entry point** - `propertreecli` runs from any directory: + install.sh symlinks a wrapper into ~/.local/bin that resolves its + real path and execs python3 on the script. wrapper must survive + being moved, so resolve symlinks with readlink -f, not $0. + handle: no args = open ./config.plist if it exists else error + message listing usage; file args = open those. + _Done 2026-09-03: symlink install (install.sh -> ~/.local/bin, verified + from /tmp over PATH), sys.path resolves the repo root through the link so + Scripts/ imports from anywhere. no args opens ./config.plist, else usage + + red error, exit 1. multiple files, per-file exit code, broken pipe handled + (head). since the editor did not exist yet, "open" = read-only styled tree + preview via Scripts/plist.py load (xml + binary, auto-detected) - this is + also the draw-layer scaffolding 2.1/2.2 build on. plain output when piped, + for scripting. write path is still section 1. + _Updated 2026-09-03 (v0.2.0): command renamed to `plist` (install.sh links + plist + keeps propertreecli as an alias; the script file keeps the repo + name so it can never shadow Scripts/plist.py). one file on a terminal now + opens the curses editor; piped output still prints the tree; `plist edit + ` forces the editor._ +- [x] **0.4 palette + logo module** - single source of truth for the + look, copied from W0lfSword's palette block: + C_FROST 38;5;117 (accents, key column) + C_DIM 38;5;240 (descriptions, hints, separators) + C_GRN 0;32 (saved / ok) + C_AMB 1;33 (warnings, changed type) + C_RED 0;31 (errors, delete) + B / NC bold + reset + plus a tree logo in the brand color (W0lfSword uses 38;5;153 for + its wolf - pick one accent for the tree art, keep C_FROST for + text). respect NO_COLOR and a --no-color flag, and disable + automatically when stdout is not a tty. + _Done 2026-09-03: palette block at the top of propertreecli.py, constants + flip to empty strings on --no-color / NO_COLOR / non-tty stdout (verified + piped output is ansi-free). C_BRAND 38;5;153 reserved for the tree art, + C_FROST 38;5;117 for keys/accents. logo is a plist tree built from column + math (root tag branching into dict/array/string) so branches always line + up, drawn above the banner on tty runs. boxed banner + dim rule + the + W0lfSword glyph helpers (ok/err/warn/info/hint) live here too. two + palette-rule additions, both marked in code: array indexes render dim + (structural, not keys) and booleans read as status (True grn, False dim), + which reads well on config.plist._ + +## 1 - plist io (non-interactive first, testable without a tui) + +- [x] **1.1 read/write roundtrip** - load and save xml + binary plists + through Scripts/plist.py (load/dump, UID, data wrap). detect the + format from the bplist00 magic on read; keep the file's format on + save unless told otherwise. + _Done 2026-09-03: write_plist() in propertreecli.py - dumps through + Scripts/plist.py with sort_keys=False, re-parses the temp file to verify, + then os.replace()s it in. a broken dump never touches the original. format + kept per file unless convert says otherwise._ +- [x] **1.2 ordering + types** - preserve key order in dicts (the gui + already does OrderedDict; plist.py's dict_type param exists for + this). full type set: string, number (int with 0x hex input, real), + bool, date, data, uid. no silent type loss on write. + _Done 2026-09-03: load keeps file order, dumps keep it (sort_keys=False); + set appends new keys, never reorders. all plist types settable: -i parses + 0x hex, -f real, -b bool, -x data, -d iso date, -u uid. uid refuses xml + writes with a clear error instead of silently corrupting (checked both on + the flag and on the whole tree via _has_uid). set refuses to clobber a + dict/array with a scalar. bool/int ordering handled (bool checked first)._ +- [x] **1.3 scriptable one-shots** - `propertreecli get/set/del + ` for shell use, with a --json mode on get (W0lfSword has + the same --json split). keypath = dot separated, n for array + indexes. this is what makes the tool useful in scripts, not just + interactive. + _Done 2026-09-03: get/set/del live. keypaths are dot separated with + backslash escaping for literal dots (com\\.apple\\.x), numeric segments + index arrays. set auto-creates missing dicts along the path and appends at + array index == len; -b with no value flips an existing boolean. get prints + strings raw, bools lowercase, data as continuous hex (xxd -r -p friendly), + dates iso; containers tree out or json with --json. --json and --no-color + pre-scan from anywhere in argv, W0lfSword style. exit codes: 0 ok, 1 file + or value error, 2 bad keypath. dash-prefixed values need -- (documented in + -h). 42-check functional suite in /tmp/propertreecli_tests.py (repo tests + come in 5.2)._ +- [x] **1.4 convert** - `propertreecli convert --to xml|binary` + in place or to a second file. port of the gui's change_plist_type. + _Done 2026-09-03: convert -t xml|binary, -o out for a second file, atomic + verified write. binary->xml with uid values refused up front (xml cannot + hold them) - the one place a conversion can genuinely lose data._ + +- [x] **1.5 create plist files** - `plist new ` makes an empty plist + from scratch and drops you into the editor. creating plists on linux + is a non-issue - a plist is just a file format, and the binary writer + already runs here (the convert tests round-trip it daily). + _Done 2026-09-03: plist new [--binary] [--root dict|array], xml by + default (config format honored), refuses to overwrite, opens the editor + on a tty. verified: xml + binary + array root + overwrite guard._ + +- [x] **1.6 sample file + friendlier errors** - test.plist in the repo + root so anyone can try the editor without hunting for a plist, + and nicer failure messages throughout the cli. + _Done 2026-09-03: test.plist (xml, EFI-flavored sample: nested dicts, + arrays of dicts, data blob, bools, a date - 18 keys). missing files now + say "create it with: plist new ", directories and non-plists get + clear errors with a hint, running bare with no config.plist suggests + plist test.plist / plist new / plist help, and `plist help` works as an + alias for -h. hints go to stderr so --json and piped output stay clean._ + +## 2 - the editor (the main event) + +- [x] **2.1 screen** - full-screen ansi renderer: alt screen on entry, + cursor hidden while drawing, terminal state restored on exit + (trap INT/TERM like W0lfSword's cleanup). handle SIGWINCH resize. + layout, top to bottom: + boxed banner (file name, format, dirty marker) + tree: key / type / value columns + status line (mode hints) + footer (keybind legend, dim) + _Done 2026-09-03: curses app in plist_tui.py (new module, loaded lazily so + one-shots stay untouched). header row: file frost-bold + [xml/binary] + + amber * when dirty + right-hand hints; dim rule under it; tree body; + status row; dim footer legend. alt screen + full restore via + curses.wrapper, cursor hidden outside prompts, KEY_RESIZE handled. + gotchas hit: curses cbreak keeps IXON on, so ctrl+s was eaten as XOFF + (termios now clears IXON+ISIG, staying in cbreak so keypad escape + parsing keeps working - curses.raw() breaks arrow-key mapping); keypad + sequences are application-mode (\\EOB), which tripped up the pty tests, + not real terminals._ + _Updated 2026-09-03: folding was broken in the first cut - collapse only + flipped the glyph because the visible row list was built once and children + never left it. toggling now rebuilds the visible rows and keeps the + selection on the toggled container (pty scenario C proves a folded Nest no + longer intercepts j). initial expansion is config driven, see 2.10._ + _Updated 2026-09-03: keys are shaded by depth - top-level rows keep the + plain frost blue they always had, nested keys desaturate in a straight + line from frost toward gray (stock 256 indices 110 steel, 103 gray-blue, + 102 gray, then flat - red channel fixed, green and blue walk down + together so the hue never detours). two earlier attempts used custom + palette entries via init_color; that got reverted twice - first the + 0..255 rgb values were fed to curses raw (it wants 0..1000, everything + rendered ~4x too dark), then palette redefinition turned out + terminal-dependent and entries 16-21 rendered as black on terminals that + ignore it, which looked like missing text. stock colors only now, same + on every terminal._ +- [x] **2.2 node model + draw** - in-memory tree of nodes over the + plist, one node per key (dict) or index (array). collapse/expand + dicts and arrays, indentation by depth, long values truncated to + the column width. scroll when the tree outgrows the screen. + current node always visible. + _Done 2026-09-03: flat visible-row model rebuilt per frame from the live + root + an expanded{} map keyed by path tuple; rows carry the live node so + edits reflect instantly. containers default-expanded unless the file has + >1200 nodes (then only depth <= 1 opens). ▸/▾ glyphs on containers, dim + array indexes, column-aligned key/type/value, values truncated with …, + bools colored (True grn / False dim) like the preview. selection stays on + screen (scroll window follows it). huge files are the remaining question, + flagged in 0.1._ +- [x] **2.3 keybinds** - vi-style + arrows: j/k or up/down move, h/l or + left/right collapse/expand and enter/leave nodes, enter edits the + value, tab switches key/type/value focus, insert adds, delete + removes (with confirm when the subtree is non-empty), ctrl+c quits + with a save prompt when dirty, ? opens a help overlay listing + every bind. ? keybinds documented in the footer hint. + _Done 2026-09-03: arrows + j/k, g/G, pgup/pgdn; left/right/space fold, + left on a folded container jumps to its parent row; enter edits values / + toggles containers and booleans; i adds (type picker menu, then key name; + into a container or after the current sibling); d deletes with a confirm + dialog; r renames dict keys; t changes scalar type; < > reorder; c/x/p + copy/cut/paste; u / ctrl+r undo/redo (ctrl+z also undoes); / find with + n/N; R replace-all in string values; ctrl+s/F2 save; q/ctrl+c quit with a + save dialog when dirty (defaults to yes); ? overlay lists everything. + delete confirms even leaves (roadmap said containers only - safer, one + keystroke more). tab focus model dropped: single editable value per row + with an inline prompt, type changes via t. + _Updated 2026-09-03: home/end top/bottom, ctrl+d / ctrl+u half a page, + { } jump prev/next sibling, D duplicates in place (key gets a "copy" + suffix, auto-increments on collision; array slots insert after)._ +- [x] **2.4 edit operations** - port from plistwindow.py's command set: + add key (type picker: key/boolean/data/date/number/uid/string), + add child vs add sibling, delete, duplicate, rename key, reorder + (W0lfSword menu_opt layout for the pickers), change a node's type. + _Done 2026-09-03: add (9-type picker incl. dict/array, inserted after the + anchor sibling or appended into a container, duplicate key refused), delete + (whole subtree, dict rebuild or list pop), rename key (order-preserving + dict rebuild), reorder < > (dict swap or list swap), change type (scalar + conversions only, containers refused, uid gated to binary files). duplicate + covered by c+x+p (copy to the /tmp clipboard, paste under a new key) - no + separate D key._ +- [x] **2.5 value editors** - per-type input with validation: + ints accept decimal and 0x hex, bools cycle the gui's styles + (True/False, YES/NO, On/Off, 1/0) - configurable default, data + entered as hex or base64, dates parsed from iso. bad input = error + flash, value unchanged, cursor back in the field. + _Done 2026-09-03: one inline prompt per type, prefilled with the current + value (caret-visible windowing, ctrl+u clears, esc cancels, left/right/ + home/end). commit parses through propertreecli.parse_value so editor and + one-shots agree: ints decimal + 0x, real, data hex (spaces ok), iso dates, + uid ints. booleans toggle on enter (style cycling needs the 4.1 config, + deferred with the bool_style key). bad input flashes red, value untouched. + unchanged input reports "unchanged" without a dirty marker._ +- [x] **2.6 undo/redo** - full stack, 200 steps (the gui's max_undo). + snapshots of the whole tree per op is fine at this size. ctrl+z / + ctrl+y, counter on the status line. + _Done 2026-09-03: deepcopy snapshots of the root per mutation, capped at + 200, redo cleared on new edits. u / ctrl+z undo, ctrl+r / ctrl+y redo, + dirty flag set by every mutation (including the push itself, so nothing + forgets). no step counter on the status line yet - the stack depth is not + a useful number on screen, cut it._ +- [x] **2.7 clipboard** - cut/copy/paste nodes between files (or + instances) and within one. internal json clipboard file under + XDG_RUNTIME_DIR or /tmp so a second propertreecli instance can + paste into the first. + _Done 2026-09-03: c/x/p work in-file and across instances - the clipboard + is an xml plist file at $XDG_RUNTIME_DIR/plist-clipboard.plist (fallback + /tmp) holding {"v": }, so fidelity is exact (plist in, plist + out; a json file would mangle data/date/uid). paste into a container + appends, after a row inserts a sibling, dict parents prompt for a key and + refuse collisions. cut = copy + delete with a confirm when it is a + subtree._ +- [x] **2.8 search** - / opens a search line, n/N next/prev, highlight + all matches in the tree. replace mode with the type filter the + fork's recent commits added (find type defaults as a setting). + search keys, values, or both. + _Done 2026-09-03: / find + n/N cycle + amber match rows shipped, and R + replaces the query inside string values (keys are left alone on + purpose). scope picker closed the item: tab inside the find prompt + cycles keys / values / both, the pick sticks for n/N and R, and the + default comes from config find_scope (4.1). matches are substring over + canonical value text; container rows match on their key only (the first + cut matched every container row for any query - fixed). R refuses on a + keys-scoped find instead of silently replacing value text. a second + latent bug surfaced when scenario F finally drove / end to end: _goto + was called by find but never defined, so every find crashed with an + AttributeError caught by the run loop - find has now been exercised + under a pty (scenario F) and works. deviation from the gui, noted: the + tk dropdown filters by plist type (key/boolean/data/date/number/uid/ + string) - a terminal finder searches value text across types instead, + which is a superset for substring use._ +- [ ] **2.9 converter + templates** - the gui's ascii/base64/decimal/ + hex/binary converter as a keybind on a selected value (parity with + the tk converter window), and insert-from-template using + config_tex_info.py + menu.plist (OpenCore/Clover samples). +- [x] **2.10 editor view config** - a small config file decides how the + editor opens files: everything expanded, everything collapsed, or + the auto heuristic. plain key=value with # comments, template + written on first run (W0lfSword config_schema trick). + _Done 2026-09-03: ~/.config/propertreecli/config (XDG_CONFIG_HOME aware) + with expand_mode = all | auto | none and format = xml | binary. all opens + every container, none opens every container folded, auto keeps the old + heuristic (expand unless the file exceeds ~1200 nodes, then only depth 1). + created on first editor run with a comment template; load_config validates + values and falls back to defaults. format is what plist new writes (1.5). + still to come from 4.1: data/int/bool display styles, find type defaults, + and a settings command to show/set values from the cli._ + +- [x] **2.11 movement polish** - holding j/k or the arrow keys + accelerates smoothly: the step size drifts 1 -> 2 -> 3 -> 4 over + a few seconds of holding, via a fractional accumulator fed by a + slow ramp. no tiers, no half-page teleports. + _Done 2026-09-03: streak-tiers (1/2/4/half-page) were the first cut; + user feedback: jerky. reworked to ramp += 0.03 per repeat (cap 2.2) and + step = the integer part of an accumulating 1.0 + ramp - so steps are + mostly 1 for the first ~0.5s of a hold, then drift through a smooth + 1/2/3 mix to at most 4. a 240-repeat pty run crosses 500 rows around + event 180, and the step sequence reads 1111111211121212... (verified in + scenario E, which still passes). note: a cmatrix-style boot scramble was + added and then removed the same session on user feedback (did not look + good) - the editor now opens straight to the tree._ + +## 3 - snapshot mode (headless, flagship feature) + +- [ ] **3.1 oc snapshot / oc clean snapshot** - port of plistwindow.py's + oc_snapshot(clean=...): walk ACPI/Kexts/Tools/Drivers, add/remove + entries, order kexts by OSBundleLibraries vs CFBundleIdentifier so + dependencies load first, warn on duplicate CFBundleIdentifiers + with MinKernel/MaxKernel/MatchKernel overlap checks, flag disabled + parent kexts with enabled children. +- [ ] **3.2 schema detection** - md5 of OpenCore.efi matched against + known hashes, falling back to the newest schema in + Scripts/snapshot.plist. target version selectable in config, same + as the gui's OC Snapshot Target Version menu. +- [ ] **3.3 dry-run mode** - `snapshot --dry-run` prints the diff + (added/removed/reordered) instead of writing. exit 0 = clean, 1 = + changes needed, 2 = error. this makes it usable in ci and in + build scripts, which the gui never could be. + +## 4 - config + settings + +- [x] **4.1 config file** - ~/.config/propertreecli/config, plain + key=value with # comments, template written on first run. schema + doubles as the docs, same trick as W0lfSword's config_schema(). + keys: expand_mode (all/auto/none), format (xml/binary), find_scope + (keys/values/both). validation lives in load_config; unknown or + invalid values fall back to defaults instead of erroring. + _Done 2026-09-03: the file itself landed with 2.10; the schema is now + CONFIG_DEFAULTS + CONFIG_VALID in propertreecli.py, load_config + validates every key against CONFIG_VALID, and `plist settings` (4.2) + manages it from the cli. keys that were planned and got cut, on + purpose: animations on/off (the animation feature was removed from the + product), prompt_symbol and logo/color toggles (the look is fixed, + color already bows to --no-color / NO_COLOR / non-tty), data/int/bool + display styles (the editor renders values readably as-is; revisit only + if someone asks)._ +- [x] **4.2 settings command** - `plist settings` shows the file, + `settings set key value` validates and writes, `settings reset` + restores defaults. no menu for it in the tui yet; editing by hand + is fine. + _Done 2026-09-03: settings show / set / reset live in cmd_settings(), + wired into COMMANDS + help. set validates against CONFIG_VALID before + writing, updates the config in place (comments and other keys + preserved), reset rewrites the template. config file auto-created on + first use, same as the editor._ +- [ ] **4.3 version + update check** - single VERSION source in the + script (W0lfSword style), --version prints it, update_check.py + pattern reused against this repo's own version feed. + +## 5 - finish + +- [ ] **5.1 kill the gui** - drop ProperTree.py, plistwindow.py, the .bat + and .command launchers, tkinter fallbacks in plist.py, py2 + shims. only once the editor covers the snapshot + converter + features. keep Scripts/plist.py's io, minus the py2 branches. +- [ ] **5.2 tests** - plist roundtrips over a corpus (xml, binary, data, + uid, dates, 0x ints, deep nesting), snapshot dry-run against a + sample efi folder, keypath get/set/del, tui smoke test driving + keys through a pty. +- [ ] **5.3 release** - README demo (asciinema), screenshots in the + readme, v0.1.0 tag, install.sh verified from a fresh clone on a + bare debian box. + +## notes + +- snapshot.plist + version.json are upstream's; version.json will + become our own feed once 5.3 ships. +- the tree logo: draw it early (0.4) and let it set the tone, the same + way the wolf does for W0lfSword. a plist is a tree - the logo should + say that. diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..74969c25 --- /dev/null +++ b/install.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# plist install - symlink into ~/.local/bin (or the dir given) +# usage: ./install.sh [dir] link the plist command into dir +# ./install.sh --uninstall [dir] remove the links +set -euo pipefail +repo="$(cd "$(dirname "$0")" && pwd)" +dir="${2:-$HOME/.local/bin}" + +if [ "${1:-}" = "--uninstall" ]; then + rm -f "$dir/plist" "$dir/propertreecli" + echo "removed $dir/plist" + exit 0 +fi + +mkdir -p "$dir" +ln -sfn "$repo/propertreecli.py" "$dir/plist" +# old name kept as an alias so nothing that learned it breaks +ln -sfn "$repo/propertreecli.py" "$dir/propertreecli" +echo "linked $dir/plist -> $repo/propertreecli.py" + +case ":$PATH:" in + *":$dir:"*) ;; + *) echo "add $dir to your PATH to run it from anywhere:" + echo " export PATH=\"$dir:\$PATH\"" + ;; +esac diff --git a/plist_tui.py b/plist_tui.py new file mode 100644 index 00000000..fa4ea0cd --- /dev/null +++ b/plist_tui.py @@ -0,0 +1,1311 @@ +#!/usr/bin/env python3 +# plist_tui - the curses editor behind `plist ` (roadmap section 2) +# imports helpers from propertreecli lazily, so the module only loads +# when an interactive session actually starts. +# +# layout, top to bottom: +# header: file (frost bold) format dirty marker hints (dim) +# ─ dim rule +# tree: key / type / value rows, ▸▾ for collapsed containers +# status: mode + transient messages +# footer: condensed keybind legend (dim) +# +# look follows W0lfSword: frost accents on grey, boxed menus, dim hints, +# status glyphs. selection is a frost bar with dark text. + +import copy +import curses +import datetime +import locale +import os +import sys +import time +import traceback + +# ── color mapping ───────────────────────────────────────────── +# W0lfSword indexes on 256-color terminals; degrade for 8/16-color. +# frost/brand are 256-color tones, the semantic colors stay ansi base. +_EXACT = {"frost": 117, "brand": 153, "dim": 240, "grn": 2, "amb": 3, "red": 1} +_16 = {"frost": 14, "brand": 14, "dim": 8, "grn": 10, "amb": 11, "red": 9} +_8 = {"frost": 6, "brand": 6, "dim": 7, "grn": 2, "amb": 3, "red": 1} +# pairs: 1 frost(accents/keys) 2 dim 3 grn 4 amb 5 red 6 brand 7 sel bar +# depth pairs 10..15: d1..d6 for nested levels. d0 is not a real pair - +# top-level keys draw as plain frost (PAIR d0 -> 1) so the root of a file +# looks exactly like it always did. +PAIR = {} +# nested-key ramp: a straight desaturation of frost (135,215,255) toward +# gray, walking green and blue down together while red stays fixed - no +# hue detours. 110 is steel, 103 is gray-blue (red=green), 102 is gray, +# then flat. no init_color on purpose: palette redefinition is +# terminal-dependent and entries 16-21 silently render as black on +# terminals that ignore it, which looks like missing text. +_DEPTH_STOCK = [110, 103, 102, 102, 102, 102] # d1..d6, clamped at d3 + +def _init_colors(): + curses.start_color() + try: + curses.use_default_colors() + except curses.error: + pass + if curses.COLORS >= 256: + m = _EXACT + elif curses.COLORS >= 16: + m = _16 + else: + m = _8 + for i, key in enumerate(("frost", "dim", "grn", "amb", "red", "brand"), 1): + try: + curses.init_pair(i, m[key], -1) + except curses.error: + curses.init_pair(i, m[key], 0) + # selection bar: dark text on a frost background + try: + curses.init_pair(7, 0, m["frost"]) + except curses.error: + curses.init_pair(7, 0, 14) + PAIR["frost"] = 1 + PAIR["dim"] = 2 + PAIR["grn"] = 3 + PAIR["amb"] = 4 + PAIR["red"] = 5 + PAIR["brand"] = 6 + PAIR["d0"] = 1 # top level = plain frost, same as the original keys + if curses.COLORS >= 256: + # stock-index depth shades, pairs 10..15 (d1..d6) + for i, idx in enumerate(_DEPTH_STOCK): + try: + curses.init_pair(10 + i, idx, -1) + except curses.error: + curses.init_pair(10 + i, idx, 0) + PAIR["d{}".format(i + 1)] = 10 + i + else: + # 8/16-color terminals: no depth tint, every key plain frost + for i in range(1, len(_DEPTH_STOCK) + 1): + PAIR["d{}".format(i)] = 1 + +def P(name, bold=False): + a = curses.color_pair(PAIR[name]) + return a | curses.A_BOLD if bold else a + +# ── display helpers ─────────────────────────────────────────── +def _esc(s): + return s.replace("\\", "\\\\").replace("\n", "\\n").replace("\t", "\\t") + +def _type_name(v): + from propertreecli import type_name + return type_name(v) + +def canonical(v): + # text form used for editing prefill and type conversion + if isinstance(v, bool): + return "True" if v else "False" + if isinstance(v, int): + return str(v) + if isinstance(v, float): + return repr(v) + if isinstance(v, str): + return v + if isinstance(v, bytes): + return " ".join("{:02X}".format(b) for b in v) + if _is_uid(v): + return str(getattr(v, "data", v)) + if isinstance(v, datetime.datetime): + return v.strftime("%Y-%m-%d %H:%M:%S") + d = getattr(v, "data", None) + if isinstance(d, bytes): + return " ".join("{:02X}".format(b) for b in d) + return str(v) + +def _is_uid(v): + from propertreecli import _is_uid as iu + return iu(v) + +def value_color_name(v): + if isinstance(v, bool): + return "grn" if v else "dim" + return None + +_KINDS = { + "string": "s", "integer": "i", "real": "f", "boolean": "b", + "data": "x", "date": "d", "uid": "u", +} +_NEW_DEFAULTS = { + "string": "", "integer": 0, "real": 0.0, "boolean": True, + "data": b"", "date": None, "uid": None, +} + +def _parse(text, kind): + from propertreecli import parse_value + return parse_value(text, kind) + +# ── the editor ──────────────────────────────────────────────── +class Editor: + def __init__(self, stdscr, root, path): + from propertreecli import file_format, ensure_config, load_config + ensure_config() + cfg = load_config() + self.s = stdscr + self.root = root + self.path = path + self.fmt = file_format(path) + self.expand_mode = cfg.get("expand_mode", "auto") + self.find_scope = cfg.get("find_scope", "both") # keys | values | both + self.rows = [] # visible rows, rebuilt per frame + self.expanded = {} # path tuple -> bool + self.sel = 0 + self.top = 0 + self.undo = [] + self.redo = [] + self.dirty = False + self.msg = "" + self.msg_color = "dim" + self.find = None # active query + self.matches = [] # row indexes matching self.find + self.match_idx = -1 + self.clip_file = os.path.join( + os.environ.get("XDG_RUNTIME_DIR") or "/tmp", "plist-clipboard.plist") + self.log = "/tmp/plist_tui.log" + self._row_of_path = {} + self._node_count = 0 + # hold-to-accelerate state for j/k and the arrow keys + self._hold_dir = 0 + self._ramp = 0.0 + self._frac = 0.0 + self._mv_last = 0.0 + self._cycle_i = 0 # last pick in a cycling prompt (find scope) + + # ── frames ──────────────────────────────────────────────── + def H(self): + return self.s.getmaxyx()[0] + + def W(self): + return self.s.getmaxyx()[1] + + def _count(self, node): + # total descendants of a container (cached per frame) + if isinstance(node, dict): + return 1 + sum(self._count(v) for v in node.values()) + if isinstance(node, list): + return 1 + sum(self._count(v) for v in node) + return 1 + + def _is_expanded(self, path, node): + if path not in self.expanded: + # decided by the config: all / auto (huge files fold deep + # levels) / none (start collapsed, unfold by hand) + mode = self.expand_mode + if mode == "none": + want = False + elif mode == "all": + want = True + else: + want = not (self._node_count > 1200 and len(path) > 1) + self.expanded[path] = want + return self.expanded[path] + + def _build_rows(self): + rows = [] + + def walk(node, path, depth): + if isinstance(node, dict): + for k, v in node.items(): + p = path + [k] + if isinstance(v, (dict, list)): + n = self._count(v) + rows.append({"path": p, "key": k, "depth": depth, + "node": v, "type": _type_name(v), + "count": n, "leaf": False}) + if self._is_expanded(tuple(p), v): + walk(v, p, depth + 1) + else: + rows.append({"path": p, "key": k, "depth": depth, + "node": v, "type": _type_name(v), + "leaf": True}) + elif isinstance(node, list): + for i, v in enumerate(node): + p = path + [i] + if isinstance(v, (dict, list)): + n = self._count(v) + rows.append({"path": p, "key": i, "depth": depth, + "node": v, "type": _type_name(v), + "count": n, "leaf": False}) + if self._is_expanded(tuple(p), v): + walk(v, p, depth + 1) + else: + rows.append({"path": p, "key": i, "depth": depth, + "node": v, "type": _type_name(v), + "leaf": True}) + + self._node_count = self._count(self.root) + self.root_is_dict = isinstance(self.root, dict) + self.root_is_list = isinstance(self.root, list) + walk(self.root, [], 0) + self.rows = rows + if self.sel >= len(rows): + self.sel = max(len(rows) - 1, 0) + + def _recompute_matches(self): + self.matches = [] + if not self.find: + return + q = self.find.lower() + for i, r in enumerate(self.rows): + key_hit = isinstance(r["key"], str) and q in r["key"].lower() + # only scalar rows carry a value; containers match on their key + val_hit = r["leaf"] and q in canonical(r["node"]).lower() + if self.find_scope == "keys": + hit = key_hit + elif self.find_scope == "values": + hit = val_hit + else: + hit = key_hit or val_hit + if hit: + self.matches.append(i) + + # ── undo ────────────────────────────────────────────────── + def _push_undo(self): + self.undo.append(copy.deepcopy(self.root)) + if len(self.undo) > 200: + self.undo.pop(0) + self.redo = [] + self.dirty = True + + def _undo(self): + if not self.undo: + self._flash("nothing to undo") + return + self.redo.append(copy.deepcopy(self.root)) + self.root = self.undo.pop() + self.dirty = True + self._after_mutate() + self._flash("undone", "grn") + + def _redo(self): + if not self.redo: + self._flash("nothing to redo") + return + self.undo.append(copy.deepcopy(self.root)) + self.root = self.redo.pop() + self.dirty = True + self._after_mutate() + self._flash("redone", "grn") + + def _after_mutate(self): + self._build_rows() + self._recompute_matches() + self._clamp() + + def _clamp(self): + if self.sel >= len(self.rows): + self.sel = max(len(self.rows) - 1, 0) + + def _goto(self, idx): + # jump the selection to a row index; the next draw scrolls it in + self.sel = idx + self._clamp() + + def _flash(self, text, color="dim"): + self.msg = text + self.msg_color = color + + def _parent_of(self, row): + p = row["path"] + parent = self.root + for seg in p[:-1]: + if isinstance(parent, dict): + parent = parent[seg] + else: + parent = parent[int(seg)] + return parent, p[-1] + + # ── save / quit ─────────────────────────────────────────── + def _save(self): + from propertreecli import file_format, write_plist + try: + write_plist(self.root, self.path, file_format(self.path)) + except Exception as e: + self._flash("save failed: {}".format(e), "red") + return False + self.dirty = False + self._flash("saved \u2713", "grn") + return True + + def _confirm_dirty(self): + if not self.dirty: + return "y" + m = self._menu("save changes?", ["yes", "no", "cancel"]) + return "y" if m == 0 else ("n" if m == 1 else "c") + + # ── row ops ─────────────────────────────────────────────── + def _row_index_of_path(self, path): + for i, r in enumerate(self.rows): + if tuple(r["path"]) == tuple(path): + return i + return None + + def _rebuild_rows_keep(self, path): + # rebuild after an expand/collapse, keeping the selection on the + # container that was toggled (its children appear or vanish below) + self._build_rows() + i = self._row_index_of_path(path) + self.sel = i if i is not None else self.sel + self._clamp() + self._recompute_matches() + + def _toggle_expand(self, row=None): + row = row or self.rows[self.sel] + p = tuple(row["path"]) + self.expanded[p] = not self.expanded.get(p, True) + self._rebuild_rows_keep(row["path"]) + + def _row_is_container(self, row): + return not row["leaf"] + + def _parent_is_dict(self, row): + if len(row["path"]) == 1: + return self.root_is_dict + parent, _ = self._parent_of(row) + return isinstance(parent, dict) + + def _add_entry(self): + row = self.rows[self.sel] + if isinstance(row["node"], (dict, list)): + parent_path, anchor = row["path"], None # child: append at end + into_container = True + else: + parent_path, anchor = row["path"][:-1], row["path"][-1] + into_container = False + parent = self.root + for seg in parent_path: + parent = parent[seg] if isinstance(parent, dict) else parent[int(seg)] + types = list(_KINDS.keys()) + ["dict", "array"] + if not into_container and isinstance(parent, list) and anchor is None: + types = [t for t in types] # arrays can hold anything + pick = self._menu("add entry - type", types) + if pick is None: + return + tname = types[pick] + if tname in ("dict", "array"): + value = {} if tname == "dict" else [] + else: + if tname == "uid" and not self._binary(): + self._flash("uid only survives in binary plists", "red") + return + if tname == "date": + value = datetime.datetime.now().replace(microsecond=0) + elif tname == "uid": + value = _parse("0", "u") + else: + value = _NEW_DEFAULTS[tname] + if isinstance(parent, dict): + key = self._prompt("key name:", "") + if key is None: + return + if key in parent: + self._flash("key already exists: {}".format(key), "red") + return + if anchor is None: + self._push_undo() + parent[key] = value + else: + self._push_undo() + self._dict_insert_after(parent, anchor, key, value) + else: + self._push_undo() + if anchor is None: + parent.append(value) + else: + parent.insert(int(anchor) + 1, value) + self._after_mutate() + self._flash("added {}".format(_type_name(value)), "grn") + + def _dict_insert_after(self, d, anchor, key, value): + # dicts keep insertion order; rebuild with the new key after anchor + items = list(d.items()) + out = {} + for k, v in items: + out[k] = v + if k == anchor: + out[key] = value + self._replace_parent_dict(d, out) + + def _replace_parent_dict(self, old, new): + # swap `old` for `new` wherever it sits under self.root + if old is self.root: + self.root = new + return + stack = [(self.root, None, None)] + while stack: + cur, parent, key = stack.pop() + if cur is old: + if isinstance(parent, dict): + # rebuild the parent, swapping the one entry in place + items = list(parent.items()) + out = {} + for k, v in items: + out[k] = new if v is old else v + self._replace_parent_dict(parent, out) + else: + parent[key] = new + return + if isinstance(cur, dict): + for k, v in cur.items(): + stack.append((v, cur, k)) + elif isinstance(cur, list): + for i, v in enumerate(cur): + stack.append((v, cur, i)) + + def _delete_row(self): + row = self.rows[self.sel] + parent, last = self._parent_of(row) + label = self._row_label(row) + if isinstance(parent, dict): + if not self._confirm("delete {}?".format(label)): + return + self._push_undo() + if parent is self.root: + items = list(parent.items()) + out = {k: v for k, v in items if k != last} + self.root = out + else: + self._rebuild_dict_without(parent, last) + elif isinstance(parent, list): + if not self._confirm("delete {}?".format(label)): + return + self._push_undo() + del parent[int(last)] + else: + self._flash("cannot delete the root value", "red") + return + self._after_mutate() + self._flash("deleted {}".format(label), "grn") + + def _rebuild_dict_without(self, d, drop): + items = list(d.items()) + out = {k: v for k, v in items if k != drop} + self._replace_parent_dict(d, out) + + def _rename_row(self): + row = self.rows[self.sel] + if not isinstance(row["key"], str): + self._flash("array elements have no key to rename", "red") + return + if not self._parent_is_dict(row): + return + parent, last = self._parent_of(row) + new = self._prompt("rename {} to:".format(last), last) + if new is None or new == last: + return + if new in parent: + self._flash("key already exists: {}".format(new), "red") + return + self._push_undo() + items = list(parent.items()) + out = {} + for k, v in items: + if k == last: + out[new] = v + else: + out[k] = v + self._replace_parent_dict(parent, out) + self._after_mutate() + self._flash("renamed to {}".format(new), "grn") + + def _change_type(self): + row = self.rows[self.sel] + if not row["leaf"]: + self._flash("change type works on values, not containers", "red") + return + cur = _type_name(row["node"]) + options = [t for t in _KINDS if t != cur] + pick = self._menu("change type (from {})".format(cur), options) + if pick is None: + return + target = options[pick] + kind = _KINDS[target] + if target == "uid" and not self._binary(): + self._flash("uid only survives in binary plists", "red") + return + text = canonical(row["node"]) + if target == "string": + new = text + else: + try: + new = _parse(text, kind) + except ValueError as e: + self._flash("cannot convert: {}".format(e), "red") + return + if new == row["node"]: + self._flash("already a {}".format(target)) + return + self._push_undo() + parent, last = self._parent_of(row) + if isinstance(parent, dict): + parent[last] = new + else: + parent[int(last)] = new + self._after_mutate() + self._flash("changed to {}".format(target), "grn") + + def _duplicate_row(self): + # copy the whole entry and insert it right after itself; dict keys + # get a "copy" suffix that auto-increments on collision + row = self.rows[self.sel] + parent, last = self._parent_of(row) + payload = copy.deepcopy(row["node"]) + self._push_undo() + if isinstance(parent, dict): + base = "{} copy".format(last) if isinstance(last, str) else "copy" + key, n = base, 2 + while key in parent: + key = "{} {}".format(base, n) + n += 1 + self._dict_insert_after(parent, last, key, payload) + newpath = row["path"][:-1] + [key] + elif isinstance(parent, list): + i = int(last) + parent.insert(i + 1, payload) + newpath = row["path"][:-1] + [i + 1] + else: + return + self._rebuild_rows_keep(newpath) + self._flash("duplicated", "grn") + + def _sibling_jump(self, direction): + # { / }: jump to the previous / next sibling of the current row + row = self.rows[self.sel] + parent_path = tuple(row["path"][:-1]) + sib = [i for i, r in enumerate(self.rows) + if tuple(r["path"][:-1]) == parent_path] + if len(sib) < 2: + self._flash("no siblings here") + return + pos = sib.index(self.sel) + npos = pos + direction + if not 0 <= npos < len(sib): + self._flash("at the {} sibling".format("first" if direction < 0 else "last")) + return + self.sel = sib[npos] + + def _move_row(self, delta): + row = self.rows[self.sel] + parent, last = self._parent_of(row) + if isinstance(parent, dict): + keys = list(parent.keys()) + i = keys.index(last) + j = i + delta + if not 0 <= j < len(keys): + self._flash("already at the edge", "dim") + return + self._push_undo() + other = keys[j] + items = list(parent.items()) + out = {} + for k, v in items: + if k == last: + out[other] = parent[other] + elif k == other: + out[last] = parent[last] + else: + out[k] = v + self._replace_parent_dict(parent, out) + elif isinstance(parent, list): + i = int(last) + j = i + delta + if not 0 <= j < len(parent): + self._flash("already at the edge", "dim") + return + self._push_undo() + parent[i], parent[j] = parent[j], parent[i] + else: + return + self._after_mutate() + if delta < 0 and self.sel > 0: + self.sel -= 1 + elif delta > 0 and self.sel < len(self.rows) - 1: + self.sel += 1 + self._flash("moved {}".format("up" if delta < 0 else "down"), "grn") + + # ── value editing ───────────────────────────────────────── + def _edit_value(self): + row = self.rows[self.sel] + if not row["leaf"]: + self._toggle_expand(row) + return + v = row["node"] + if isinstance(v, bool): + self._push_undo() + parent, last = self._parent_of(row) + if isinstance(parent, dict): + parent[last] = not v + else: + parent[int(last)] = not v + self._after_mutate() + self._flash("toggled to {}".format("true" if not v else "false"), "grn") + return + text = self._prompt("value ({}):".format(_type_name(v)), canonical(v)) + if text is None: + return + try: + new = _parse(text, _KINDS[_type_name(v)]) + except ValueError as e: + self._flash(str(e), "red") + return + if new == v: + self._flash("unchanged") + return + self._push_undo() + parent, last = self._parent_of(row) + if isinstance(parent, dict): + parent[last] = new + else: + parent[int(last)] = new + self._after_mutate() + self._flash("edited \u2713", "grn") + + # ── clipboard ───────────────────────────────────────────── + def _copy_row(self, cut=False): + row = self.rows[self.sel] + payload = copy.deepcopy(row["node"]) + from propertreecli import _plist_mod + plist = _plist_mod() + try: + with open(self.clip_file, "wb") as f: + plist.dump({"v": payload}, f, fmt=plist.FMT_XML, sort_keys=False) + except Exception as e: + self._flash("clipboard write failed: {}".format(e), "red") + return + if cut: + if not row["leaf"] and not self._confirm( + "cut {} (its whole subtree)?".format(self._row_label(row))): + return + self._delete_row_no_confirm() + self._flash("cut {}".format(self._row_label(row)), "grn") + else: + self._flash("copied {}".format(self._row_label(row)), "grn") + + def _delete_row_no_confirm(self): + row = self.rows[self.sel] + parent, last = self._parent_of(row) + self._push_undo() + if isinstance(parent, dict): + if parent is self.root: + self.root = {k: v for k, v in parent.items() if k != last} + else: + self._rebuild_dict_without(parent, last) + else: + del parent[int(last)] + self._after_mutate() + + def _paste(self): + if not os.path.exists(self.clip_file): + self._flash("clipboard is empty", "red") + return + from propertreecli import _plist_mod + try: + with open(self.clip_file, "rb") as f: + data = _plist_mod().load(f) + payload = data["v"] + except Exception as e: + self._flash("clipboard read failed: {}".format(e), "red") + return + row = self.rows[self.sel] + if isinstance(row["node"], (dict, list)): + parent_path, anchor = row["path"], None + parent = self._node_at(parent_path) + else: + parent_path, anchor = row["path"][:-1], row["path"][-1] + parent = self._node_at(parent_path) + if isinstance(parent, dict): + key = self._prompt("paste as key:", str(row["key"]) if isinstance(row["key"], str) else "") + if key is None: + return + if key in parent: + self._flash("key already exists: {}".format(key), "red") + return + self._push_undo() + if anchor is None: + parent[key] = payload + else: + self._dict_insert_after(parent, anchor, key, payload) + else: + self._push_undo() + if anchor is None: + parent.append(payload) + else: + parent.insert(int(anchor) + 1, payload) + self._after_mutate() + self._flash("pasted \u2713", "grn") + + def _node_at(self, path): + cur = self.root + for seg in path: + cur = cur[seg] if isinstance(cur, dict) else cur[int(seg)] + return cur + + def _row_label(self, row): + if isinstance(row["key"], str): + return row["key"] + return "#{} ({})".format(row["key"], _type_name(row["node"])) + + def _binary(self): + return self.fmt == "binary" + + # ── find ────────────────────────────────────────────────── + _SCOPES = ("keys", "values", "both") + + def _find(self): + # tab inside the prompt cycles keys -> values -> both; the last + # used scope sticks for n/N and R until the next / search + start = self._SCOPES.index(self.find_scope) + q = self._prompt("find:", self.find or "", + cycle=self._SCOPES, cycle_i=start) + if q is None: + return + self.find_scope = self._SCOPES[self._cycle_i % len(self._SCOPES)] + if q == "": + self.find = None + self.matches = [] + self._flash("find cleared") + return + self.find = q + self._recompute_matches() + if not self.matches: + self._flash("no {} match for '{}'".format(self.find_scope, q), "red") + return + # jump to the first match at or after the selection + pos = next((i for i in self.matches if i >= self.sel), self.matches[0]) + self.match_idx = self.matches.index(pos) + self._goto(pos) + self._flash("match {}/{} ({})".format(self.match_idx + 1, len(self.matches), + self.find_scope), "grn") + + def _find_step(self, d): + if not self.matches: + self._flash("no active find (press /)", "red") + return + self.match_idx = (self.match_idx + d) % len(self.matches) + self._goto(self.matches[self.match_idx]) + self._flash("match {}/{} ({})".format(self.match_idx + 1, len(self.matches), + self.find_scope), "grn") + + def _replace_all(self): + if not self.find: + self._flash("no active find (press / first)", "red") + return + if self.find_scope == "keys": + self._flash("replace works on values - press / then tab to scope=values", "red") + return + q = self.find + repl = self._prompt("replace '{}' with:".format(q), "") + if repl is None: + return + count = 0 + + def fix(v): + nonlocal count + if isinstance(v, dict): + return {k: fix(x) for k, x in v.items()} + if isinstance(v, list): + return [fix(x) for x in v] + if isinstance(v, str) and q in v: + count += 1 + return v.replace(q, repl) + return v + + self._push_undo() + self.root = fix(self.root) + self._after_mutate() + if count: + self._flash("replaced {} in string values".format(count), "grn") + else: + self._flash("no string values matched (keys are left alone)", "red") + self._undo() + + # ── popups ──────────────────────────────────────────────── + def _box(self, title, lines, width=None, color="frost"): + h, w = self.H(), self.W() + width = width or min(max(len(title) + 4, max(len(l) for l in lines) + 4), w - 4) + height = len(lines) + 4 + top = max((h - height) // 2, 0) + left = max((w - width) // 2, 0) + self.s.move(top, 0) # force a sync point + border = P("brand" if color == "brand" else "frost", bold=True) + self.s.addstr(top, left, "\u2554" + "\u2550" * (width - 2) + "\u2557", border) + self.s.addstr(top + 1, left, "\u2551" + " " * (width - 2) + "\u2551", border) + t = title[: width - 4] + pad = width - 2 - len(t) + self.s.addstr(top + 1, left + 1, " " * (pad // 2) + t + " " * (pad - pad // 2), P("frost", bold=True)) + for i, l in enumerate(lines): + self.s.addstr(top + 2 + i, left, "\u2551", border) + self.s.addstr(top + 2 + i, left + 1, " " * (width - 2), P("dim")) + self.s.addstr(top + 2 + i, left + 1, l[: width - 2]) + self.s.addstr(top + 2 + i, left + width - 1, "\u2551", border) + self.s.addstr(top + height - 1, left, + "\u255a" + "\u2550" * (width - 2) + "\u255d", border) + + def _menu(self, title, items, sel=0): + # centered pick list; returns index or None. j/k or arrows move, + # enter picks, esc cancels. + h, w = self.H(), self.W() + sel = sel % len(items) + top = 0 + while True: + self._draw() + shown = items + rows_h = min(len(shown), h - 6) + offset = min(max(sel - rows_h + 1, 0), max(len(shown) - rows_h, 0)) + vis = shown[offset:offset + rows_h] + width = min(max(len(title) + 6, max(len(i) for i in vis) + 8), w - 4) + top = max((h - len(vis) - 4) // 2, 0) + left = max((w - width) // 2, 0) + self._box(title, [" " * (width - 4)] * len(vis), width) + for i, item in enumerate(vis): + y = top + 3 + i + x = left + 2 + if offset + i == sel: + self.s.addstr(y, x, "{:>2}.".format(offset + i + 1), P("frost", bold=True) | curses.A_REVERSE) + self.s.addstr(y, x + 3, item, curses.A_REVERSE | P("frost", bold=True)) + else: + self.s.addstr(y, x, "{:>2}.".format(offset + i + 1), P("frost", bold=True)) + self.s.addstr(y, x + 3, item) + self.s.refresh() + ch = self.s.getch() + if ch in (ord("j"), curses.KEY_DOWN): + sel = min(sel + 1, len(items) - 1) + elif ch in (ord("k"), curses.KEY_UP): + sel = max(sel - 1, 0) + elif ch in (curses.KEY_NPAGE,): + sel = min(sel + rows_h, len(items) - 1) + elif ch in (curses.KEY_PPAGE,): + sel = max(sel - rows_h, 0) + elif ch in (10, 13, curses.KEY_ENTER, ord(" ")): + return sel + elif ch in (27, ord("q")): + return None + return None + + def _confirm(self, question): + m = self._menu(question, ["yes", "no"], sel=1) + return m == 0 + + def _help(self): + lines = [ + "move arrows, j/k, g/G, home/end, pgup/pgdn", + "page ctrl+d / ctrl+u (half screen)", + "siblings { } jump to the prev/next sibling", + "fold left/right or space, enter on a container", + "edit enter on a value (booleans toggle)", + "add i (into a container, else as sibling)", + "duplicate D (copies under a new key / array slot)", + "delete d (asks first, even for leaves)", + "rename r (dict keys)", + "type t (scalar conversions)", + "move entry < > (reorder inside its parent)", + "copy/cut/paste c x p (shared /tmp clipboard)", + "undo/redo u / ctrl+r (200 steps)", + "find / then n / N to cycle, esc clears", + " tab inside the prompt: keys / values / both", + "replace R (replace-all in string values, values scope)", + "save ctrl+s or F2", + "quit q (save prompt when dirty)", + "", + "writes are atomic and verified; the file keeps its format", + "and key order. esc cancels prompts. ? shows this again.", + ] + self._static_box("plist - keybinds", lines) + + def _static_box(self, title, lines): + self._draw() + h, w = self.H(), self.W() + width = min(max(len(title) + 6, max(len(l) for l in lines) + 6), w - 4) + vis = lines[: max(h - 6, 1)] + top = max((h - len(vis) - 4) // 2, 0) + left = max((w - width) // 2, 0) + self._box(title, [" " * (width - 4)] * len(vis), width) + for i, l in enumerate(vis): + y = top + 3 + i + self.s.addstr(y, left + 2, l[: width - 4]) + self.s.refresh() + while True: + ch = self.s.getch() + if ch != -1: + return + + # ── prompt line ─────────────────────────────────────────── + def _prompt(self, label, prefill="", cycle=None, cycle_i=0): + # blocking line editor on the status row; returns text or None. + # cycle = optional list of modes shown in the label; tab steps + # through them and the pick lands in self._cycle_i. + buf = list(prefill) + pos = len(buf) + self._cycle_i = cycle_i % len(cycle) if cycle else 0 + while True: + h, w = self.H(), self.W() + lab = label + if cycle: + lab = "{} <{}>".format(label, cycle[self._cycle_i]) + self._draw() + y = h - 3 + self.s.move(y, 0) + self.s.clrtoeol() + self.s.addstr(y, 0, " " + lab + " ", P("frost", bold=True)) + x0 = len(lab) + 2 + show = "".join(buf) + # window the buffer so the caret stays visible + avail = max(w - x0 - 1, 1) + if len(show) <= avail: + win = 0 + elif pos >= avail: + win = min(pos - avail + 1, len(show) - avail) + else: + win = 0 + view = show[win:win + avail] + self.s.addstr(y, x0, view, curses.A_UNDERLINE) + try: + self.s.move(y, x0 + (pos - win)) + except curses.error: + pass + self.s.refresh() + ch = self.s.getch() + if ch in (9,) and cycle: # tab: cycle the mode + self._cycle_i = (self._cycle_i + 1) % len(cycle) + continue + if ch in (27,): + return None + if ch in (10, 13, curses.KEY_ENTER): + return "".join(buf) + if ch in (curses.KEY_BACKSPACE, 127, 8): + if pos > 0: + del buf[pos - 1] + pos -= 1 + elif ch in (21,): # ctrl+u: clear line + buf = [] + pos = 0 + elif ch == curses.KEY_LEFT: + pos = max(pos - 1, 0) + elif ch == curses.KEY_RIGHT: + pos = min(pos + 1, len(buf)) + elif ch in (curses.KEY_HOME,): + pos = 0 + elif ch in (curses.KEY_END,): + pos = len(buf) + elif 32 <= ch < 127: + buf.insert(pos, chr(ch)) + pos += 1 + + # ── drawing ─────────────────────────────────────────────── + def _draw(self, status=None): + self.s.erase() + h, w = self.H(), self.W() + if h < 10 or w < 30: + self.s.addstr(0, 0, "terminal too small for the editor") + self.s.refresh() + return + # header + dirty = "*" if self.dirty else "" + name = self.path + if len(name) + 20 > w: + name = "..." + name[-(w - 23):] + self.s.addstr(0, 0, " " + name, P("frost", bold=True)) + x = len(name) + 2 + fmt = "binary" if self.path.endswith("plist") and self._binary() else "xml" + self.s.addstr(0, x, "[{}]".format(fmt), P("dim")) + if self.dirty: + self.s.addstr(0, x + len(fmt) + 2, dirty, P("amb", bold=True)) + right = "? help q quit ^s save"[: max(w - 30, 1)] + self.s.addstr(0, max(w - len(right) - 1, x + 10), right, P("dim")) + try: + self.s.addstr(1, 0, " " + "\u2500" * (w - 1), P("dim")) + except curses.error: + pass + + # tree + body_top, body_bot = 2, h - 4 + list_h = body_bot - body_top + if self.sel < self.top: + self.top = self.sel + if self.sel >= self.top + list_h: + self.top = self.sel - list_h + 1 + self._keyw = 0 + for r in self.rows[self.top:self.top + list_h]: + kt = str(r["key"]) + self._keyw = max(self._keyw, min(len(kt), 26)) + self._keyw = min(max(self._keyw, 8), 26) + for i, r in enumerate(self.rows[self.top:self.top + list_h]): + y = body_top + i + self._draw_row(y, r, self.top + i == self.sel, self.top + i) + # footer / status + self._draw_status(status) + + def _row_value_text(self, r): + if not r["leaf"]: + if r["count"] == 0: + return "(empty)" + collapsed = not self.expanded.get(tuple(r["path"]), True) + if collapsed: + return "{} entries".format(r["count"]) + return "" + v = r["node"] + t = canonical(v) + return _esc(t) + + def _draw_row(self, y, r, selected, abs_idx): + w = self.W() + indent = " " * (2 + r["depth"] * 2) + if not r["leaf"]: + arrow = "\u25be" if self.expanded.get(tuple(r["path"]), True) else "\u25b8" + else: + arrow = " " + x = 0 + key = str(r["key"]) + if isinstance(r["key"], int): + keypair = P("dim") + else: + # blue by depth: top-level keys keep plain frost, nested keys + # stay the same hue, slightly desaturated (d1..d6, clamped) + keypair = P("d{}".format(min(r["depth"], 6)), bold=True) + # pad key to the column width + key_txt = key[: self._keyw] + if len(key) > self._keyw: + key_txt = key[: self._keyw - 1] + "\u2026" + segments = [] + if not r["leaf"]: + segments.append((indent + arrow + " ", P("dim"))) + else: + segments.append((indent + " ", P("dim"))) + segments.append((key_txt.ljust(self._keyw) + " ", keypair)) + segments.append((_type_name(r["node"]).ljust(9), P("dim"))) + vc = value_color_name(r["node"]) + vt = self._row_value_text(r) + seg_col = P("grn") if vc == "grn" else (P("dim") if vc == "dim" else 0) + match = bool(self.find) and abs_idx in self.matches + if selected: + # frost selection bar, dark text + for txt, attr in segments: + try: + self.s.addstr(y, x, txt, curses.color_pair(7) | curses.A_BOLD) + except curses.error: + pass + x += len(txt) + try: + self.s.addstr(y, x, vt, curses.color_pair(7) | curses.A_BOLD) + self.s.addstr(y, x + len(vt), " " * (w - x - len(vt)), curses.color_pair(7)) + except curses.error: + pass + else: + for txt, attr in segments: + try: + self.s.addstr(y, x, txt, attr) + except curses.error: + pass + x += len(txt) + try: + if match: + self.s.addstr(y, x, vt, P("amb")) + else: + self.s.addstr(y, x, vt, seg_col) + except curses.error: + pass + + def _draw_status(self, status=None): + h, w = self.H(), self.W() + y = h - 3 + self.s.move(y, 0) + self.s.clrtoeol() + mode = status or "normal" + self.s.addstr(y, 0, " " + mode, P("frost", bold=True)) + if self.find: + m = "find: {} ({})".format(self.find, len(self.matches)) + self.s.addstr(y, len(mode) + 2, m, P("amb")) + if self.msg: + xx = len(mode) + 2 + (len("find: ") + len(self.find or "") + 4 if self.find else 0) + room = max(w - xx - 3, 1) + self.s.addstr(y, min(xx + 2, w - 1), self.msg[:room], P(self.msg_color)) + self.s.move(h - 2, 0) + self.s.clrtoeol() + self.s.addstr(h - 2, 0, " " + "\u2500" * (w - 1), P("dim")) + legend = ("\u2191\u2193 move \u2190\u2192 fold enter edit i add d del " + "c/x/p copy cut paste u undo / find ? help").strip() + # never occupy the bottom-right cell: ncurses errors on it + self.s.addstr(h - 1, 0, (" " + legend)[: w - 1], P("dim")) + + # ── main loop ───────────────────────────────────────────── + def run(self): + self._build_rows() + self._flash("arrow keys to move - ? for help", "dim") + while True: + self._draw() + self.s.refresh() + ch = self.s.getch() + try: + if self._handle(ch): + return + except Exception as e: + with open(self.log, "a") as f: + f.write(traceback.format_exc()) + self._flash("error: {} (see {})".format(e, self.log), "red") + + def _move_sel(self, d): + # d is -1 (up) or 1 (down). holding a direction accelerates + # smoothly: a fractional accumulator adds roughly 1 row per + # repeat plus a slowly growing ramp, so the step size drifts + # 1 -> 2 -> 3 -> 4 over a few seconds of holding instead of + # jumping between fixed tiers. no teleporting half-pages. + now = time.monotonic() + if d == self._hold_dir and (now - self._mv_last) < 0.4: + self._ramp = min(self._ramp + 0.03, 2.2) + else: + self._hold_dir = d + self._ramp = 0.03 + self._frac = 0.0 + self._mv_last = now + self._frac += 1.0 + self._ramp + step = int(self._frac) + self._frac -= step + if d < 0: + self.sel = max(self.sel - step, 0) + else: + self.sel = min(self.sel + step, len(self.rows) - 1) + + def _handle(self, ch): + if ch == -1: + return None + if ch not in (ord("j"), ord("k"), curses.KEY_DOWN, curses.KEY_UP): + # any other key breaks the hold-acceleration ramp + self._hold_dir = 0 + self._ramp = 0.0 + self._frac = 0.0 + r = self.sel + if ch in (ord("j"), curses.KEY_DOWN): + self._move_sel(1) + elif ch in (ord("k"), curses.KEY_UP): + self._move_sel(-1) + elif ch in (ord("g"),): + self.sel = 0 + elif ch in (ord("G"),): + self.sel = len(self.rows) - 1 + elif ch in (curses.KEY_NPAGE,): + self.sel = min(self.sel + self.H() - 6, len(self.rows) - 1) + elif ch in (curses.KEY_PPAGE,): + self.sel = max(self.sel - (self.H() - 6), 0) + elif ch in (curses.KEY_LEFT, ord("h")): + row = self.rows[self.sel] + p = tuple(row["path"]) + if not row["leaf"] and self.expanded.get(p, True): + self._toggle_expand(row) + elif len(row["path"]) > 1: + # folded already: jump to the parent row + target = tuple(row["path"][:-1]) + i = self._row_index_of_path(target) + if i is not None: + self.sel = i + elif ch in (curses.KEY_RIGHT, ord("l"), ord(" ")): + row = self.rows[self.sel] + p = tuple(row["path"]) + if not row["leaf"] and not self.expanded.get(p, True): + self._toggle_expand(row) + elif ch in (ord("{"),): + self._sibling_jump(-1) + elif ch in (ord("}"),): + self._sibling_jump(1) + elif ch in (4, 21): # ctrl+d / ctrl+u: half a page + step = max((self.H() - 6) // 2, 1) + if ch == 4: + self.sel = min(self.sel + step, len(self.rows) - 1) + else: + self.sel = max(self.sel - step, 0) + elif ch in (curses.KEY_HOME,): + self.sel = 0 + elif ch in (curses.KEY_END,): + self.sel = len(self.rows) - 1 + elif ch in (10, 13, curses.KEY_ENTER): + self._edit_value() + elif ch in (ord("i"), curses.KEY_IC): + self._add_entry() + elif ch in (ord("D"),): + self._duplicate_row() + elif ch == ord("d"): + self._delete_row() + elif ch == ord("r"): + self._rename_row() + elif ch == ord("t"): + self._change_type() + elif ch in (ord("c"),): + self._copy_row(cut=False) + elif ch in (ord("x"),): + self._copy_row(cut=True) + elif ch in (ord("p"),): + self._paste() + elif ch in (ord("u"), 26): # u or ctrl+z + self._undo() + elif ch in (18, 25): # ctrl+r or ctrl+y + self._redo() + elif ch == ord("R"): + self._replace_all() + elif ch == ord("/"): + self._find() + elif ch in (ord("n"),): + self._find_step(1) + elif ch in (ord("N"),): + self._find_step(-1) + elif ch == ord("<"): + self._move_row(-1) + elif ch == ord(">"): + self._move_row(1) + elif ch == 27: # esc: clear find / close overlays + if self.find: + self.find = None + self.matches = [] + self._flash("find cleared") + elif ch in (ord("?"),): + self._help() + elif ch in (ord("q"), 3): # q or ctrl+c + c = self._confirm_dirty() + if c == "y": + if not self._save(): + return None + return True + if c == "n": + return True + elif ch in (19, curses.KEY_F2): # ctrl+s or F2 + self._save() + elif ch == curses.KEY_RESIZE: + pass + return None + +def run_editor(root, path): + # main entry: wraps everything in curses and returns an exit code + def app(stdscr): + try: + locale.setlocale(locale.LC_ALL, "") + except locale.Error: + pass + try: + curses.curs_set(0) + except curses.error: + pass + stdscr.keypad(True) + # stay in cbreak (keypad escape parsing needs it) but drop IXON so + # ctrl+s arrives as a key instead of pausing output, and ISIG so + # ctrl+c arrives as a key (quit-with-confirm) instead of a signal + try: + import termios as _termios + _a = _termios.tcgetattr(sys.stdin.fileno()) + _a[0] &= ~_termios.IXON + _a[3] &= ~_termios.ISIG + _termios.tcsetattr(sys.stdin.fileno(), _termios.TCSANOW, _a) + except Exception: + pass + _init_colors() + ed = Editor(stdscr, root, path) + ed.run() + + try: + curses.wrapper(app) + return 0 + except KeyboardInterrupt: + return 130 + +if __name__ == "__main__": + print("plist_tui is a module - run it through propertreecli.py") diff --git a/propertreecli.py b/propertreecli.py new file mode 100755 index 00000000..c880a976 --- /dev/null +++ b/propertreecli.py @@ -0,0 +1,990 @@ +#!/usr/bin/env python3 +# propertreecli - a plist editor for the terminal +# fork of corpnewt/ProperTree (BSD-3), look stolen from W0lfSword +# +# single source of truth for the version - keep this in sync with the +# update feed when 4.3 lands. the command is `plist`; the file keeps the +# repo name so it can never shadow Scripts/plist.py on import. +VERSION = "0.3.0" + +import argparse +import datetime +import json +import os +import plistlib +import shutil +import sys +import traceback + +# repo root, resolved through the ~/.local/bin symlink so the Scripts +# import works no matter which directory the command runs from +_HERE = os.path.dirname(os.path.realpath(__file__)) +if _HERE not in sys.path: + sys.path.insert(0, _HERE) + +# ── color palette - arctic wolf ─────────────────────────────── +# one accent (C_FROST), one muted tone (C_DIM), three semantic colors +# (grn ok, amb warn, red error). C_BRAND is for the tree art only. +# bold (B) marks important words. everything resets with NC. +C_BRAND = "\033[38;5;153m" +C_FROST = "\033[38;5;117m" +C_DIM = "\033[38;5;240m" +C_GRN = "\033[0;32m" +C_AMB = "\033[1;33m" +C_RED = "\033[0;31m" +B = "\033[1m" +NC = "\033[0m" + +COLOR = True # flipped off by --no-color, NO_COLOR, or a non-tty stdout + +def color_on(): + return COLOR + +def reset_colors(): + global C_BRAND, C_FROST, C_DIM, C_GRN, C_AMB, C_RED, B, NC + C_BRAND = C_FROST = C_DIM = C_GRN = C_AMB = C_RED = B = NC = "" + +# ── drawing helpers ─────────────────────────────────────────── +def term_cols(): + return shutil.get_terminal_size((80, 24)).columns + +def _vis_len(s): + # visible length of an ansi-free string + return len(s) + +def _trunc(s, n): + if _vis_len(s) <= n: + return s + if n <= 1: + return s[:n] + return s[: n - 1] + "\u2026" + +def rule(n=None): + # dim horizontal rule + n = n or min(term_cols() - 2, 76) + print(" " + C_DIM + "\u2500" * n + NC) + +def banner(title, sub=None, color=None): + # even boxed banner, W0lfSword style: computed padding, so the title + # centers regardless of length. only drawn on a tty. + color = color or C_FROST + w = term_cols() - 6 + w = min(max(w, 40), 72) + w -= w % 2 + inner = w - 2 + lines = [title] + ([sub] if sub else []) + print(" " + color + B + "\u2554" + "\u2550" * inner + "\u2557" + NC) + for t in lines: + t = _trunc(t, inner) + pad = inner - _vis_len(t) + l = pad // 2 + r = pad - l + print(" " + color + B + "\u2551" + NC + " " * l + t + " " * r + color + B + "\u2551" + NC) + print(" " + color + B + "\u255a" + "\u2550" * inner + "\u255d" + NC) + +# ── the tree logo ───────────────────────────────────────────── +# built from column math instead of hand-tuned so the branches always +# line up. a plist is a tree: root tag branching into its children. +def tree_art_lines(): + pad = " " * 8 + w = 21 + b = "+" + "-" * (w - 2) + "+" + # inner text centered: "" in a w-2 wide cell + tag = "" + cell = w - 2 + lp = (cell - len(tag)) // 2 + rp = cell - lp - len(tag) + mid = w // 2 # 10: center column inside the box + half = (w - 3) // 2 # 9: dash runs on the branch line + lines = [ + pad + b, + pad + "|" + " " * lp + tag + " " * rp + "|", + pad + "+" + "-" * half + "+" + "-" * half + "+", + pad + " " * mid + "|", + pad + "+" + "-" * half + "+" + "-" * half + "+", + pad + "|" + " " * half + "|" + " " * half + "|", + ] + # labels centered under the three branch points (cols 0, mid, w-1), + # in absolute columns so the left one never clips + labels = [(0, ""), (mid, ""), (w - 1, "")] + row_w = len(pad) + w + 8 + row = [" "] * row_w + for col, text in labels: + start = len(pad) + col - len(text) // 2 + for i, ch in enumerate(text): + if 0 <= start + i < row_w: + row[start + i] = ch + lines.append("".join(row).rstrip()) + return lines + +def show_logo(): + if not COLOR: + return + art = tree_art_lines() + print(C_BRAND + "\n".join(art) + NC) + print("") + +# ── status glyphs (W0lfSword convention) ────────────────────── +def ok(text): print(" " + C_GRN + "\u2713" + NC + " " + text) +def err(text): print(" " + C_RED + "\u2717" + NC + " " + text, file=sys.stderr) +def warn(text): print(" " + C_AMB + "\u26a0" + NC + " " + text, file=sys.stderr) +def info(text): print(" " + C_FROST + "\u2139" + NC + " " + text) +def hint(text): print(" " + C_DIM + " \u2192 " + text + NC) +def hint_err(text): print(" " + C_DIM + " \u2192 " + text + NC, file=sys.stderr) + +# ── plist io ────────────────────────────────────────────────── +def _plist_mod(): + # lazy import: --version/--help must work even if Scripts/plist.py + # misbehaves on some python build + from Scripts import plist as _plist + return _plist + +def is_binary(path): + with open(path, "rb") as f: + return f.read(8) == b"bplist00" + +def file_format(path): + return "binary" if is_binary(path) else "xml" + +def load_plist(path): + return _plist_mod().load(open(path, "rb")) + +def write_plist(root, path, fmt="xml"): + # atomic write + verify: dump to a temp file, parse it back, then + # swap it in. a broken write never leaves a half-written plist behind. + from Scripts import plist as _plist + fmt = _plist.FMT_BINARY if fmt == "binary" else _plist.FMT_XML + tmp = path + ".tmp" + try: + with open(tmp, "wb") as f: + _plist.dump(root, f, fmt=fmt, sort_keys=False) + with open(tmp, "rb") as f: + _plist.load(f) # throws if the dump is garbage + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + +# ── value formatting ────────────────────────────────────────── +def type_name(v): + if isinstance(v, bool): return "boolean" + if isinstance(v, int): return "integer" + if isinstance(v, float): return "real" + if isinstance(v, str): return "string" + if isinstance(v, bytes): return "data" + if isinstance(v, plistlib.UID) or type(v).__name__ == "UID": + return "uid" + if isinstance(v, datetime.datetime): + return "date" + if isinstance(v, dict): return "dict" + if isinstance(v, list): return "array" + return type(v).__name__.lower() + +def value_text(v): + # plain text for a leaf value; dict/array return "" (children drawn + # below). strings get control chars unescaped so rows stay single-line. + if isinstance(v, bool): + return "True" if v else "False" + if isinstance(v, int): + return str(v) + if isinstance(v, float): + return repr(v) + if isinstance(v, str): + return v.replace("\\", "\\\\").replace("\n", "\\n").replace("\t", "\\t") + if isinstance(v, bytes): + s = " ".join("{:02X}".format(x) for x in v[:32]) + return s + (" \u2026" if len(v) > 32 else "") + if isinstance(v, plistlib.UID) or type(v).__name__ == "UID": + return str(getattr(v, "data", v)) + if isinstance(v, datetime.datetime): + return v.strftime("%Y-%m-%d %H:%M:%S") + # data objects from old parsers (plistlib.Data and friends) + d = getattr(v, "data", None) + if d is not None and isinstance(d, bytes): + return " ".join("{:02X}".format(x) for x in d[:32]) + return str(v) + +def value_color(v): + # booleans read as status: enabled green, disabled grey. everything + # else keeps the default foreground - frost is for keys. + if isinstance(v, bool): + return C_GRN if v else C_DIM + return "" + +# ── tree rendering ──────────────────────────────────────────── +def _max_key_len(node): + best = 0 + if isinstance(node, dict): + for k, v in node.items(): + best = max(best, len(k)) + if isinstance(v, (dict, list)): + best = max(best, _max_key_len(v)) + elif isinstance(node, list): + for v in node: + if isinstance(v, (dict, list)): + best = max(best, _max_key_len(v)) + return best + +def _render_node(node, prefix, keyw, cols, last=True, key=None): + # one row per entry: guides (dim), key (bold frost), type (dim), + # value (plain or status-colored). containers recurse. visible width + # is tracked separately from the ansi codes so values don't truncate + # early when colors are on. + leaf = not isinstance(node, (dict, list)) + if key is None and isinstance(node, dict): + key = "" + guide = prefix + ("\u2514\u2500 " if last else "\u251c\u2500 ") + out = [C_DIM + guide + NC] + vis = len(guide) + if key is not None: + ktxt = _trunc(str(key), keyw) + # array indexes are structural, not keys: dim them + if isinstance(key, int): + out.append(C_DIM + ktxt + NC) + else: + out.append(C_FROST + B + ktxt + NC) + out.append(" " * (keyw - len(ktxt)) + " ") + vis += keyw + 1 + out.append(C_DIM + type_name(node).ljust(9) + " " + NC) + vis += 10 + if leaf: + vt = value_text(node) + vc = value_color(node) + out.append(vc + _trunc(vt, max(cols - vis, 0)) + (NC if vc else "")) + elif len(node) == 0: + out.append(C_DIM + "(empty)" + NC) + print("".join(out)) + if not leaf: + child_prefix = prefix + (" " if last else "\u2502 ") + items = list(node.items()) if isinstance(node, dict) else list(enumerate(node)) + for i, (ck, cv) in enumerate(items): + _render_node(cv, child_prefix, keyw, cols, last=i == len(items) - 1, key=ck) + +def show_tree(root, cols=None): + cols = cols or term_cols() - 2 + keyw = min(max(_max_key_len(root), 8), 32) + if isinstance(root, (dict, list)): + # the root container has no row of its own; its entries do + items = list(root.items()) if isinstance(root, dict) else list(enumerate(root)) + for i, (k, v) in enumerate(items): + _render_node(v, " ", keyw, cols, last=i == len(items) - 1, key=k) + else: + _render_node(root, " ", keyw, cols, last=True, key="root") + +def _describe_root(root): + if isinstance(root, dict): + n = len(root) + return "{} key{}".format(n, "" if n == 1 else "s") + if isinstance(root, list): + n = len(root) + return "{} item{}".format(n, "" if n == 1 else "s") + return type_name(root) + +def _open_error(path, e): + # friendly failure text for the common cases: missing file, directory, + # or something that is not a plist + if isinstance(e, FileNotFoundError): + err("cannot open {}: no such file".format(path)) + hint_err("create it with: plist new {}".format(path)) + elif isinstance(e, IsADirectoryError): + err("{} is a directory, not a plist".format(path)) + else: + err("cannot open {}: {}".format(path, e)) + hint_err("is it a plist? xml and binary both work") + +def open_and_show(path, show_banner=True): + root = _load_or_err(path) + if root is None: + return 1 + fmt = file_format(path) + if show_banner and COLOR: + show_logo() + banner(path, "{} plist - {}".format(fmt, _describe_root(root))) + else: + print(path) + show_tree(root) + return 0 + +# ── config ──────────────────────────────────────────────────── +# plain key=value file, # comments, template written on first run. +# the schema doubles as the docs, same trick as W0lfSword's config_schema(). +CONFIG_DEFAULTS = {"expand_mode": "auto", "format": "xml", "find_scope": "both"} +# valid values per key, so `plist settings set` can validate before writing +CONFIG_VALID = { + "expand_mode": ("all", "auto", "none"), + "format": ("xml", "binary"), + "find_scope": ("keys", "values", "both"), +} +CONFIG_TEMPLATE = """# plist config - plain key=value, # comments. the editor reads this +# on open. edit by hand, or manage it with: plist settings +# plist settings show current values +# plist settings set K V change one (validated) +# plist settings reset back to defaults + +# expand_mode - how containers look when the editor opens a file: +# all every container expanded +# auto expand everything unless the file is huge (>1200 nodes) +# none everything collapsed; unfold with right arrow or space +expand_mode = auto + +# format - what `plist new` writes by default: xml | binary +format = xml + +# find_scope - what / searches by default: keys | values | both +# (tab inside the find prompt cycles the scope for that search) +find_scope = both +""" + +def config_dir(): + base = os.environ.get("XDG_CONFIG_HOME") or os.path.join( + os.path.expanduser("~"), ".config") + return os.path.join(base, "propertreecli") + +def config_path(): + return os.path.join(config_dir(), "config") + +def ensure_config(): + p = config_path() + if os.path.exists(p): + return p + try: + os.makedirs(config_dir(), exist_ok=True) + with open(p, "w") as f: + f.write(CONFIG_TEMPLATE) + except OSError: + pass + return p + +def load_config(): + cfg = dict(CONFIG_DEFAULTS) + try: + with open(config_path()) as f: + for line in f: + line = line.split("#", 1)[0].strip() + if not line or "=" not in line: + continue + k, v = [x.strip() for x in line.split("=", 1)] + cfg[k] = v + except OSError: + pass + if cfg.get("expand_mode") not in CONFIG_VALID["expand_mode"]: + cfg["expand_mode"] = "auto" + if cfg.get("format") not in CONFIG_VALID["format"]: + cfg["format"] = "xml" + if cfg.get("find_scope") not in CONFIG_VALID["find_scope"]: + cfg["find_scope"] = "both" + return cfg + +# ── keypaths ────────────────────────────────────────────────── +class PathError(Exception): + pass + +def split_path(s): + # dot separated; a backslash escapes a literal dot in a key name + parts, buf = [], [] + i = 0 + while i < len(s): + c = s[i] + if c == "\\" and i + 1 < len(s): + buf.append(s[i + 1]) + i += 2 + continue + if c == ".": + parts.append("".join(buf)) + buf = [] + i += 1 + continue + buf.append(c) + i += 1 + parts.append("".join(buf)) + for p in parts: + if p == "": + raise PathError("empty segment in keypath '{}'".format(s)) + return parts + +def _step_into(cur, seg, create=False): + # descend one level during path resolution + if isinstance(cur, dict): + if seg in cur: + return cur[seg] + if create: + cur[seg] = {} + return cur[seg] + raise PathError("no key '{}'".format(seg)) + if isinstance(cur, list): + try: + i = int(seg) + except ValueError: + raise PathError("'{}' is not an array index".format(seg)) + if i < len(cur): + return cur[i] + if create and i == len(cur): + cur.append({}) + return cur[-1] + raise PathError("array index {} out of range (len {})".format(i, len(cur))) + raise PathError("cannot descend into a {} value".format(type_name(cur))) + +def _resolve_path(root, segs, create=False): + # walk everything but the last segment; returns (container, last) + cur = root + for seg in segs[:-1]: + cur = _step_into(cur, seg, create=create) + return cur, segs[-1] + +def _is_uid(v): + return isinstance(v, plistlib.UID) or type(v).__name__ == "UID" + +def _has_uid(v): + if isinstance(v, dict): + return any(_has_uid(x) for x in v.values()) + if isinstance(v, list): + return any(_has_uid(x) for x in v) + return _is_uid(v) + +# ── one-shot value io ───────────────────────────────────────── +_BOOL_WORDS = { + "true": True, "false": False, "yes": True, "no": False, + "on": True, "off": False, "1": True, "0": False, +} + +def parse_value(raw, kind): + # kind: s string (default), i integer (0x ok), f real, b boolean, + # x data from hex, d date (iso), u uid (binary plists only) + if kind == "s": + return raw + if kind == "i": + try: + return int(raw, 0) + except ValueError: + raise ValueError("not an integer: '{}'".format(raw)) + if kind == "f": + try: + return float(raw) + except ValueError: + raise ValueError("not a number: '{}'".format(raw)) + if kind == "b": + w = raw.lower() + if w not in _BOOL_WORDS: + raise ValueError("not a boolean: '{}' (try true/false, yes/no, on/off, 1/0)".format(raw)) + return _BOOL_WORDS[w] + if kind == "x": + try: + return bytes.fromhex("".join(raw.split())) + except ValueError: + raise ValueError("not hex data: '{}'".format(raw)) + if kind == "d": + try: + return datetime.datetime.fromisoformat(raw.replace(" ", "T", 1)) + except ValueError: + raise ValueError("not an iso date: '{}' (try 2024-03-01 or 2024-03-01 12:30:00)".format(raw)) + if kind == "u": + if not hasattr(plistlib, "UID"): + raise ValueError("uid needs python 3.8+") + try: + return plistlib.UID(int(raw, 0)) + except ValueError: + raise ValueError("not an integer uid: '{}'".format(raw)) + raise ValueError("unknown type flag '{}'".format(kind)) + +def _data_bytes(v): + # bytes, or the data payload of legacy plistlib.Data style objects + if isinstance(v, bytes): + return v + d = getattr(v, "data", None) + return d if isinstance(d, bytes) else None + +def to_jsonable(v): + if isinstance(v, dict): + return {k: to_jsonable(x) for k, x in v.items()} + if isinstance(v, list): + return [to_jsonable(x) for x in v] + if isinstance(v, bool) or isinstance(v, (int, float)) or isinstance(v, str): + return v + d = _data_bytes(v) + if d is not None: + return " ".join("{:02X}".format(b) for b in d) + if _is_uid(v): + return getattr(v, "data", v) + if isinstance(v, datetime.datetime): + return v.strftime("%Y-%m-%d %H:%M:%S") + return str(v) + +def print_value(v, json_mode=False): + # script output: strings raw, bools lowercase, data as continuous + # hex (pipes into xxd -r -p), dates iso. containers tree out, or + # json with --json. + if json_mode: + print(json.dumps(to_jsonable(v))) + return + if isinstance(v, (dict, list)): + show_tree(v) + return + d = _data_bytes(v) + if isinstance(v, bool): + print("true" if v else "false") + elif d is not None: + print("".join("{:02X}".format(b) for b in d)) + elif _is_uid(v): + print(getattr(v, "data", v)) + elif isinstance(v, datetime.datetime): + print(v.strftime("%Y-%m-%d %H:%M:%S")) + else: + print(v) + +def _load_or_err(path): + try: + return load_plist(path) + except Exception as e: + _open_error(path, e) + if os.environ.get("PROPERTREECLI_DEBUG"): + traceback.print_exc() + return None + +def _write_or_err(root, path): + try: + write_plist(root, path, file_format(path)) + except Exception as e: + err("cannot write {}: {}".format(path, e)) + if os.environ.get("PROPERTREECLI_DEBUG"): + traceback.print_exc() + return 1 + ok("wrote {}".format(path)) + return 0 + +def cmd_get(argv, json_mode=False): + p = argparse.ArgumentParser(prog="plist get", + description="print the value at a keypath") + p.add_argument("file") + p.add_argument("keypath") + a = p.parse_args(argv) + root = _load_or_err(a.file) + if root is None: + return 1 + try: + segs = split_path(a.keypath) + if len(segs) == 1 and not isinstance(root, (dict, list)): + raise PathError("root is a {} value, not a container".format(type_name(root))) + parent, last = _resolve_path(root, segs) + v = _step_into(parent, last) + except PathError as e: + err("{}: {}".format(a.keypath, e)) + return 2 + print_value(v, json_mode) + return 0 + +def cmd_set(argv): + p = argparse.ArgumentParser(prog="plist set", + description="set the value at a keypath; missing dictionaries along " + "the path are created, arrays accept index == length to " + "append. the file keeps its format and key order.") + p.add_argument("file") + p.add_argument("keypath") + p.add_argument("value", nargs="?", help="value to store; omit with -b to flip an existing boolean") + g = p.add_mutually_exclusive_group() + g.add_argument("-i", action="store_const", const="i", dest="kind", help="integer (0x hex ok)") + g.add_argument("-f", action="store_const", const="f", dest="kind", help="real number") + g.add_argument("-b", action="store_const", const="b", dest="kind", help="boolean (true/false/yes/no/on/off/1/0)") + g.add_argument("-x", action="store_const", const="x", dest="kind", help="data from a hex string") + g.add_argument("-d", action="store_const", const="d", dest="kind", help="date (iso)") + g.add_argument("-u", action="store_const", const="u", dest="kind", help="uid (binary plists only)") + a = p.parse_args(argv) + kind = a.kind or "s" + root = _load_or_err(a.file) + if root is None: + return 1 + if a.value is None: + if kind == "b": + # flag flip: needs an existing boolean + try: + segs = split_path(a.keypath) + parent, last = _resolve_path(root, segs) + cur = _step_into(parent, last) + except PathError as e: + err("{}: {}".format(a.keypath, e)) + return 2 + if not isinstance(cur, bool): + err("{} is a {} - flip needs an existing boolean".format(a.keypath, type_name(cur))) + return 1 + parsed = not cur + else: + p.error("missing value") + else: + try: + parsed = parse_value(a.value, kind) + except ValueError as e: + err(str(e)) + return 1 + if kind == "u" and file_format(a.file) != "binary": + err("uid values only survive in binary plists - {} is xml".format(a.file)) + return 1 + try: + segs = split_path(a.keypath) + container, last = _resolve_path(root, segs, create=True) + if isinstance(container, dict): + existing = container.get(last) + if isinstance(existing, (dict, list)): + err("{} is a {} - refusing to replace a container (del it first)".format( + a.keypath, type_name(existing))) + return 1 + container[last] = parsed + elif isinstance(container, list): + try: + i = int(last) + except ValueError: + err("'{}' is not an array index".format(last)) + return 2 + if i < len(container): + existing = container[i] + if isinstance(existing, (dict, list)): + err("{} is a {} - refusing to replace a container (del it first)".format( + a.keypath, type_name(existing))) + return 1 + container[i] = parsed + elif i == len(container): + container.append(parsed) + else: + err("array index {} out of range (len {})".format(i, len(container))) + return 2 + else: + err("root is a {} value, not a container".format(type_name(root))) + return 1 + except PathError as e: + err("{}: {}".format(a.keypath, e)) + return 2 + if _has_uid(root) and file_format(a.file) != "binary": + err("file contains uid values; only binary plists can store them") + return 1 + code = _write_or_err(root, a.file) + if code == 0: + ok("{} = {}".format(a.keypath, a.value if a.value is not None else "flipped")) + return code + +def cmd_del(argv): + p = argparse.ArgumentParser(prog="plist del", + description="remove the key or array element at a keypath") + p.add_argument("file") + p.add_argument("keypath") + a = p.parse_args(argv) + root = _load_or_err(a.file) + if root is None: + return 1 + try: + segs = split_path(a.keypath) + container, last = _resolve_path(root, segs) + if isinstance(container, dict): + if last not in container: + raise PathError("no key '{}'".format(last)) + del container[last] + elif isinstance(container, list): + try: + i = int(last) + except ValueError: + raise PathError("'{}' is not an array index".format(last)) + if not 0 <= i < len(container): + raise PathError("array index {} out of range (len {})".format(i, len(container))) + del container[i] + else: + raise PathError("cannot descend into a {} value".format(type_name(container))) + except PathError as e: + err("{}: {}".format(a.keypath, e)) + return 2 + code = _write_or_err(root, a.file) + if code == 0: + ok("removed {}".format(a.keypath)) + return code + +def cmd_new(argv): + p = argparse.ArgumentParser(prog="plist new", + description="create a new empty plist and open it in the editor") + p.add_argument("path", help="file to create (refuses to overwrite)") + p.add_argument("--binary", action="store_true", + help="write a binary plist (default: xml, or the config format)") + p.add_argument("--root", choices=("dict", "array"), default="dict", + help="root container type (default dict)") + a = p.parse_args(argv) + if os.path.exists(a.path): + err("{} already exists - refusing to overwrite".format(a.path)) + return 1 + root = {} if a.root == "dict" else [] + fmt = "binary" if a.binary else load_config().get("format", "xml") + try: + write_plist(root, a.path, fmt) + except Exception as e: + err("cannot create {}: {}".format(a.path, e)) + if os.environ.get("PROPERTREECLI_DEBUG"): + traceback.print_exc() + return 1 + ok("created {} ({} plist, {} root)".format(a.path, fmt, a.root)) + if _tty(): + return _start_editor(a.path) + return 0 + +def cmd_convert(argv): + p = argparse.ArgumentParser(prog="plist convert", + description="rewrite a plist in the other format, in place unless -o") + p.add_argument("file") + p.add_argument("-t", "--to", required=True, choices=("xml", "binary"), + help="target format") + p.add_argument("-o", "--out", help="output path (default: overwrite the file)") + a = p.parse_args(argv) + root = _load_or_err(a.file) + if root is None: + return 1 + if a.to == "xml" and _has_uid(root): + err("{} holds uid values - xml plists cannot represent them".format(a.file)) + return 1 + out = a.out or a.file + try: + write_plist(root, out, a.to) + except Exception as e: + err("cannot write {}: {}".format(out, e)) + if os.environ.get("PROPERTREECLI_DEBUG"): + traceback.print_exc() + return 1 + ok("wrote {} as a {} plist".format(out, a.to)) + return 0 + +def _write_config_file(p, text): + # atomic-ish config write: temp file + rename, same discipline as plists + tmp = p + ".tmp" + with open(tmp, "w") as f: + f.write(text) + os.replace(tmp, p) + +def cmd_settings(argv): + # plist settings | settings set key value | settings reset + p = argparse.ArgumentParser(prog="plist settings", + description="show or change the plist config file") + p.add_argument("action", nargs="?", default="show", choices=("show", "set", "reset")) + p.add_argument("rest", nargs="*") + a = p.parse_args(argv) + path = ensure_config() + if a.action == "show": + cfg = load_config() + ok("config: {}".format(path)) + for k in CONFIG_DEFAULTS: + info("{} = {}".format(k, cfg.get(k, CONFIG_DEFAULTS[k]))) + return 0 + if a.action == "reset": + try: + os.remove(path) + except OSError: + pass + ensure_config() + ok("config reset to defaults: {}".format(path)) + return 0 + # set key value + if len(a.rest) != 2: + err("settings set takes a key and a value, e.g.: plist settings set format binary") + return 2 + key, value = a.rest + if key not in CONFIG_VALID: + err("unknown setting '{}' - known: {}".format(key, ", ".join(sorted(CONFIG_VALID)))) + return 2 + if value not in CONFIG_VALID[key]: + err("'{}' is not valid for {} - use one of: {}".format( + value, key, ", ".join(CONFIG_VALID[key]))) + return 2 + # update the file in place, preserving comments and other keys + lines = [] + try: + with open(path) as f: + lines = f.readlines() + except OSError: + pass + found = False + for i, ln in enumerate(lines): + body = ln.split("#", 1)[0].strip() + if body.startswith(key + "=") or body.startswith(key + " =")\ + or body == key: + lines[i] = "{} = {}\n".format(key, value) + found = True + break + if not found: + lines.append("{} = {}\n".format(key, value)) + _write_config_file(path, "".join(lines)) + ok("{} = {}".format(key, value)) + return 0 + +# ── cli ─────────────────────────────────────────────────────── +JSON_OUT = False + +COMMANDS = ("get", "set", "del", "convert", "edit", "new", "settings", "help") + +SHORT_USAGE = ( + "usage: plist [--json] [--no-color] [args] | \n" + " commands: get, set, del, convert, edit, new, settings (plist help for details)" +) + +HELP = """\ +plist v{} - a plist editor for the terminal (W0lfSword-flavored) + +usage: + plist [file] open a plist in the interactive + editor (falls back to a tree print + when stdout is piped) + plist show plists as a tree + plist edit force the interactive editor + plist get [--json] print the value at a keypath + plist set [-i|-f|-b|-x|-d|-u] + set the value at a keypath + plist del remove a key or array element + plist convert --to xml|binary [-o out] + rewrite in the other format + plist new [--binary] [--root dict|array] + create an empty plist (xml by + default) and open the editor + plist settings show the config values + plist settings set change one (validated) + plist settings reset back to defaults + +options: + --json machine-readable output (get only) + --no-color plain output (implied when stdout is not a tty) + --version print the version + -h, --help this help + +the editor: + arrow keys or j/k move, left/right (or space) fold containers, home/end + top/bottom, {{ }} jump to the previous/next sibling, ctrl+d / ctrl+u + half a page, enter edits a value (booleans toggle), i adds an entry, + d deletes, D duplicates, r renames a key, t changes a value's type, + c/x/p copy/cut/paste, u undoes (ctrl+r redoes, 200 steps), / finds + with n/N cycling, R replaces the find text in string values, ctrl+s + saves, q quits (it asks when dirty), ? shows every keybind. the file + keeps its format and key order, and writes are atomic + verified by + re-reading. + +config: ~/.config/propertreecli/config (created on first editor run). +expand_mode = all | auto | none decides how containers open; format += xml | binary is what plist new writes; find_scope = keys | values | +both is what / searches by default (tab inside the find prompt cycles +the scope for that search). plist settings shows and changes these. + +one-shots: + keypaths are dot separated: Misc.Boot.Timeout or Drivers.0.Path; a + backslash escapes a literal dot in a key name (com\\\\.apple\\\\.x). get and + del walk existing keys; set creates missing dictionaries along the way + and appends to arrays at index == length. values default to strings: + -i integer (0x hex ok) -f real -b boolean (true/false/yes/no/on/off/ + 1/0; omit the value to flip an existing boolean) -x data from hex + -d date (iso) -u uid (binary plists only). values that start with a + dash need a -- separator first: plist set f.plist Boot-args -- -v x + +get prints strings raw, booleans lowercase, data as continuous hex (pipe +it into xxd -r -p), dates iso; containers tree out. exit codes: +0 ok, 1 file or value error, 2 bad keypath or usage +""".format(VERSION) + +def _apply_color_flags(): + global COLOR + if not COLOR or os.environ.get("NO_COLOR"): + COLOR = False + elif not sys.stdout.isatty(): + COLOR = False + if not COLOR: + reset_colors() + +def _tty(): + return sys.stdout.isatty() and sys.stdin.isatty() + +def _start_editor(path): + # the curses editor (plist_tui); only works on a real terminal + if not _tty(): + err("the editor needs a terminal - run it in one, or use get/set/del") + return 1 + root = _load_or_err(path) + if root is None: + return 1 + try: + from plist_tui import run_editor + except Exception as e: + err("editor failed to load: {}".format(e)) + if os.environ.get("PROPERTREECLI_DEBUG"): + traceback.print_exc() + return 1 + return run_editor(root, path) + +def main(argv=None): + global COLOR, JSON_OUT + argv = list(sys.argv[1:]) if argv is None else list(argv) + # global flags can sit anywhere (W0lfSword pre-scans the same way) + rest, help_wanted = [], False + for a in argv: + if a == "--json": + JSON_OUT = True + elif a == "--no-color": + COLOR = False + elif a == "--version": + print("plist v" + VERSION) + return 0 + elif a in ("-h", "--help"): + help_wanted = True + else: + rest.append(a) + _apply_color_flags() + + if not rest: + if help_wanted: + print(HELP) + return 0 + if os.path.exists("config.plist"): + return _start_editor("config.plist") if _tty() else open_and_show("config.plist") + err("nothing to open: no config.plist in this directory") + hint_err("try: plist test.plist (open a plist)") + hint_err(" plist new my.plist (start from scratch)") + hint_err(" plist help (everything)") + return 1 + + cmd = rest[0] + if cmd in COMMANDS: + sub = rest[1:] + if help_wanted: + sub.insert(0, "-h") + if cmd == "help": + print(HELP) + return 0 + if cmd == "edit": + if len(sub) != 1: + err("edit takes exactly one plist file") + return 2 + return _start_editor(sub[0]) + if cmd == "new": + return cmd_new(sub) + if cmd == "settings": + return cmd_settings(sub) + if cmd == "get": + return cmd_get(sub, json_mode=JSON_OUT) + if cmd == "set": + return cmd_set(sub) + if cmd == "del": + return cmd_del(sub) + return cmd_convert(sub) + if help_wanted or cmd.startswith("-"): + print(HELP) + return 0 if help_wanted else 2 + + # not a command: plist files. one file on a terminal opens the + # editor; anything else prints the tree (plain when piped) + if len(rest) == 1 and _tty() and not help_wanted: + return _start_editor(rest[0]) + code = 0 + for i, f in enumerate(rest): + if i: + print("") + code |= open_and_show(f) + return code + +if __name__ == "__main__": + try: + sys.exit(main()) + except BrokenPipeError: + # stdout closed early (propertreecli file.plist | head) - not an error + try: + sys.stdout.close() + except Exception: + pass + sys.exit(0) diff --git a/test.plist b/test.plist new file mode 100644 index 00000000..e99b092c --- /dev/null +++ b/test.plist @@ -0,0 +1,104 @@ + + + + + Comment + sample plist - try: plist test.plist (q to quit, ? for keys) + Boot-args + -v keepsyms=1 debug=0x100 + csr-active-config + + ZwAAAA== + + Misc + + Boot + + Timeout + 5 + HibernateMode + Auto + PollAppleHotKeys + + LauncherOption + Full + + BlessOverride + + \EFI\OC\Bootstrap\Bootstrap.efi + + + NVRAM + + Add + + 7C436110-AB2A-4BBB-A880-FE41995C9F82 + + boot-args + keepsyms=1 + prev-lang:kbd + en:0 + + + + ACPI + + Add + + + Comment + EC fix + Enabled + + Path + SSDT-EC.aml + + + Comment + USB map + Enabled + + Path + SSDT-USB.aml + + + + Drivers + + + Path + OpenRuntime.efi + Enabled + + + + Path + ResetNvram.efi + Enabled + + + + Kernel + + Add + + Quirks + + AppleCpuPmCfgLock + + DisableIoMapper + + PanicNoKextDump + + + + Tags + + hackintosh + opencore + plist + + Built + 2026-09-03T12:00:00Z + + From 7fb82fb47ea09b085a3f101e2243f98c0ac22c79 Mon Sep 17 00:00:00 2001 From: kaffeindecaf Date: Thu, 3 Sep 2026 22:48:09 +0200 Subject: [PATCH 2/7] add red theme and plist diff command --- README.md | 80 ++++++++++++++++++++---------------- ROADMAP.md | 31 ++++++++++---- plist_tui.py | 29 ++++++++++--- propertreecli.py | 105 ++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 191 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index cca19d17..d46d1b2a 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,44 @@ # ProperTreeCLI -A plist editor that lives in the terminal. ProperTree's job - editing -config.plist, kext ordering, oc snapshots - without a window, so it -works over ssh, in tmux, or on a box with no display at all. - -This is a fork of corpnewt's ProperTree (a tkinter app). The gui still -works; the point of this repo is the command that runs from any -directory: +Edit plists the way you edit config files on a server: from the +terminal, over ssh, inside tmux, on a machine with no display at all. plist test.plist -That opens a plist as a tree in your terminal, styled like W0lfSword: -frost blue on grey, boxed banners, dim hints, nothing on screen that +opens a plist as a tree you can walk with the arrow keys, fold like +a file explorer, edit inline, and save with ctrl+s. Styled after +W0lfSword - frost blue on grey, boxed banners, nothing on screen that does not earn its place. If that look is good enough for a kernel exploit toolkit, it is good enough for an EFI folder. +ProperTreeCLI is a fork of corpnewt's ProperTree, a tkinter app. The +gui still works; the point of this repo is the command line twin. It +keeps the parts worth keeping (Scripts/plist.py handles xml + binary +plists, the snapshot logic stays for later) and adds what a terminal +tool should have: scriptable one-shots, exit codes, and no window. + test.plist in the repo root is a small sample config (nested dicts, arrays, data, a date) for trying it out. No file yet? plist new my.plist starts one from scratch. -The same command works piped: no terminal means no colors, no editor, -just the tree as plain text, so it greps and scripts. - -## Quick start +## Try it in a minute git clone https://github.com/kaffeindecaf/ProperTreeCLI cd ProperTreeCLI - ./install.sh # links `plist` into ~/.local/bin, stdlib only - plist test.plist # open the sample editor + ./install.sh # symlinks `plist` into ~/.local/bin + plist test.plist # sample editor (q quits, ? shows keys) plist --version -That is the whole install. No pip, no venv. Run plist from any +No pip, no venv, no tkinter. Stdlib only. Run plist from any directory afterwards. +Piped output is plain text: no terminal means no colors and no editor, +just the tree, so it greps and scripts. + ## The editor + plist test.plist + j/k or the arrows move, home/end top/bottom, ctrl+d / ctrl+u half a page, { } jump between siblings, left/right fold containers, enter edits a value (booleans toggle), i adds an entry (pick the type, then @@ -47,14 +51,13 @@ shows every keybind. prompt picks what to search - keys, values, or both - and the choice sticks for the session. R replaces the query inside string values (when the search is scoped to keys it refuses; replacing keys is a -rename, do it by hand with r). Top-level keys keep the plain frost -blue, nested keys stay the same hue but slightly desaturated, so nesting -reads at a glance. Hold j/k or the arrows and movement accelerates -smoothly: one row per repeat at first, then gradually more, no sudden -jumps. +rename, do it by hand with r). -The file keeps its format and key order, and writes are atomic and -verified by re-reading. +Hold j/k or the arrows and movement accelerates smoothly: one row per +repeat at first, then gradually more, no sudden jumps. + +Writes are atomic and verified by re-reading; the file keeps its +format and key order. ## One-shots @@ -62,15 +65,21 @@ verified by re-reading. plist set config.plist Misc.Boot.Timeout 5 -i plist set config.plist Kernel.Quirks.EnableWriteUnprotector false -b plist del config.plist Wifi + plist diff config.plist config-clean.plist what changed between two plist convert config.plist -t binary plist new new-config.plist start from an empty plist plist settings show the config values - plist settings set format binary change one (validated) + plist settings set theme red every text color red + +diff walks both trees and prints added/removed/changed keypaths; +it exits 0 when the files match and 1 when they differ, so it works +in a build script or a pre-push check. -set creates missing keys along the way and keeps the file's format and -key order. get prints strings raw, booleans lowercase, data as hex; -add --json for machine output. Keypaths are dotted, array elements are -indexes: Drivers.0.Path. Values that start with a dash need -- first: +set creates missing keys along the way and keeps the file's format +and key order. get prints strings raw, booleans lowercase, data as +hex; add --json for machine output. Keypaths are dotted, array +elements are indexes: Drivers.0.Path. Values that start with a dash +need -- first: plist set config.plist Boot-args -- -v keepsyms=1 @@ -78,13 +87,14 @@ Run plist help for the whole list. ## Config -The editor reads ~/.config/propertreecli/config (created on first run, -XDG_CONFIG_HOME aware): +The editor reads ~/.config/propertreecli/config (created on first +run, XDG_CONFIG_HOME aware): - expand_mode = all, auto, or none: whether containers open expanded or folded - format = xml or binary: what plist new writes - find_scope = keys, values, or both: what / searches by default +- theme = frost or red: the palette. red turns every text color red plist settings shows and changes these without hand-editing the file. @@ -94,12 +104,10 @@ The editor and one-shots sit on the parts of ProperTree worth keeping instead of rewriting them: - Scripts/plist.py handles xml and binary plists, data, uid, 0x ints -- the oc snapshot code walks ACPI/Kexts/Tools/Drivers, orders kexts by - dependency, and detects the schema from OpenCore.efi's md5 - the - headless snapshot command is the next big thing, tracked in - ROADMAP.md -- find/replace and the ascii/hex/base64 converter from the tk window - (the converter is still pending too) +- the oc snapshot code walks ACPI/Kexts/Tools/Drivers, orders kexts + by dependency, and detects the schema from OpenCore.efi's md5 - a + headless snapshot command is next, tracked in ROADMAP.md +- find/replace is ported; the ascii/hex/base64 converter is pending Progress lives in ROADMAP.md. It is a checklist, not a plan: one item per session, done end to end or not done. diff --git a/ROADMAP.md b/ROADMAP.md index e82b8643..2f9bd286 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,16 @@ snapshot logic in plistwindow.py, the converter, the settings model. clear errors with a hint, running bare with no config.plist suggests plist test.plist / plist new / plist help, and `plist help` works as an alias for -h. hints go to stderr so --json and piped output stay clean._ +- [x] **1.7 diff** - `plist diff ` walks both trees and + prints what differs as keypaths: + added, - removed, ~ changed, + arrays by index and dicts by key, recursion stopping at the + first difference so a whole subtree reads as one line. exit 0 + identical, 1 differences, 2 file error - scriptable, which is + the whole point of the one-shots. + _Done 2026-09-03: cmd_diff in propertreecli.py, wired into COMMANDS + + help. output sorted by keypath, containers summarized as / , summary line at the end. test.plist-vs-edited + copies covered in the /tmp suite._ ## 2 - the editor (the main event) @@ -320,17 +330,22 @@ snapshot logic in plistwindow.py, the converter, the settings model. key=value with # comments, template written on first run. schema doubles as the docs, same trick as W0lfSword's config_schema(). keys: expand_mode (all/auto/none), format (xml/binary), find_scope - (keys/values/both). validation lives in load_config; unknown or - invalid values fall back to defaults instead of erroring. + (keys/values/both), theme (frost/red). validation lives in + load_config; unknown or invalid values fall back to defaults + instead of erroring. _Done 2026-09-03: the file itself landed with 2.10; the schema is now CONFIG_DEFAULTS + CONFIG_VALID in propertreecli.py, load_config validates every key against CONFIG_VALID, and `plist settings` (4.2) - manages it from the cli. keys that were planned and got cut, on - purpose: animations on/off (the animation feature was removed from the - product), prompt_symbol and logo/color toggles (the look is fixed, - color already bows to --no-color / NO_COLOR / non-tty), data/int/bool - display styles (the editor renders values readably as-is; revisit only - if someone asks)._ + manages it from the cli. theme = red (added same day, user request) + recolors the whole ui: the curses editor swaps its palette maps for + red variants (accents 196, dim 124, depth shades 160/124/88) and the + ansi one-shots swap their escapes when the config says so - every + text color goes red, hints stay muted. keys that were planned and got + cut, on purpose: animations on/off (the animation feature was removed + from the product), prompt_symbol and logo/color toggles (the look is + fixed, color already bows to --no-color / NO_COLOR / non-tty), + data/int/bool display styles (the editor renders values readably + as-is; revisit only if someone asks)._ - [x] **4.2 settings command** - `plist settings` shows the file, `settings set key value` validates and writes, `settings reset` restores defaults. no menu for it in the tui yet; editing by hand diff --git a/plist_tui.py b/plist_tui.py index fa4ea0cd..4ce00a0e 100644 --- a/plist_tui.py +++ b/plist_tui.py @@ -28,6 +28,11 @@ _EXACT = {"frost": 117, "brand": 153, "dim": 240, "grn": 2, "amb": 3, "red": 1} _16 = {"frost": 14, "brand": 14, "dim": 8, "grn": 10, "amb": 11, "red": 9} _8 = {"frost": 6, "brand": 6, "dim": 7, "grn": 2, "amb": 3, "red": 1} +# theme = red turns every text color red: accents at 196, dim at 124 so +# hints stay muted, and the depth shades walk down through darker reds. +_RED_EXACT = {"frost": 196, "brand": 196, "dim": 124, "grn": 196, "amb": 196, "red": 196} +_RED_16 = {"frost": 9, "brand": 9, "dim": 1, "grn": 9, "amb": 9, "red": 9} +_RED_8 = {"frost": 1, "brand": 1, "dim": 1, "grn": 1, "amb": 1, "red": 1} # pairs: 1 frost(accents/keys) 2 dim 3 grn 4 amb 5 red 6 brand 7 sel bar # depth pairs 10..15: d1..d6 for nested levels. d0 is not a real pair - # top-level keys draw as plain frost (PAIR d0 -> 1) so the root of a file @@ -40,6 +45,18 @@ # terminal-dependent and entries 16-21 silently render as black on # terminals that ignore it, which looks like missing text. _DEPTH_STOCK = [110, 103, 102, 102, 102, 102] # d1..d6, clamped at d3 +# red theme: same idea, darker reds per level (clamped at 88) +_DEPTH_RED = [160, 124, 88, 88, 88, 88] + +def _load_theme(): + # which palette theme the config asks for; never throws - a missing or + # broken config just means frost + try: + from propertreecli import ensure_config, load_config + ensure_config() + return load_config().get("theme", "frost") + except Exception: + return "frost" def _init_colors(): curses.start_color() @@ -47,12 +64,14 @@ def _init_colors(): curses.use_default_colors() except curses.error: pass + red = _load_theme() == "red" if curses.COLORS >= 256: - m = _EXACT + m = _RED_EXACT if red else _EXACT elif curses.COLORS >= 16: - m = _16 + m = _RED_16 if red else _16 else: - m = _8 + m = _RED_8 if red else _8 + depth = _DEPTH_RED if red else _DEPTH_STOCK for i, key in enumerate(("frost", "dim", "grn", "amb", "red", "brand"), 1): try: curses.init_pair(i, m[key], -1) @@ -72,7 +91,7 @@ def _init_colors(): PAIR["d0"] = 1 # top level = plain frost, same as the original keys if curses.COLORS >= 256: # stock-index depth shades, pairs 10..15 (d1..d6) - for i, idx in enumerate(_DEPTH_STOCK): + for i, idx in enumerate(depth): try: curses.init_pair(10 + i, idx, -1) except curses.error: @@ -80,7 +99,7 @@ def _init_colors(): PAIR["d{}".format(i + 1)] = 10 + i else: # 8/16-color terminals: no depth tint, every key plain frost - for i in range(1, len(_DEPTH_STOCK) + 1): + for i in range(1, len(depth) + 1): PAIR["d{}".format(i)] = 1 def P(name, bold=False): diff --git a/propertreecli.py b/propertreecli.py index c880a976..351a3dfa 100755 --- a/propertreecli.py +++ b/propertreecli.py @@ -5,7 +5,7 @@ # single source of truth for the version - keep this in sync with the # update feed when 4.3 lands. the command is `plist`; the file keeps the # repo name so it can never shadow Scripts/plist.py on import. -VERSION = "0.3.0" +VERSION = "0.4.0" import argparse import datetime @@ -311,12 +311,14 @@ def open_and_show(path, show_banner=True): # ── config ──────────────────────────────────────────────────── # plain key=value file, # comments, template written on first run. # the schema doubles as the docs, same trick as W0lfSword's config_schema(). -CONFIG_DEFAULTS = {"expand_mode": "auto", "format": "xml", "find_scope": "both"} +CONFIG_DEFAULTS = {"expand_mode": "auto", "format": "xml", "find_scope": "both", + "theme": "frost"} # valid values per key, so `plist settings set` can validate before writing CONFIG_VALID = { "expand_mode": ("all", "auto", "none"), "format": ("xml", "binary"), "find_scope": ("keys", "values", "both"), + "theme": ("frost", "red"), } CONFIG_TEMPLATE = """# plist config - plain key=value, # comments. the editor reads this # on open. edit by hand, or manage it with: plist settings @@ -336,6 +338,9 @@ def open_and_show(path, show_banner=True): # find_scope - what / searches by default: keys | values | both # (tab inside the find prompt cycles the scope for that search) find_scope = both + +# theme - frost is the usual pale blue; red turns every text color red +theme = frost """ def config_dir(): @@ -376,6 +381,8 @@ def load_config(): cfg["format"] = "xml" if cfg.get("find_scope") not in CONFIG_VALID["find_scope"]: cfg["find_scope"] = "both" + if cfg.get("theme") not in CONFIG_VALID["theme"]: + cfg["theme"] = "frost" return cfg # ── keypaths ────────────────────────────────────────────────── @@ -696,6 +703,78 @@ def cmd_del(argv): ok("removed {}".format(a.keypath)) return code +def _fmt_diff_value(v): + # short value text for diff lines: containers as a count summary + if isinstance(v, dict): + return "".format(len(v)) + if isinstance(v, list): + return "".format(len(v)) + return value_text(v) + +def _diff_walk(a, b, path, out): + # out is a list of (kind, path, a_text, b_text) where kind is + # "+" added, "-" removed, "~" changed. arrays compare by index, + # dicts by key; recursion stops at the first difference. + if isinstance(a, dict) and isinstance(b, dict): + for k in a: + if k not in b: + out.append(("-", path + [k], _fmt_diff_value(a[k]), None)) + for k in b: + if k not in a: + out.append(("+", path + [k], None, _fmt_diff_value(b[k]))) + for k in a: + if k in b: + _diff_walk(a[k], b[k], path + [k], out) + elif isinstance(a, list) and isinstance(b, list): + for i in range(max(len(a), len(b))): + if i >= len(a): + out.append(("+", path + [i], None, _fmt_diff_value(b[i]))) + elif i >= len(b): + out.append(("-", path + [i], _fmt_diff_value(a[i]), None)) + else: + _diff_walk(a[i], b[i], path + [i], out) + elif a != b: + out.append(("~", path, _fmt_diff_value(a), _fmt_diff_value(b))) + +def _fmt_diff_path(path): + # dotted keypath for display; array indexes read as plain numbers + return ".".join(str(s) for s in path) + +def cmd_diff(argv): + # plist diff : walk both trees, print what differs. + # exit codes: 0 identical, 1 differences, 2 file/usage error. + p = argparse.ArgumentParser(prog="plist diff", + description="compare two plists, print added/removed/changed keypaths") + p.add_argument("files", nargs=2, metavar="file") + a = p.parse_args(argv) + f1, f2 = a.files + r1 = _load_or_err(f1) + if r1 is None: + return 2 + r2 = _load_or_err(f2) + if r2 is None: + return 2 + out = [] + _diff_walk(r1, r2, [], out) + # sort by keypath so the output is stable and readable + out.sort(key=lambda t: _fmt_diff_path(t[1])) + for kind, path, va, vb in out: + kp = _fmt_diff_path(path) + if kind == "+": + print(" " + C_GRN + "+ " + NC + kp + " = " + vb) + elif kind == "-": + print(" " + C_RED + "- " + NC + kp + " = " + va) + else: + print(" " + C_AMB + "~ " + NC + kp + ": " + va + " -> " + vb) + if out: + n_add = sum(1 for t in out if t[0] == "+") + n_del = sum(1 for t in out if t[0] == "-") + n_chg = len(out) - n_add - n_del + info("{} added, {} removed, {} changed".format(n_add, n_del, n_chg)) + return 1 + ok("identical") + return 0 + def cmd_new(argv): p = argparse.ArgumentParser(prog="plist new", description="create a new empty plist and open it in the editor") @@ -812,11 +891,11 @@ def cmd_settings(argv): # ── cli ─────────────────────────────────────────────────────── JSON_OUT = False -COMMANDS = ("get", "set", "del", "convert", "edit", "new", "settings", "help") +COMMANDS = ("get", "set", "del", "diff", "convert", "edit", "new", "settings", "help") SHORT_USAGE = ( "usage: plist [--json] [--no-color] [args] | \n" - " commands: get, set, del, convert, edit, new, settings (plist help for details)" + " commands: get, set, del, diff, convert, edit, new, settings (plist help for details)" ) HELP = """\ @@ -832,6 +911,8 @@ def cmd_settings(argv): plist set [-i|-f|-b|-x|-d|-u] set the value at a keypath plist del remove a key or array element + plist diff show added/removed/changed keys + (exit 0 identical, 1 differs) plist convert --to xml|binary [-o out] rewrite in the other format plist new [--binary] [--root dict|array] @@ -862,7 +943,9 @@ def cmd_settings(argv): expand_mode = all | auto | none decides how containers open; format = xml | binary is what plist new writes; find_scope = keys | values | both is what / searches by default (tab inside the find prompt cycles -the scope for that search). plist settings shows and changes these. +the scope for that search); theme = frost | red picks the palette +(red turns every text color red). plist settings shows and changes +these. one-shots: keypaths are dot separated: Misc.Boot.Timeout or Drivers.0.Path; a @@ -887,6 +970,16 @@ def _apply_color_flags(): COLOR = False if not COLOR: reset_colors() + return + # theme = red from the config file turns every color red (frost 196, + # dim 124 so hints stay muted) - same idea as the editor palette + try: + if load_config().get("theme") == "red": + global C_BRAND, C_FROST, C_DIM, C_GRN, C_AMB, C_RED + C_BRAND = C_FROST = C_GRN = C_AMB = C_RED = "\033[38;5;196m" + C_DIM = "\033[38;5;124m" + except Exception: + pass def _tty(): return sys.stdout.isatty() and sys.stdin.isatty() @@ -962,6 +1055,8 @@ def main(argv=None): return cmd_set(sub) if cmd == "del": return cmd_del(sub) + if cmd == "diff": + return cmd_diff(sub) return cmd_convert(sub) if help_wanted or cmd.startswith("-"): print(HELP) From 7347ddbd638600fa8d9ab196a89516d401e97ab4 Mon Sep 17 00:00:00 2001 From: kaffeindecaf Date: Thu, 3 Sep 2026 23:16:39 +0200 Subject: [PATCH 3/7] add value converter and insert-from-template presets --- README.md | 15 ++- ROADMAP.md | 27 +++- plist_tui.py | 344 +++++++++++++++++++++++++++++++++++++++++++++++ propertreecli.py | 2 +- 4 files changed, 385 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d46d1b2a..0ebd7ac1 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,18 @@ sticks for the session. R replaces the query inside string values (when the search is scoped to keys it refuses; replacing keys is a rename, do it by hand with r). +^t (ctrl+t) opens the value converter on the selected entry: read the +text as ascii, base64, decimal, hex, or binary and render it as +another. pick from and to, tweak the text if you like, and enter on +the result writes it back as the entry's own kind - pasting foreign +base64 into a data field and entering stores the decoded bytes in one +trip. + +T inserts from the OpenCore/Clover preset library: pick a section, +then a path (Kernel/Add, ACPI/Patch...), then a preset such as a blank +entry or a ready kext pack. Missing containers are created along the +way; it asks before clobbering anything. + Hold j/k or the arrows and movement accelerates smoothly: one row per repeat at first, then gradually more, no sudden jumps. @@ -107,7 +119,8 @@ instead of rewriting them: - the oc snapshot code walks ACPI/Kexts/Tools/Drivers, orders kexts by dependency, and detects the schema from OpenCore.efi's md5 - a headless snapshot command is next, tracked in ROADMAP.md -- find/replace is ported; the ascii/hex/base64 converter is pending +- find/replace is ported, as are the value converter and the + OpenCore/Clover insert-from-template presets Progress lives in ROADMAP.md. It is a checklist, not a plan: one item per session, done end to end or not done. diff --git a/ROADMAP.md b/ROADMAP.md index 2f9bd286..38a280ab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -276,10 +276,35 @@ snapshot logic in plistwindow.py, the converter, the settings model. tk dropdown filters by plist type (key/boolean/data/date/number/uid/ string) - a terminal finder searches value text across types instead, which is a superset for substring use._ -- [ ] **2.9 converter + templates** - the gui's ascii/base64/decimal/ +- [x] **2.9 converter + templates** - the gui's ascii/base64/decimal/ hex/binary converter as a keybind on a selected value (parity with the tk converter window), and insert-from-template using config_tex_info.py + menu.plist (OpenCore/Clover samples). + _Done 2026-09-03: both halves live in the editor. ^t (ctrl+t, the same + accelerator the gui uses for its Convert Window) opens the converter + on the selected string/data/integer value: pick a from/to encoding + (ascii, base64, decimal, hex, binary), the source text is prefilled + with the value, conversion math mirrors convert_values() in + ProperTree.py (0x and <> tolerated in hex, odd nibbles padded, + base64 padding repaired, hex out grouped in 8s). enter on the result + writes the source back through the from-encoding as the node's own + kind: pasting foreign base64 into a data field then entering stores + the decoded bytes in one trip. encodings that cannot round-trip into + a kind (decimal/binary -> data) refuse with a "view only" flash. + T offers insert-from-template: section (OpenCore/Clover) -> path + (ACPI/Add, Kernel/Add, ...) -> preset (New Blank Entry, kext packs, + Drop DMAR...). presets know their own destination path + container + types (d = dict, a = array), so the walk creates missing containers + and appends into arrays / merges into dicts (confirm first when a + segment exists with the wrong type or a key would be clobbered), the + same merge_menu_preset semantics minus tk bookkeeping. the terminal + editor has no pointer, so the whole preset list is offered and the + preset's own path decides where it lands (tk filters by the + right-clicked node - noted deviation). data source is the repo's + Scripts/menu.plist, loaded lazily once. both keybinds are in the ? + overlay. pty scenario G covers: hex data -> pasted base64 -> stored + bytes, and two Kernel/Add blank inserts into a fresh file (first run + creates the path, second appends)._ - [x] **2.10 editor view config** - a small config file decides how the editor opens files: everything expanded, everything collapsed, or the auto heuristic. plain key=value with # comments, template diff --git a/plist_tui.py b/plist_tui.py index 4ce00a0e..eeef56e9 100644 --- a/plist_tui.py +++ b/plist_tui.py @@ -13,6 +13,7 @@ # look follows W0lfSword: frost accents on grey, boxed menus, dim hints, # status glyphs. selection is a frost bar with dark text. +import base64 import copy import curses import datetime @@ -157,6 +158,141 @@ def _parse(text, kind): from propertreecli import parse_value return parse_value(text, kind) +# ── converter (roadmap 2.9) ───────────────────────────────── +# the tk gui's Convert Window (Ctrl+T) is a scratchpad that +# renders text as bytes in one encoding and back out in another. +# same five views here: ascii, base64, decimal, hex, binary. +_CONV_NAMES = ("Ascii", "Base64", "Decimal", "Hex", "Binary") + +def _convert_text(text, frm, to): + # raise ValueError with a readable reason on bad input + if not text: + raise ValueError("nothing to convert") + f, t = frm.lower(), to.lower() + if f == "hex": + v = text[2:] if text.lower().startswith("0x") else text + v = v.replace(" ", "").replace("<", "").replace(">", "") + if not v or any(c.lower() not in "0123456789abcdef" for c in v): + raise ValueError("hex text may only hold 0-9 a-f (0x, <>, spaces ok)") + if len(v) % 2: + v = "0" + v + data = bytes.fromhex(v) + elif f in ("decimal", "binary"): + base = 10 if f == "decimal" else 2 + try: + n = int("".join(text.split()), base) + except ValueError: + raise ValueError("not a valid {} number: '{}'".format(frm, text.strip())) + h = "{:x}".format(n) + if len(h) % 2: + h = "0" + h + data = bytes.fromhex(h) + elif f == "base64": + s = "".join(text.split()) + s2 = s.rstrip("=") + if len(s2) % 4 == 1: + raise ValueError("not valid base64 text") + s = s2 + "=" * ((4 - len(s2) % 4) % 4) + try: + data = base64.b64decode(s, validate=True) + except Exception: + raise ValueError("not valid base64 text") + else: # ascii + data = text.encode("utf-8") + if t == "base64": + return base64.b64encode(data).decode("ascii") + if t == "hex": + out = data.hex().upper() + return " ".join(out[i:i + 8] for i in range(0, len(out), 8)) + if t == "decimal": + return str(int(data.hex() or "0", 16)) + if t == "binary": + return "{:b}".format(int(data.hex() or "0", 16)) + try: + return data.decode("utf-8") + except UnicodeDecodeError: + raise ValueError("bytes are not valid utf-8 text (try hex or base64)") + +def _convert_prefill(kind): + # (from_index, to_index) preselects for a node kind + if kind == "data": + return 3, 1 # hex -> base64 + if kind == "integer": + return 2, 3 # decimal -> hex + return 0, 3 # string: ascii -> hex + +def _convert_apply(kind, text, frm): + # write the source text back into a value of the node's own kind, + # interpreting the text as the from-encoding (what the user picked + # in the converter). raises ValueError when that encoding cannot + # round-trip into the plist type (view-only conversions). + f = frm.lower() + if kind == "data": + if f == "hex": + return bytes.fromhex("".join(text.split())) + if f == "base64": + return base64.b64decode("".join(text.split())) + if f == "ascii": + return text.encode("utf-8") + raise ValueError("decimal/binary cannot rebuild data bytes - view only") + if kind == "integer": + base = {"decimal": 10, "hex": 16, "binary": 2}.get(f) + if base is None: + raise ValueError("ascii/base64 cannot rebuild an integer - view only") + clean = "".join(text.split()) + if f == "hex": + clean = clean[2:] if clean.lower().startswith("0x") else clean + try: + return int(clean, base) + except ValueError: + raise ValueError("not a valid {} number: '{}'".format(frm, text.strip())) + # string + if f == "ascii": + return text + if f == "hex": + try: + return bytes.fromhex("".join(text.split())).decode("utf-8") + except UnicodeDecodeError: + raise ValueError("those bytes are not utf-8 text - view only") + if f == "base64": + try: + return base64.b64decode("".join(text.split())).decode("utf-8") + except Exception: + raise ValueError("not text in that base64 - view only") + raise ValueError("decimal/binary cannot rebuild a string - view only") + +# ── insert-from-template (roadmap 2.9) ─────────────────────── +# the tk gui right-clicks a node and offers presets from +# Scripts/menu.plist: each preset knows its destination path +# (Root/ACPI/Add, Root/Kernel/Add, ...), the container types +# along it (d = dict, a = array), and the value to insert. +# the terminal editor has no pointer, so T offers the whole +# list and the preset's own path decides where it lands. +_MENU_CACHE_PATH = None +_MENU_CACHE_DATA = None + +def _template_root(): + # repo layout: this file sits next to Scripts/menu.plist + return os.path.join(os.path.dirname(os.path.realpath(__file__)), + "Scripts", "menu.plist") + +def _template_data(): + # lazy one-time load of menu.plist; returns {section: {path: [items]}} + global _MENU_CACHE_PATH, _MENU_CACHE_DATA + p = _template_root() + if _MENU_CACHE_PATH == p and _MENU_CACHE_DATA is not None: + return _MENU_CACHE_DATA + if not os.path.exists(p): + return None + try: + from propertreecli import load_plist + data = load_plist(p) + _MENU_CACHE_PATH = p + _MENU_CACHE_DATA = data + return data + except Exception: + return None + # ── the editor ──────────────────────────────────────────────── class Editor: def __init__(self, stdscr, root, path): @@ -566,6 +702,185 @@ def _change_type(self): self._after_mutate() self._flash("changed to {}".format(target), "grn") + # ── converter (^t) ──────────────────────────────────────── + def _convert_value(self): + # port of the tk gui's Convert Window, bound to the selected + # value: pick from/to encodings, tweak the text, convert. + # enter on the result writes the source back into the value + # (interpreted as its own kind), so pasting foreign base64 + # or hex into a data field works in one trip. + row = self.rows[self.sel] + if not row["leaf"]: + self._flash("converter works on values, not containers", "red") + return + v = row["node"] + kind = _type_name(v) + if kind not in ("string", "data", "integer"): + self._flash("converter works on string, data, and integer values", "red") + return + label = self._row_label(row) + fi, ti = _convert_prefill(kind) + f = self._menu("convert {} - from ({})".format(label, _CONV_NAMES[fi]), + list(_CONV_NAMES), sel=fi) + if f is None: + return + t = self._menu("convert {} - to ({})".format(label, _CONV_NAMES[ti]), + list(_CONV_NAMES), sel=ti) + if t is None: + return + fname, tname = _CONV_NAMES[f], _CONV_NAMES[t] + text = self._prompt("convert ({} -> {}):".format(fname, tname), + canonical(v)) + if text is None: + return + if not text.strip() and kind != "string": + self._flash("nothing to convert", "red") + return + try: + result = _convert_text(text, fname, tname) + except ValueError as e: + self._flash(str(e), "red") + return + # result box: show the conversion, enter writes back + w = self.W() + chunk = max(w - 8, 12) + lines = ["{} ({} -> {})".format(label, fname, tname)] + rl = result if len(result) <= chunk else result[:chunk - 1] + "\u2026" + lines.append(rl) + lines.append("") + lines.append("enter = write source back as {}".format(kind)) + if not self._choice_box("convert", lines): + self._flash("converted (not written)") + return + try: + new = _convert_apply(kind, text, fname) + except ValueError as e: + self._flash(str(e), "red") + return + if new == v: + self._flash("unchanged") + return + self._push_undo() + parent, last = self._parent_of(row) + if isinstance(parent, dict): + parent[last] = new + else: + parent[int(last)] = new + self._after_mutate() + self._flash("converted \u2713", "grn") + + # ── insert from template (T) ────────────────────────────── + def _insert_template(self): + data = _template_data() + if not data: + self._flash("template data missing - Scripts/menu.plist not found", "red") + return + if not isinstance(self.root, dict): + self._flash("template presets need a dictionary root", "red") + return + sections = sorted(s for s in data if isinstance(data[s], dict) and data[s]) + if not sections: + self._flash("no template sections found", "red") + return + pick = self._menu("insert from template - section", sections) + if pick is None: + return + section = sections[pick] + paths = sorted(p for p in data[section] + if isinstance(data[section][p], list) and data[section][p]) + labels = [p.replace("Root/", "", 1) if p.startswith("Root/") else p + for p in paths] + pick = self._menu("insert from template - {}".format(section), labels) + if pick is None: + return + path = paths[pick] + # presets are the entries with a name + types + value; separator + # and title rows are menu furniture the terminal does not need + presets = [e for e in data[section][path] + if isinstance(e, dict) and "name" in e + and "types" in e and "value" in e] + if not presets: + self._flash("no insertable presets under {}".format(labels[pick]), "red") + return + names = [e["name"] for e in presets] + pick = self._menu("insert - {}".format(labels[pick]), names) + if pick is None: + return + self._template_apply(path, presets[pick]) + + def _template_apply(self, path, preset): + # walk the preset's own path from the root, creating missing + # containers (d = dict, a = array), then drop the value in: + # arrays append, dicts merge keys. mirrors the tk gui's + # merge_menu_preset, minus the drag-and-drop bookkeeping. + types = str(preset.get("types", "")).split("/") + value = preset.get("value") + segs = [s for s in str(path).split("/") if s] + if segs and segs[0].lower() == "root": + segs, types = segs[1:], types[1:] + if not segs or len(segs) != len(types) or not isinstance(value, dict): + self._flash("malformed preset ({})".format(path), "red") + return + # walk until the first segment that is missing or the wrong + # type - from there down everything gets (re)built fresh + cur = self.root + question = None + replace_kind = None + complete = True + for seg, typ in zip(segs, types): + want_list = str(typ).lower() == "a" + if not isinstance(cur, dict): + # an array sits mid-path; presets only descend dicts + self._flash("preset path crosses an array ({})".format(path), "red") + return + if seg not in cur: + complete = False + break + ex = cur[seg] + good = isinstance(ex, list) if want_list else isinstance(ex, dict) + if not good: + question = "{} is a {} - replace it with {}?".format( + seg, _type_name(ex), "array" if want_list else "dict") + replace_kind = "array" if want_list else "dict" + complete = False + break + cur = ex + if question is not None and not self._confirm(question): + self._flash("template not applied") + return + if complete and isinstance(cur, dict): + for k in value: + if k in cur: + if not self._confirm("key {} already exists - replace it?".format(k)): + self._flash("template not applied") + return + # apply (single undo step covers the whole insert) + self._push_undo() + cur = self.root + for seg, typ in zip(segs, types): + want_list = str(typ).lower() == "a" + if isinstance(cur, dict): + if seg not in cur or not ( + isinstance(cur[seg], list) if want_list + else isinstance(cur[seg], dict)): + cur[seg] = [] if want_list else {} + cur = cur[seg] + if isinstance(cur, list): + cur.append(copy.deepcopy(value)) + sel_path = segs + [len(cur) - 1] + else: + for k, v in value.items(): + cur[k] = copy.deepcopy(v) + sel_path = segs + [list(value)[-1]] + # expand the container path so the insert is visible, then jump to it + for i in range(1, len(segs) + 1): + self.expanded[tuple(segs[:i])] = True + self._after_mutate() + idx = self._row_index_of_path(sel_path) + if idx is not None: + self.sel = idx + self._flash("inserted {}".format(preset.get("name", path)), "grn") + def _duplicate_row(self): # copy the whole entry and insert it right after itself; dict keys # get a "copy" suffix that auto-increments on collision @@ -920,6 +1235,8 @@ def _help(self): "delete d (asks first, even for leaves)", "rename r (dict keys)", "type t (scalar conversions)", + "convert ^t (text: ascii/base64/decimal/hex/binary)", + "template T (insert OC/Clover presets from menu.plist)", "move entry < > (reorder inside its parent)", "copy/cut/paste c x p (shared /tmp clipboard)", "undo/redo u / ctrl+r (200 steps)", @@ -951,6 +1268,29 @@ def _static_box(self, title, lines): if ch != -1: return + def _choice_box(self, title, lines): + # like _static_box, but enter = yes and esc = no so callers + # can branch on the answer + self._draw() + h, w = self.H(), self.W() + width = min(max(len(title) + 6, max(len(l) for l in lines) + 6), w - 4) + vis = lines[: max(h - 6, 1)] + top = max((h - len(vis) - 4) // 2, 0) + left = max((w - width) // 2, 0) + self._box(title, [" " * (width - 4)] * len(vis), width) + for i, l in enumerate(vis): + y = top + 3 + i + self.s.addstr(y, left + 2, l[: width - 4]) + self.s.refresh() + while True: + ch = self.s.getch() + if ch in (10, 13, curses.KEY_ENTER): + return True + if ch in (27, ord("q"), ord("n")): + return False + if ch != -1: + return False + # ── prompt line ─────────────────────────────────────────── def _prompt(self, label, prefill="", cycle=None, cycle_i=0): # blocking line editor on the status row; returns text or None. @@ -1250,6 +1590,10 @@ def _handle(self, ch): self._rename_row() elif ch == ord("t"): self._change_type() + elif ch in (20,): # ctrl+t: the value converter (tk gui parity) + self._convert_value() + elif ch == ord("T"): + self._insert_template() elif ch in (ord("c"),): self._copy_row(cut=False) elif ch in (ord("x"),): diff --git a/propertreecli.py b/propertreecli.py index 351a3dfa..3508642c 100755 --- a/propertreecli.py +++ b/propertreecli.py @@ -5,7 +5,7 @@ # single source of truth for the version - keep this in sync with the # update feed when 4.3 lands. the command is `plist`; the file keeps the # repo name so it can never shadow Scripts/plist.py on import. -VERSION = "0.4.0" +VERSION = "0.5.0" import argparse import datetime From d476fd2f72f693c8c980aed81f5ac723bef202d1 Mon Sep 17 00:00:00 2001 From: kaffeindecaf Date: Fri, 4 Sep 2026 00:31:59 +0200 Subject: [PATCH 4/7] Update README.md --- README.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/README.md b/README.md index 0ebd7ac1..372e8628 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,7 @@ just the tree, so it greps and scripts. plist test.plist -j/k or the arrows move, home/end top/bottom, ctrl+d / ctrl+u half a -page, { } jump between siblings, left/right fold containers, enter -edits a value (booleans toggle), i adds an entry (pick the type, then -name it), D duplicates, d deletes, r renames a key, t changes a -value's type, c/x/p copy/cut/paste, u undoes (ctrl+r redoes, 200 -steps), ctrl+s saves, q quits (it asks when the file is dirty). ? -shows every keybind. + / finds. n and N cycle the matches, esc clears. tab inside the find prompt picks what to search - keys, values, or both - and the choice From 290005687b5ea74897595ee53970814134ee1f79 Mon Sep 17 00:00:00 2001 From: kaffeindecaf Date: Fri, 18 Sep 2026 16:55:05 +0200 Subject: [PATCH 5/7] fix adding entries to a new empty plist the root container is never drawn as a row, so a fresh `plist new` had an empty row list and every row op died with "IndexError: list index out of range" (logged to /tmp/plist_tui.log, flashed as an error line - the editor stayed usable, so it looked like the keys did nothing at all). i, enter, d, c, r, t, D and the fold arrows were all affected. - _sel_row() / _target_of_row(): an empty tree falls back to the root as the container, so i (and p) can fill a brand new dict or array - _last(): the selection can no longer land on -1 when there are no rows, and the two arrow branches in _handle bail out - an empty body now says what the file is and how to fill it instead of showing a blank tree, and ? mentions the empty-plist case - tests/run.sh: pty smoke tests, no deps - fills a new dict root and a new array root through the real editor, then a regression pass over a normal tree (insert order, fold, copy/paste, undo, rename, quit) - test.plist: the sample comment now says what to try --- README.md | 10 +++ plist_tui.py | 142 ++++++++++++++++++++++++++++---------- test.plist | 2 +- tests/run.sh | 13 ++++ tests/tui_empty_test.py | 141 +++++++++++++++++++++++++++++++++++++ tests/tui_regress_test.py | 89 ++++++++++++++++++++++++ 6 files changed, 360 insertions(+), 37 deletions(-) create mode 100755 tests/run.sh create mode 100644 tests/tui_empty_test.py create mode 100644 tests/tui_regress_test.py diff --git a/README.md b/README.md index 372e8628..9284f67d 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,16 @@ instead of rewriting them: Progress lives in ROADMAP.md. It is a checklist, not a plan: one item per session, done end to end or not done. +## Tests + + tests/run.sh # compile check + pty smoke tests, stdlib only + +drives the real editor over a pty and asserts on the saved file: filling +a brand new plist of both root types (the empty tree has no rows to +select, so `i` has to fall back to the root), plus sibling insert order, +fold, copy/paste, undo, rename and clean quit on a normal tree. no +framework, no deps, exit code says pass or fail. + ## Running the original gui python3 ProperTree.py [file.plist] diff --git a/plist_tui.py b/plist_tui.py index eeef56e9..119ca284 100644 --- a/plist_tui.py +++ b/plist_tui.py @@ -473,6 +473,31 @@ def _parent_of(self, row): parent = parent[int(seg)] return parent, p[-1] + def _sel_row(self): + # the selected row, or None when the tree is empty: the root + # container is never drawn as a row, so a fresh `plist new` (or + # any empty root) has no rows at all for the row ops to point at + if not self.rows: + kind = ("dict" if getattr(self, "root_is_dict", False) + else "array" if getattr(self, "root_is_list", False) else "plist") + self._flash("empty {} - press i to add the first entry".format(kind)) + return None + return self.rows[self.sel] + + def _target_of_row(self, row): + # (container, anchor, into_container) for an add or a paste: a + # container row takes children, a leaf row takes siblings, and no + # row at all means the root itself is the container + if row is None: + return self.root, None, True + if isinstance(row["node"], (dict, list)): + return self._node_at(row["path"]), None, True + return self._node_at(row["path"][:-1]), row["path"][-1], False + + def _last(self): + # index of the last row, 0 when the tree is empty (never -1) + return max(len(self.rows) - 1, 0) + # ── save / quit ─────────────────────────────────────────── def _save(self): from propertreecli import file_format, write_plist @@ -508,7 +533,9 @@ def _rebuild_rows_keep(self, path): self._recompute_matches() def _toggle_expand(self, row=None): - row = row or self.rows[self.sel] + row = row or self._sel_row() + if row is None: + return p = tuple(row["path"]) self.expanded[p] = not self.expanded.get(p, True) self._rebuild_rows_keep(row["path"]) @@ -523,19 +550,15 @@ def _parent_is_dict(self, row): return isinstance(parent, dict) def _add_entry(self): - row = self.rows[self.sel] - if isinstance(row["node"], (dict, list)): - parent_path, anchor = row["path"], None # child: append at end - into_container = True - else: - parent_path, anchor = row["path"][:-1], row["path"][-1] - into_container = False - parent = self.root - for seg in parent_path: - parent = parent[seg] if isinstance(parent, dict) else parent[int(seg)] + # no rows means an empty tree, so the root is the container to + # add into - this is the only way to fill a fresh plist + row = self.rows[self.sel] if self.rows else None + if row is None and not isinstance(self.root, (dict, list)): + self._flash("the root is a {} value - nothing to add to".format( + _type_name(self.root)), "red") + return + parent, anchor, _ = self._target_of_row(row) types = list(_KINDS.keys()) + ["dict", "array"] - if not into_container and isinstance(parent, list) and anchor is None: - types = [t for t in types] # arrays can hold anything pick = self._menu("add entry - type", types) if pick is None: return @@ -611,7 +634,9 @@ def _replace_parent_dict(self, old, new): stack.append((v, cur, i)) def _delete_row(self): - row = self.rows[self.sel] + row = self._sel_row() + if row is None: + return parent, last = self._parent_of(row) label = self._row_label(row) if isinstance(parent, dict): @@ -641,7 +666,9 @@ def _rebuild_dict_without(self, d, drop): self._replace_parent_dict(d, out) def _rename_row(self): - row = self.rows[self.sel] + row = self._sel_row() + if row is None: + return if not isinstance(row["key"], str): self._flash("array elements have no key to rename", "red") return @@ -667,7 +694,9 @@ def _rename_row(self): self._flash("renamed to {}".format(new), "grn") def _change_type(self): - row = self.rows[self.sel] + row = self._sel_row() + if row is None: + return if not row["leaf"]: self._flash("change type works on values, not containers", "red") return @@ -709,7 +738,9 @@ def _convert_value(self): # enter on the result writes the source back into the value # (interpreted as its own kind), so pasting foreign base64 # or hex into a data field works in one trip. - row = self.rows[self.sel] + row = self._sel_row() + if row is None: + return if not row["leaf"]: self._flash("converter works on values, not containers", "red") return @@ -884,7 +915,9 @@ def _template_apply(self, path, preset): def _duplicate_row(self): # copy the whole entry and insert it right after itself; dict keys # get a "copy" suffix that auto-increments on collision - row = self.rows[self.sel] + row = self._sel_row() + if row is None: + return parent, last = self._parent_of(row) payload = copy.deepcopy(row["node"]) self._push_undo() @@ -907,7 +940,9 @@ def _duplicate_row(self): def _sibling_jump(self, direction): # { / }: jump to the previous / next sibling of the current row - row = self.rows[self.sel] + row = self._sel_row() + if row is None: + return parent_path = tuple(row["path"][:-1]) sib = [i for i, r in enumerate(self.rows) if tuple(r["path"][:-1]) == parent_path] @@ -922,7 +957,9 @@ def _sibling_jump(self, direction): self.sel = sib[npos] def _move_row(self, delta): - row = self.rows[self.sel] + row = self._sel_row() + if row is None: + return parent, last = self._parent_of(row) if isinstance(parent, dict): keys = list(parent.keys()) @@ -962,7 +999,9 @@ def _move_row(self, delta): # ── value editing ───────────────────────────────────────── def _edit_value(self): - row = self.rows[self.sel] + row = self._sel_row() + if row is None: + return if not row["leaf"]: self._toggle_expand(row) return @@ -999,7 +1038,9 @@ def _edit_value(self): # ── clipboard ───────────────────────────────────────────── def _copy_row(self, cut=False): - row = self.rows[self.sel] + row = self._sel_row() + if row is None: + return payload = copy.deepcopy(row["node"]) from propertreecli import _plist_mod plist = _plist_mod() @@ -1019,7 +1060,9 @@ def _copy_row(self, cut=False): self._flash("copied {}".format(self._row_label(row)), "grn") def _delete_row_no_confirm(self): - row = self.rows[self.sel] + row = self._sel_row() + if row is None: + return parent, last = self._parent_of(row) self._push_undo() if isinstance(parent, dict): @@ -1043,15 +1086,16 @@ def _paste(self): except Exception as e: self._flash("clipboard read failed: {}".format(e), "red") return - row = self.rows[self.sel] - if isinstance(row["node"], (dict, list)): - parent_path, anchor = row["path"], None - parent = self._node_at(parent_path) - else: - parent_path, anchor = row["path"][:-1], row["path"][-1] - parent = self._node_at(parent_path) + row = self.rows[self.sel] if self.rows else None + if row is None and not isinstance(self.root, (dict, list)): + self._flash("the root is a {} value - nothing to paste into".format( + _type_name(self.root)), "red") + return + parent, anchor, _ = self._target_of_row(row) if isinstance(parent, dict): - key = self._prompt("paste as key:", str(row["key"]) if isinstance(row["key"], str) else "") + default = (str(row["key"]) + if row is not None and isinstance(row["key"], str) else "") + key = self._prompt("paste as key:", default) if key is None: return if key in parent: @@ -1231,6 +1275,7 @@ def _help(self): "fold left/right or space, enter on a container", "edit enter on a value (booleans toggle)", "add i (into a container, else as sibling)", + " an empty plist has no rows: i fills the root", "duplicate D (copies under a new key / array slot)", "delete d (asks first, even for leaves)", "rename r (dict keys)", @@ -1394,9 +1439,30 @@ def _draw(self, status=None): for i, r in enumerate(self.rows[self.top:self.top + list_h]): y = body_top + i self._draw_row(y, r, self.top + i == self.sel, self.top + i) + if not self.rows: + self._draw_empty(body_top) # footer / status self._draw_status(status) + def _draw_empty(self, y): + # an empty root draws no rows: say what the file is and how to + # fill it rather than leaving the body blank + if self.root_is_dict: + what = "dictionary" + elif self.root_is_list: + what = "array" + else: + what = "{} value".format(_type_name(self.root)) + self.s.addstr(y, 2, "(empty {} root)".format(what), P("dim")) + hint = "press " + self.s.addstr(y + 1, 2, hint, P("dim")) + self.s.addstr(y + 1, 2 + len(hint), "i", P("frost", bold=True)) + hint2 = " to add the first entry, or " + self.s.addstr(y + 1, 2 + len(hint) + 1, hint2, P("dim")) + self.s.addstr(y + 1, 2 + len(hint) + 1 + len(hint2), "T", P("frost", bold=True)) + self.s.addstr(y + 1, 2 + len(hint) + 2 + len(hint2), + " for an OpenCore preset", P("dim")) + def _row_value_text(self, r): if not r["leaf"]: if r["count"] == 0: @@ -1525,7 +1591,7 @@ def _move_sel(self, d): if d < 0: self.sel = max(self.sel - step, 0) else: - self.sel = min(self.sel + step, len(self.rows) - 1) + self.sel = min(self.sel + step, self._last()) def _handle(self, ch): if ch == -1: @@ -1543,12 +1609,14 @@ def _handle(self, ch): elif ch in (ord("g"),): self.sel = 0 elif ch in (ord("G"),): - self.sel = len(self.rows) - 1 + self.sel = self._last() elif ch in (curses.KEY_NPAGE,): - self.sel = min(self.sel + self.H() - 6, len(self.rows) - 1) + self.sel = min(self.sel + self.H() - 6, self._last()) elif ch in (curses.KEY_PPAGE,): self.sel = max(self.sel - (self.H() - 6), 0) elif ch in (curses.KEY_LEFT, ord("h")): + if not self.rows: + return None row = self.rows[self.sel] p = tuple(row["path"]) if not row["leaf"] and self.expanded.get(p, True): @@ -1560,6 +1628,8 @@ def _handle(self, ch): if i is not None: self.sel = i elif ch in (curses.KEY_RIGHT, ord("l"), ord(" ")): + if not self.rows: + return None row = self.rows[self.sel] p = tuple(row["path"]) if not row["leaf"] and not self.expanded.get(p, True): @@ -1571,13 +1641,13 @@ def _handle(self, ch): elif ch in (4, 21): # ctrl+d / ctrl+u: half a page step = max((self.H() - 6) // 2, 1) if ch == 4: - self.sel = min(self.sel + step, len(self.rows) - 1) + self.sel = min(self.sel + step, self._last()) else: self.sel = max(self.sel - step, 0) elif ch in (curses.KEY_HOME,): self.sel = 0 elif ch in (curses.KEY_END,): - self.sel = len(self.rows) - 1 + self.sel = self._last() elif ch in (10, 13, curses.KEY_ENTER): self._edit_value() elif ch in (ord("i"), curses.KEY_IC): diff --git a/test.plist b/test.plist index e99b092c..dd8696c1 100644 --- a/test.plist +++ b/test.plist @@ -3,7 +3,7 @@ Comment - sample plist - try: plist test.plist (q to quit, ? for keys) + press enter and type something! Boot-args -v keepsyms=1 debug=0x100 csr-active-config diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 00000000..bc76ec7a --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# tui tests - pty driven, stdlib only, no test framework +# usage: tests/run.sh run everything +set -euo pipefail +cd "$(dirname "$(readlink -f "$0")")/.." + +python3 -m py_compile plist_tui.py propertreecli.py Scripts/plist.py +echo "compile ok" + +rm -rf /tmp/plist_tui_tests +python3 tests/tui_empty_test.py +python3 tests/tui_regress_test.py +echo "all tui tests passed" diff --git a/tests/tui_empty_test.py b/tests/tui_empty_test.py new file mode 100644 index 00000000..30eda86c --- /dev/null +++ b/tests/tui_empty_test.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +# regression smoke test: filling a brand new (empty root) plist in the tui +import os, fcntl, pty, plistlib, select, struct, subprocess, sys, termios, time + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +WD = "/tmp/plist_tui_tests" # scratch files for the run +XDG = os.path.join(WD, "config") # first-run config stays out of $HOME +LOG = "/tmp/plist_tui.log" # the editor appends tracebacks here +os.makedirs(XDG, exist_ok=True) +os.makedirs(WD, exist_ok=True) + +DOWN, UP, RIGHT, LEFT = b"\x1bOB", b"\x1bOA", b"\x1bOC", b"\x1bOD" +CTRL_S = b"\x13" +fails = [] + +def check(name, cond, extra=""): + print(("PASS " if cond else "FAIL ") + name + (" " + extra if extra and not cond else "")) + if not cond: + fails.append(name) + +class Tui: + def __init__(self, path): + self.master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 100, 0, 0)) + env = dict(os.environ, TERM="xterm-256color", LC_ALL="C.UTF-8", + LANG="C.UTF-8", XDG_CONFIG_HOME=XDG) + self.proc = subprocess.Popen([sys.executable, os.path.join(REPO, "propertreecli.py"), + "edit", path], + stdin=slave, stdout=slave, stderr=slave, + env=env, close_fds=True) + os.close(slave) + self.buf = b"" + + def pump(self, t=0.4): + end = time.time() + t + while time.time() < end: + r, _, _ = select.select([self.master], [], [], 0.1) + if r: + try: + c = os.read(self.master, 65536) + except OSError: + break + if not c: + break + self.buf += c + if len(self.buf) > 400000: + self.buf = self.buf[-200000:] + + def wait_for(self, text, timeout=6): + end = time.time() + timeout + while time.time() < end: + if text in self.buf.decode("utf-8", "replace"): + return True + self.pump(0.2) + return False + + def send(self, d, t=0.35): + os.write(self.master, d if isinstance(d, bytes) else d.encode()) + time.sleep(t) + self.pump() + + def quit(self): + self.send(b"q") + time.sleep(0.8) + self.pump(0.5) + try: + rc = self.proc.wait(timeout=4) + except subprocess.TimeoutExpired: + self.proc.kill() + return None + os.close(self.master) + return rc + +def new_plist(path, *extra): + return subprocess.run([sys.executable, os.path.join(REPO, "propertreecli.py"), + "new", path] + list(extra), + capture_output=True, env=dict(os.environ, XDG_CONFIG_HOME=XDG)).returncode + +def load(path): + with open(path, "rb") as f: + return plistlib.load(f) + +open(LOG, "w").close() # the editor appends tracebacks here + +# ── empty dict root ──────────────────────────────────────────────────── +F = os.path.join(WD, "fresh.plist") +os.path.exists(F) and os.unlink(F) +check("plist new creates the file", new_plist(F) == 0 and os.path.exists(F)) +t = Tui(F) +check("empty root is announced", t.wait_for("empty dictionary root")) +# every one of these used to raise IndexError on an empty tree +for k in (LEFT, RIGHT, UP, DOWN, b"\r", b"c", b"d", b"r", b"t", b"D", b"x", + b"g", b"G", b">{", b"{"): + t.send(k) +check("no crash from row keys", not os.path.getsize(LOG), open(LOG).read()[-400:]) +check("still alive after row keys", t.proc.poll() is None) + +# i -> string -> key name +t.send(b"i"); t.send(b"\r"); t.send(b"Alpha\r") +check("first entry added to the root", t.wait_for("Alpha")) +t.send(b"i"); t.send(b"\r"); t.send(b"Beta\r") +# last row, then a dict child: menu item 9 is "dict" +t.send(b"G"); t.send(b"i"); t.send(b"j" * 7); t.send(b"\r"); t.send(b"Nested\r") +t.send(b"G"); t.send(b"i"); t.send(b"\r"); t.send(b"Child\r") +t.send(CTRL_S, t=0.6) +d = load(F) +check("dict root filled", d == {"Alpha": "", "Beta": "", "Nested": {"Child": ""}}, repr(d)) +# delete the child through the confirm menu +t.send(b"G"); t.send(b"d"); t.send(b"k\r") # confirm menu defaults to no +t.send(CTRL_S, t=0.6) +d = load(F) +check("delete works on the filled tree", d == {"Alpha": "", "Beta": "", "Nested": {}}, repr(d)) +rc = t.quit() +check("clean quit (dict)", rc == 0, "rc=%s" % rc) +check("no tracebacks (dict run)", not os.path.getsize(LOG), open(LOG).read()[-400:]) + +# ── empty array root ─────────────────────────────────────────────────── +A = os.path.join(WD, "fresh_array.plist") +os.path.exists(A) and os.unlink(A) +check("plist new --root array", new_plist(A, "--root", "array") == 0) +t = Tui(A) +check("empty array root is announced", t.wait_for("empty array root")) +t.send(b"i"); t.send(b"\r"); t.send(CTRL_S, t=0.6) +d = load(A) +check("array root filled", d == [""], repr(d)) +t.send(b"i"); t.send(b"j" * 8); t.send(b"\r") # -> array, appended +t.send(b"i"); t.send(b"j" * 3); t.send(b"\r") # -> boolean, appended +t.send(CTRL_S, t=0.6) +d = load(A) +check("array keeps taking mixed types", d == ["", True, []], repr(d)) +t.send(b">") +t.send(b"G"); t.send(b"i"); t.send(b"\r") # on the empty array row: adds into it +t.send(CTRL_S, t=0.6) +d = load(A) +check("move row + add into the last container", d == [True, "", [""]], repr(d)) +rc = t.quit() +check("clean quit (array)", rc == 0, "rc=%s" % rc) +check("no tracebacks (array run)", not os.path.getsize(LOG), open(LOG).read()[-400:]) + +print("FAILS:", len(fails)) +sys.exit(1 if fails else 0) diff --git a/tests/tui_regress_test.py b/tests/tui_regress_test.py new file mode 100644 index 00000000..3a098914 --- /dev/null +++ b/tests/tui_regress_test.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# regression check: the row ops on a non-empty tree still behave (the +# empty-tree guards must not have changed the normal paths) +import os, fcntl, pty, plistlib, select, struct, subprocess, sys, termios, time + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +WD = "/tmp/plist_tui_tests" # scratch files for the run +XDG = os.path.join(WD, "config") # first-run config stays out of $HOME +LOG = "/tmp/plist_tui.log" # the editor appends tracebacks here +F = os.path.join(WD, "normal.plist") +os.makedirs(XDG, exist_ok=True); os.makedirs(WD, exist_ok=True) +with open(F, "wb") as f: + plistlib.dump({"Alpha": "one", "Flag": True, "Nest": {"Deep": "x"}}, + f, fmt=plistlib.FMT_XML, sort_keys=False) + +DOWN, UP = b"\x1bOB", b"\x1bOA" +CTRL_S, CTRL_Z = b"\x13", b"\x1a" +fails = [] +def check(n, c, extra=""): + print(("PASS " if c else "FAIL ") + n + (" " + extra if extra and not c else "")) + if not c: fails.append(n) + +master, slave = pty.openpty() +fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 100, 0, 0)) +env = dict(os.environ, TERM="xterm-256color", LC_ALL="C.UTF-8", XDG_CONFIG_HOME=XDG) +proc = subprocess.Popen([sys.executable, os.path.join(REPO, "propertreecli.py"), "edit", F], + stdin=slave, stdout=slave, stderr=slave, env=env, close_fds=True) +os.close(slave) +buf = b"" +def pump(t=0.4): + global buf + end = time.time() + t + while time.time() < end: + r, _, _ = select.select([master], [], [], 0.1) + if r: + try: c = os.read(master, 65536) + except OSError: break + if not c: break + buf += c +def wait_for(text, timeout=6): + end = time.time() + timeout + while time.time() < end: + if text in buf.decode("utf-8", "replace"): return True + pump(0.2) + return False +def send(d, t=0.35): + os.write(master, d if isinstance(d, bytes) else d.encode()); time.sleep(t); pump() +def load(): + with open(F, "rb") as f: return plistlib.load(f) + +open(LOG, "w").close() +check("boots", wait_for("Alpha") and wait_for("Nest")) +# rows: 0 Alpha, 1 Flag, 2 Nest, 3 Deep (Nest auto-expands in a small file) +def down(n): + for _ in range(n): + send(b"j") +# add a sibling after Alpha (root dict, leaf row) +send(b"i"); send(b"\r"); send(b"Mid\r") +send(b"g"); down(3) # Nest +send(b"i"); send(b"\r"); send(b"Extra\r") +send(b"g"); down(4) # Deep (Extra appends after it in Nest) +send(b"c", t=0.5) +send(b"g") # back to Alpha +send(b"p"); send(b"\x15Pasted\r", t=0.6) # ctrl+u clears the prefilled key +send(CTRL_S, t=0.6) +d = load() +check("sibling insert keeps dict order", + list(d) == ["Alpha", "Pasted", "Mid", "Flag", "Nest"], repr(list(d))) +check("child added into the container", d["Nest"].get("Extra") == "", repr(d)) +check("paste copies the value", d["Pasted"] == "x", repr(d)) +send(b"g"); down(3) # Flag +send(b"\r", t=0.5) +send(CTRL_S, t=0.5) +check("enter toggles a boolean", load()["Flag"] is False, repr(load())) +send(CTRL_Z, t=0.5); send(CTRL_S, t=0.5) +check("undo restores it", load()["Flag"] is True, repr(load())) +send(b"g"); send(b"r"); send(b"\x15Renamed\r", t=0.5) +send(CTRL_S, t=0.5) +check("rename", load().get("Renamed") == "one", repr(load())) +send(b"q", t=0.8); pump(0.5) +try: + rc = proc.wait(timeout=4) +except subprocess.TimeoutExpired: + proc.kill(); rc = None +check("clean quit", rc == 0, "rc=%s" % rc) +check("no tracebacks", not os.path.getsize(LOG), open(LOG).read()[-400:]) +os.close(master) +print("FAILS:", len(fails)) +sys.exit(1 if fails else 0) From 50ffeba917fcb34622fa30e234b0f5ac93e6d177 Mon Sep 17 00:00:00 2001 From: kaffeindecaf Date: Fri, 18 Sep 2026 17:07:48 +0200 Subject: [PATCH 6/7] restore the editor keybind list in the readme d476fd2 (github web edit, sep 4) dropped the paragraph under "The editor" and left two blank lines behind, so the section listed / find, ^t convert and T presets but never said how to move, edit or save. put it back, with the one change the fix earned: i on an empty plist fills the root. --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9284f67d..3c04416b 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,13 @@ just the tree, so it greps and scripts. plist test.plist - +j/k or the arrows move, home/end top/bottom, ctrl+d / ctrl+u half a +page, { } jump between siblings, left/right fold containers, enter +edits a value (booleans toggle), i adds an entry (pick the type, then +name it - on an empty plist it fills the root), D duplicates, d +deletes, r renames a key, t changes a value's type, c/x/p +copy/cut/paste, u undoes (ctrl+r redoes, 200 steps), ctrl+s saves, q +quits (it asks when the file is dirty). ? shows every keybind. / finds. n and N cycle the matches, esc clears. tab inside the find prompt picks what to search - keys, values, or both - and the choice From ca2abc6c11a907cfb47732ae25a2f28f0a12637e Mon Sep 17 00:00:00 2001 From: kaffeindecaf Date: Fri, 18 Sep 2026 17:25:57 +0200 Subject: [PATCH 7/7] drop the build log and the plan talk from the public tree this fork is meant to be PR-able to corpnewt/ProperTree, so the session notes should not ship with the tool. - ROADMAP.md leaves the repo and keeps living locally as ROADMAP.local.md (gitignored): a public fork ships the tool, not the build log - roadmap item numbers out of the code headers and the version comment, the "when 4.3 lands" note gone, and the section rules line back up at 64 columns - README: no plan talk ("for later", "is next", "one item per session", "the snapshot logic stays for later"), and the reuse section now says what is shared code and what is a port of gui behaviour - README gets a section for a maintainer: the four added things, no upstream file modified, no new deps, no build step, same licence - test.plist moves to tests/sample.plist so the repo root only gains the tool itself; the empty-argv hint and the README follow it - both new modules say what they are and that they are BSD-3 new code --- .gitignore | 3 + README.md | 59 +++-- ROADMAP.md | 407 ------------------------------- plist_tui.py | 9 +- propertreecli.py | 12 +- test.plist => tests/sample.plist | 0 6 files changed, 52 insertions(+), 438 deletions(-) delete mode 100644 ROADMAP.md rename test.plist => tests/sample.plist (100%) diff --git a/.gitignore b/.gitignore index 9623af8f..3b7da594 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,6 @@ Scripts/settings.json Configuration.tex .DS_Store ProperTree.app + +# personal build notes for this fork, not part of the tool +ROADMAP.local.md diff --git a/README.md b/README.md index 3c04416b..c6cda1f3 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Edit plists the way you edit config files on a server: from the terminal, over ssh, inside tmux, on a machine with no display at all. - plist test.plist + plist tests/sample.plist opens a plist as a tree you can walk with the arrow keys, fold like a file explorer, edit inline, and save with ctrl+s. Styled after @@ -13,20 +13,20 @@ exploit toolkit, it is good enough for an EFI folder. ProperTreeCLI is a fork of corpnewt's ProperTree, a tkinter app. The gui still works; the point of this repo is the command line twin. It -keeps the parts worth keeping (Scripts/plist.py handles xml + binary -plists, the snapshot logic stays for later) and adds what a terminal -tool should have: scriptable one-shots, exit codes, and no window. +sits on Scripts/plist.py for the xml and binary io and adds what a +terminal tool should have: scriptable one-shots, exit codes, and no +window. -test.plist in the repo root is a small sample config (nested dicts, -arrays, data, a date) for trying it out. No file yet? plist new -my.plist starts one from scratch. +tests/sample.plist is a small config to try it on (nested dicts, +arrays, data, a date). No file yet? plist new my.plist starts one +from scratch. ## Try it in a minute git clone https://github.com/kaffeindecaf/ProperTreeCLI cd ProperTreeCLI - ./install.sh # symlinks `plist` into ~/.local/bin - plist test.plist # sample editor (q quits, ? shows keys) + ./install.sh # symlinks `plist` into ~/.local/bin + plist tests/sample.plist # sample editor (q quits, ? shows keys) plist --version No pip, no venv, no tkinter. Stdlib only. Run plist from any @@ -37,7 +37,7 @@ just the tree, so it greps and scripts. ## The editor - plist test.plist + plist tests/sample.plist j/k or the arrows move, home/end top/bottom, ctrl+d / ctrl+u half a page, { } jump between siblings, left/right fold containers, enter @@ -110,20 +110,35 @@ run, XDG_CONFIG_HOME aware): plist settings shows and changes these without hand-editing the file. -## State of things +## What is reused from ProperTree -The editor and one-shots sit on the parts of ProperTree worth keeping -instead of rewriting them: +The terminal side does not rewrite what already works: -- Scripts/plist.py handles xml and binary plists, data, uid, 0x ints -- the oc snapshot code walks ACPI/Kexts/Tools/Drivers, orders kexts - by dependency, and detects the schema from OpenCore.efi's md5 - a - headless snapshot command is next, tracked in ROADMAP.md -- find/replace is ported, as are the value converter and the - OpenCore/Clover insert-from-template presets +- Scripts/plist.py reads and writes xml and binary plists, data, uids + and 0x integers, the same code path the gui uses +- the value converter, find/replace (with the gui's find scope) and the + OpenCore/Clover insert-from-template presets are ports of the gui's + behaviour, not new implementations -Progress lives in ROADMAP.md. It is a checklist, not a plan: one item -per session, done end to end or not done. +Neither propertreecli.py nor plist_tui.py imports the gui: the only +shared code is Scripts/plist.py. + +## Adding this to an existing ProperTree checkout + +Everything here is additive, so the two can live in one checkout. No +file that ships with ProperTree is modified: + +| added | what it is | +| --- | --- | +| propertreecli.py | the `plist` command, the one-shots, the plist io | +| plist_tui.py | the curses editor | +| install.sh | symlinks `plist` into ~/.local/bin | +| tests/ | pty smoke tests, plus the sample plist | + +Copy those in next to ProperTree.py and Scripts/, and `python3 +propertreecli.py --version` works with no install step. `./install.sh` +puts `plist` on PATH. python 3 only, no dependencies, no build, same +BSD 3-Clause licence as upstream. ## Tests @@ -140,7 +155,7 @@ framework, no deps, exit code says pass or fail. python3 ProperTree.py [file.plist] needs python 3 with tkinter (apt install python3-tk on debian/ubuntu). -That dependency goes away with the gui. +The terminal side does not: it needs neither tkinter nor the gui. ## Credit diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 38a280ab..00000000 --- a/ROADMAP.md +++ /dev/null @@ -1,407 +0,0 @@ -# ROADMAP - master task file - -> purpose: turn this ProperTree fork into a terminal plist editor. one item per -> session, finish it end to end (code + test + docs touch), then check it off. -> format: `[ ]` = open, `[x] = done, with the date noted under it. -> priority: (bold) = do next, (plain) = queue, (dim) = later / maybe never. - -The repo is corpnewt/ProperTree (tkinter gui, BSD-3). The gui code still -works and stays until the terminal editor covers it. Everything below -reuses the parts worth keeping: Scripts/plist.py (xml + binary io), the -snapshot logic in plistwindow.py, the converter, the settings model. - -## 0 - shape the project (do first, in order) - -- [x] **0.1 pick the ui engine** - spike both, half a day each: - 1. stdlib curses, zero deps, full control of the W0lfSword look - 2. textual (pip dep, widgets, mouse for free) - default is curses: ProperTree and W0lfSword are both zero-dep, and - the aesthetic is hand-rolled ansi anyway. if the tree gets laggy - with big plists or mouse support becomes a must-have, textual wins. - record the decision here. - _Done 2026-09-03: curses chosen. capability probe under a script(1) pty - (TERM=xterm-256color): 256 colors, 65536 pairs, palette indexes 117/153/240 - init clean on default bg, unicode box glyphs + status glyphs render. note: - this box has no controlling tty so direct pty runs fail on cbreak - script(1) - is the test vehicle, same trick 5.2 will use. split: one-shot commands print - plain ansi (this preview), the interactive editor gets curses. textual stays - unspiked until big-plist perf or mouse support is a real need (the 0.1 - criteria), zero-dep ethos wins for now._ -- [x] **0.2 layout** - decide where the cli code lives. option A: new - propertreecli/ package next to Scripts/. option B: root-level - propertreecli.py single file, mirroring ProperTree.py. default is - B for the first cut (one file, easy to move later), then split - when it passes ~1500 lines. - _Done 2026-09-03: option B - root-level propertreecli.py (~380 lines), - sections marked (palette / drawing / logo / glyphs / io / value fmt / - tree render / cli) so the split is mechanical when it outgrows._ -- [x] **0.3 entry point** - `propertreecli` runs from any directory: - install.sh symlinks a wrapper into ~/.local/bin that resolves its - real path and execs python3 on the script. wrapper must survive - being moved, so resolve symlinks with readlink -f, not $0. - handle: no args = open ./config.plist if it exists else error - message listing usage; file args = open those. - _Done 2026-09-03: symlink install (install.sh -> ~/.local/bin, verified - from /tmp over PATH), sys.path resolves the repo root through the link so - Scripts/ imports from anywhere. no args opens ./config.plist, else usage + - red error, exit 1. multiple files, per-file exit code, broken pipe handled - (head). since the editor did not exist yet, "open" = read-only styled tree - preview via Scripts/plist.py load (xml + binary, auto-detected) - this is - also the draw-layer scaffolding 2.1/2.2 build on. plain output when piped, - for scripting. write path is still section 1. - _Updated 2026-09-03 (v0.2.0): command renamed to `plist` (install.sh links - plist + keeps propertreecli as an alias; the script file keeps the repo - name so it can never shadow Scripts/plist.py). one file on a terminal now - opens the curses editor; piped output still prints the tree; `plist edit - ` forces the editor._ -- [x] **0.4 palette + logo module** - single source of truth for the - look, copied from W0lfSword's palette block: - C_FROST 38;5;117 (accents, key column) - C_DIM 38;5;240 (descriptions, hints, separators) - C_GRN 0;32 (saved / ok) - C_AMB 1;33 (warnings, changed type) - C_RED 0;31 (errors, delete) - B / NC bold + reset - plus a tree logo in the brand color (W0lfSword uses 38;5;153 for - its wolf - pick one accent for the tree art, keep C_FROST for - text). respect NO_COLOR and a --no-color flag, and disable - automatically when stdout is not a tty. - _Done 2026-09-03: palette block at the top of propertreecli.py, constants - flip to empty strings on --no-color / NO_COLOR / non-tty stdout (verified - piped output is ansi-free). C_BRAND 38;5;153 reserved for the tree art, - C_FROST 38;5;117 for keys/accents. logo is a plist tree built from column - math (root tag branching into dict/array/string) so branches always line - up, drawn above the banner on tty runs. boxed banner + dim rule + the - W0lfSword glyph helpers (ok/err/warn/info/hint) live here too. two - palette-rule additions, both marked in code: array indexes render dim - (structural, not keys) and booleans read as status (True grn, False dim), - which reads well on config.plist._ - -## 1 - plist io (non-interactive first, testable without a tui) - -- [x] **1.1 read/write roundtrip** - load and save xml + binary plists - through Scripts/plist.py (load/dump, UID, data wrap). detect the - format from the bplist00 magic on read; keep the file's format on - save unless told otherwise. - _Done 2026-09-03: write_plist() in propertreecli.py - dumps through - Scripts/plist.py with sort_keys=False, re-parses the temp file to verify, - then os.replace()s it in. a broken dump never touches the original. format - kept per file unless convert says otherwise._ -- [x] **1.2 ordering + types** - preserve key order in dicts (the gui - already does OrderedDict; plist.py's dict_type param exists for - this). full type set: string, number (int with 0x hex input, real), - bool, date, data, uid. no silent type loss on write. - _Done 2026-09-03: load keeps file order, dumps keep it (sort_keys=False); - set appends new keys, never reorders. all plist types settable: -i parses - 0x hex, -f real, -b bool, -x data, -d iso date, -u uid. uid refuses xml - writes with a clear error instead of silently corrupting (checked both on - the flag and on the whole tree via _has_uid). set refuses to clobber a - dict/array with a scalar. bool/int ordering handled (bool checked first)._ -- [x] **1.3 scriptable one-shots** - `propertreecli get/set/del - ` for shell use, with a --json mode on get (W0lfSword has - the same --json split). keypath = dot separated, n for array - indexes. this is what makes the tool useful in scripts, not just - interactive. - _Done 2026-09-03: get/set/del live. keypaths are dot separated with - backslash escaping for literal dots (com\\.apple\\.x), numeric segments - index arrays. set auto-creates missing dicts along the path and appends at - array index == len; -b with no value flips an existing boolean. get prints - strings raw, bools lowercase, data as continuous hex (xxd -r -p friendly), - dates iso; containers tree out or json with --json. --json and --no-color - pre-scan from anywhere in argv, W0lfSword style. exit codes: 0 ok, 1 file - or value error, 2 bad keypath. dash-prefixed values need -- (documented in - -h). 42-check functional suite in /tmp/propertreecli_tests.py (repo tests - come in 5.2)._ -- [x] **1.4 convert** - `propertreecli convert --to xml|binary` - in place or to a second file. port of the gui's change_plist_type. - _Done 2026-09-03: convert -t xml|binary, -o out for a second file, atomic - verified write. binary->xml with uid values refused up front (xml cannot - hold them) - the one place a conversion can genuinely lose data._ - -- [x] **1.5 create plist files** - `plist new ` makes an empty plist - from scratch and drops you into the editor. creating plists on linux - is a non-issue - a plist is just a file format, and the binary writer - already runs here (the convert tests round-trip it daily). - _Done 2026-09-03: plist new [--binary] [--root dict|array], xml by - default (config format honored), refuses to overwrite, opens the editor - on a tty. verified: xml + binary + array root + overwrite guard._ - -- [x] **1.6 sample file + friendlier errors** - test.plist in the repo - root so anyone can try the editor without hunting for a plist, - and nicer failure messages throughout the cli. - _Done 2026-09-03: test.plist (xml, EFI-flavored sample: nested dicts, - arrays of dicts, data blob, bools, a date - 18 keys). missing files now - say "create it with: plist new ", directories and non-plists get - clear errors with a hint, running bare with no config.plist suggests - plist test.plist / plist new / plist help, and `plist help` works as an - alias for -h. hints go to stderr so --json and piped output stay clean._ -- [x] **1.7 diff** - `plist diff ` walks both trees and - prints what differs as keypaths: + added, - removed, ~ changed, - arrays by index and dicts by key, recursion stopping at the - first difference so a whole subtree reads as one line. exit 0 - identical, 1 differences, 2 file error - scriptable, which is - the whole point of the one-shots. - _Done 2026-09-03: cmd_diff in propertreecli.py, wired into COMMANDS + - help. output sorted by keypath, containers summarized as / , summary line at the end. test.plist-vs-edited - copies covered in the /tmp suite._ - -## 2 - the editor (the main event) - -- [x] **2.1 screen** - full-screen ansi renderer: alt screen on entry, - cursor hidden while drawing, terminal state restored on exit - (trap INT/TERM like W0lfSword's cleanup). handle SIGWINCH resize. - layout, top to bottom: - boxed banner (file name, format, dirty marker) - tree: key / type / value columns - status line (mode hints) - footer (keybind legend, dim) - _Done 2026-09-03: curses app in plist_tui.py (new module, loaded lazily so - one-shots stay untouched). header row: file frost-bold + [xml/binary] + - amber * when dirty + right-hand hints; dim rule under it; tree body; - status row; dim footer legend. alt screen + full restore via - curses.wrapper, cursor hidden outside prompts, KEY_RESIZE handled. - gotchas hit: curses cbreak keeps IXON on, so ctrl+s was eaten as XOFF - (termios now clears IXON+ISIG, staying in cbreak so keypad escape - parsing keeps working - curses.raw() breaks arrow-key mapping); keypad - sequences are application-mode (\\EOB), which tripped up the pty tests, - not real terminals._ - _Updated 2026-09-03: folding was broken in the first cut - collapse only - flipped the glyph because the visible row list was built once and children - never left it. toggling now rebuilds the visible rows and keeps the - selection on the toggled container (pty scenario C proves a folded Nest no - longer intercepts j). initial expansion is config driven, see 2.10._ - _Updated 2026-09-03: keys are shaded by depth - top-level rows keep the - plain frost blue they always had, nested keys desaturate in a straight - line from frost toward gray (stock 256 indices 110 steel, 103 gray-blue, - 102 gray, then flat - red channel fixed, green and blue walk down - together so the hue never detours). two earlier attempts used custom - palette entries via init_color; that got reverted twice - first the - 0..255 rgb values were fed to curses raw (it wants 0..1000, everything - rendered ~4x too dark), then palette redefinition turned out - terminal-dependent and entries 16-21 rendered as black on terminals that - ignore it, which looked like missing text. stock colors only now, same - on every terminal._ -- [x] **2.2 node model + draw** - in-memory tree of nodes over the - plist, one node per key (dict) or index (array). collapse/expand - dicts and arrays, indentation by depth, long values truncated to - the column width. scroll when the tree outgrows the screen. - current node always visible. - _Done 2026-09-03: flat visible-row model rebuilt per frame from the live - root + an expanded{} map keyed by path tuple; rows carry the live node so - edits reflect instantly. containers default-expanded unless the file has - >1200 nodes (then only depth <= 1 opens). ▸/▾ glyphs on containers, dim - array indexes, column-aligned key/type/value, values truncated with …, - bools colored (True grn / False dim) like the preview. selection stays on - screen (scroll window follows it). huge files are the remaining question, - flagged in 0.1._ -- [x] **2.3 keybinds** - vi-style + arrows: j/k or up/down move, h/l or - left/right collapse/expand and enter/leave nodes, enter edits the - value, tab switches key/type/value focus, insert adds, delete - removes (with confirm when the subtree is non-empty), ctrl+c quits - with a save prompt when dirty, ? opens a help overlay listing - every bind. ? keybinds documented in the footer hint. - _Done 2026-09-03: arrows + j/k, g/G, pgup/pgdn; left/right/space fold, - left on a folded container jumps to its parent row; enter edits values / - toggles containers and booleans; i adds (type picker menu, then key name; - into a container or after the current sibling); d deletes with a confirm - dialog; r renames dict keys; t changes scalar type; < > reorder; c/x/p - copy/cut/paste; u / ctrl+r undo/redo (ctrl+z also undoes); / find with - n/N; R replace-all in string values; ctrl+s/F2 save; q/ctrl+c quit with a - save dialog when dirty (defaults to yes); ? overlay lists everything. - delete confirms even leaves (roadmap said containers only - safer, one - keystroke more). tab focus model dropped: single editable value per row - with an inline prompt, type changes via t. - _Updated 2026-09-03: home/end top/bottom, ctrl+d / ctrl+u half a page, - { } jump prev/next sibling, D duplicates in place (key gets a "copy" - suffix, auto-increments on collision; array slots insert after)._ -- [x] **2.4 edit operations** - port from plistwindow.py's command set: - add key (type picker: key/boolean/data/date/number/uid/string), - add child vs add sibling, delete, duplicate, rename key, reorder - (W0lfSword menu_opt layout for the pickers), change a node's type. - _Done 2026-09-03: add (9-type picker incl. dict/array, inserted after the - anchor sibling or appended into a container, duplicate key refused), delete - (whole subtree, dict rebuild or list pop), rename key (order-preserving - dict rebuild), reorder < > (dict swap or list swap), change type (scalar - conversions only, containers refused, uid gated to binary files). duplicate - covered by c+x+p (copy to the /tmp clipboard, paste under a new key) - no - separate D key._ -- [x] **2.5 value editors** - per-type input with validation: - ints accept decimal and 0x hex, bools cycle the gui's styles - (True/False, YES/NO, On/Off, 1/0) - configurable default, data - entered as hex or base64, dates parsed from iso. bad input = error - flash, value unchanged, cursor back in the field. - _Done 2026-09-03: one inline prompt per type, prefilled with the current - value (caret-visible windowing, ctrl+u clears, esc cancels, left/right/ - home/end). commit parses through propertreecli.parse_value so editor and - one-shots agree: ints decimal + 0x, real, data hex (spaces ok), iso dates, - uid ints. booleans toggle on enter (style cycling needs the 4.1 config, - deferred with the bool_style key). bad input flashes red, value untouched. - unchanged input reports "unchanged" without a dirty marker._ -- [x] **2.6 undo/redo** - full stack, 200 steps (the gui's max_undo). - snapshots of the whole tree per op is fine at this size. ctrl+z / - ctrl+y, counter on the status line. - _Done 2026-09-03: deepcopy snapshots of the root per mutation, capped at - 200, redo cleared on new edits. u / ctrl+z undo, ctrl+r / ctrl+y redo, - dirty flag set by every mutation (including the push itself, so nothing - forgets). no step counter on the status line yet - the stack depth is not - a useful number on screen, cut it._ -- [x] **2.7 clipboard** - cut/copy/paste nodes between files (or - instances) and within one. internal json clipboard file under - XDG_RUNTIME_DIR or /tmp so a second propertreecli instance can - paste into the first. - _Done 2026-09-03: c/x/p work in-file and across instances - the clipboard - is an xml plist file at $XDG_RUNTIME_DIR/plist-clipboard.plist (fallback - /tmp) holding {"v": }, so fidelity is exact (plist in, plist - out; a json file would mangle data/date/uid). paste into a container - appends, after a row inserts a sibling, dict parents prompt for a key and - refuse collisions. cut = copy + delete with a confirm when it is a - subtree._ -- [x] **2.8 search** - / opens a search line, n/N next/prev, highlight - all matches in the tree. replace mode with the type filter the - fork's recent commits added (find type defaults as a setting). - search keys, values, or both. - _Done 2026-09-03: / find + n/N cycle + amber match rows shipped, and R - replaces the query inside string values (keys are left alone on - purpose). scope picker closed the item: tab inside the find prompt - cycles keys / values / both, the pick sticks for n/N and R, and the - default comes from config find_scope (4.1). matches are substring over - canonical value text; container rows match on their key only (the first - cut matched every container row for any query - fixed). R refuses on a - keys-scoped find instead of silently replacing value text. a second - latent bug surfaced when scenario F finally drove / end to end: _goto - was called by find but never defined, so every find crashed with an - AttributeError caught by the run loop - find has now been exercised - under a pty (scenario F) and works. deviation from the gui, noted: the - tk dropdown filters by plist type (key/boolean/data/date/number/uid/ - string) - a terminal finder searches value text across types instead, - which is a superset for substring use._ -- [x] **2.9 converter + templates** - the gui's ascii/base64/decimal/ - hex/binary converter as a keybind on a selected value (parity with - the tk converter window), and insert-from-template using - config_tex_info.py + menu.plist (OpenCore/Clover samples). - _Done 2026-09-03: both halves live in the editor. ^t (ctrl+t, the same - accelerator the gui uses for its Convert Window) opens the converter - on the selected string/data/integer value: pick a from/to encoding - (ascii, base64, decimal, hex, binary), the source text is prefilled - with the value, conversion math mirrors convert_values() in - ProperTree.py (0x and <> tolerated in hex, odd nibbles padded, - base64 padding repaired, hex out grouped in 8s). enter on the result - writes the source back through the from-encoding as the node's own - kind: pasting foreign base64 into a data field then entering stores - the decoded bytes in one trip. encodings that cannot round-trip into - a kind (decimal/binary -> data) refuse with a "view only" flash. - T offers insert-from-template: section (OpenCore/Clover) -> path - (ACPI/Add, Kernel/Add, ...) -> preset (New Blank Entry, kext packs, - Drop DMAR...). presets know their own destination path + container - types (d = dict, a = array), so the walk creates missing containers - and appends into arrays / merges into dicts (confirm first when a - segment exists with the wrong type or a key would be clobbered), the - same merge_menu_preset semantics minus tk bookkeeping. the terminal - editor has no pointer, so the whole preset list is offered and the - preset's own path decides where it lands (tk filters by the - right-clicked node - noted deviation). data source is the repo's - Scripts/menu.plist, loaded lazily once. both keybinds are in the ? - overlay. pty scenario G covers: hex data -> pasted base64 -> stored - bytes, and two Kernel/Add blank inserts into a fresh file (first run - creates the path, second appends)._ -- [x] **2.10 editor view config** - a small config file decides how the - editor opens files: everything expanded, everything collapsed, or - the auto heuristic. plain key=value with # comments, template - written on first run (W0lfSword config_schema trick). - _Done 2026-09-03: ~/.config/propertreecli/config (XDG_CONFIG_HOME aware) - with expand_mode = all | auto | none and format = xml | binary. all opens - every container, none opens every container folded, auto keeps the old - heuristic (expand unless the file exceeds ~1200 nodes, then only depth 1). - created on first editor run with a comment template; load_config validates - values and falls back to defaults. format is what plist new writes (1.5). - still to come from 4.1: data/int/bool display styles, find type defaults, - and a settings command to show/set values from the cli._ - -- [x] **2.11 movement polish** - holding j/k or the arrow keys - accelerates smoothly: the step size drifts 1 -> 2 -> 3 -> 4 over - a few seconds of holding, via a fractional accumulator fed by a - slow ramp. no tiers, no half-page teleports. - _Done 2026-09-03: streak-tiers (1/2/4/half-page) were the first cut; - user feedback: jerky. reworked to ramp += 0.03 per repeat (cap 2.2) and - step = the integer part of an accumulating 1.0 + ramp - so steps are - mostly 1 for the first ~0.5s of a hold, then drift through a smooth - 1/2/3 mix to at most 4. a 240-repeat pty run crosses 500 rows around - event 180, and the step sequence reads 1111111211121212... (verified in - scenario E, which still passes). note: a cmatrix-style boot scramble was - added and then removed the same session on user feedback (did not look - good) - the editor now opens straight to the tree._ - -## 3 - snapshot mode (headless, flagship feature) - -- [ ] **3.1 oc snapshot / oc clean snapshot** - port of plistwindow.py's - oc_snapshot(clean=...): walk ACPI/Kexts/Tools/Drivers, add/remove - entries, order kexts by OSBundleLibraries vs CFBundleIdentifier so - dependencies load first, warn on duplicate CFBundleIdentifiers - with MinKernel/MaxKernel/MatchKernel overlap checks, flag disabled - parent kexts with enabled children. -- [ ] **3.2 schema detection** - md5 of OpenCore.efi matched against - known hashes, falling back to the newest schema in - Scripts/snapshot.plist. target version selectable in config, same - as the gui's OC Snapshot Target Version menu. -- [ ] **3.3 dry-run mode** - `snapshot --dry-run` prints the diff - (added/removed/reordered) instead of writing. exit 0 = clean, 1 = - changes needed, 2 = error. this makes it usable in ci and in - build scripts, which the gui never could be. - -## 4 - config + settings - -- [x] **4.1 config file** - ~/.config/propertreecli/config, plain - key=value with # comments, template written on first run. schema - doubles as the docs, same trick as W0lfSword's config_schema(). - keys: expand_mode (all/auto/none), format (xml/binary), find_scope - (keys/values/both), theme (frost/red). validation lives in - load_config; unknown or invalid values fall back to defaults - instead of erroring. - _Done 2026-09-03: the file itself landed with 2.10; the schema is now - CONFIG_DEFAULTS + CONFIG_VALID in propertreecli.py, load_config - validates every key against CONFIG_VALID, and `plist settings` (4.2) - manages it from the cli. theme = red (added same day, user request) - recolors the whole ui: the curses editor swaps its palette maps for - red variants (accents 196, dim 124, depth shades 160/124/88) and the - ansi one-shots swap their escapes when the config says so - every - text color goes red, hints stay muted. keys that were planned and got - cut, on purpose: animations on/off (the animation feature was removed - from the product), prompt_symbol and logo/color toggles (the look is - fixed, color already bows to --no-color / NO_COLOR / non-tty), - data/int/bool display styles (the editor renders values readably - as-is; revisit only if someone asks)._ -- [x] **4.2 settings command** - `plist settings` shows the file, - `settings set key value` validates and writes, `settings reset` - restores defaults. no menu for it in the tui yet; editing by hand - is fine. - _Done 2026-09-03: settings show / set / reset live in cmd_settings(), - wired into COMMANDS + help. set validates against CONFIG_VALID before - writing, updates the config in place (comments and other keys - preserved), reset rewrites the template. config file auto-created on - first use, same as the editor._ -- [ ] **4.3 version + update check** - single VERSION source in the - script (W0lfSword style), --version prints it, update_check.py - pattern reused against this repo's own version feed. - -## 5 - finish - -- [ ] **5.1 kill the gui** - drop ProperTree.py, plistwindow.py, the .bat - and .command launchers, tkinter fallbacks in plist.py, py2 - shims. only once the editor covers the snapshot + converter - features. keep Scripts/plist.py's io, minus the py2 branches. -- [ ] **5.2 tests** - plist roundtrips over a corpus (xml, binary, data, - uid, dates, 0x ints, deep nesting), snapshot dry-run against a - sample efi folder, keypath get/set/del, tui smoke test driving - keys through a pty. -- [ ] **5.3 release** - README demo (asciinema), screenshots in the - readme, v0.1.0 tag, install.sh verified from a fresh clone on a - bare debian box. - -## notes - -- snapshot.plist + version.json are upstream's; version.json will - become our own feed once 5.3 ships. -- the tree logo: draw it early (0.4) and let it set the tone, the same - way the wolf does for W0lfSword. a plist is a tree - the logo should - say that. diff --git a/plist_tui.py b/plist_tui.py index 119ca284..b05b2a63 100644 --- a/plist_tui.py +++ b/plist_tui.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 -# plist_tui - the curses editor behind `plist ` (roadmap section 2) +# plist_tui - the curses editor behind `plist ` +# new code in the ProperTree tree, BSD-3 like the rest (see LICENSE). # imports helpers from propertreecli lazily, so the module only loads -# when an interactive session actually starts. +# when an editor actually starts. # # layout, top to bottom: # header: file (frost bold) format dirty marker hints (dim) @@ -158,7 +159,7 @@ def _parse(text, kind): from propertreecli import parse_value return parse_value(text, kind) -# ── converter (roadmap 2.9) ───────────────────────────────── +# ── converter (ctrl+t) ──────────────────────────────────────── # the tk gui's Convert Window (Ctrl+T) is a scratchpad that # renders text as bytes in one encoding and back out in another. # same five views here: ascii, base64, decimal, hex, binary. @@ -261,7 +262,7 @@ def _convert_apply(kind, text, frm): raise ValueError("not text in that base64 - view only") raise ValueError("decimal/binary cannot rebuild a string - view only") -# ── insert-from-template (roadmap 2.9) ─────────────────────── +# ── insert from template (T) ────────────────────────────────── # the tk gui right-clicks a node and offers presets from # Scripts/menu.plist: each preset knows its destination path # (Root/ACPI/Add, Root/Kernel/Add, ...), the container types diff --git a/propertreecli.py b/propertreecli.py index 3508642c..3398481e 100755 --- a/propertreecli.py +++ b/propertreecli.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 # propertreecli - a plist editor for the terminal -# fork of corpnewt/ProperTree (BSD-3), look stolen from W0lfSword +# new code in corpnewt's ProperTree tree, BSD-3 like the rest (see +# LICENSE). the terminal look is styled after W0lfSword, a toolkit of +# mine. # -# single source of truth for the version - keep this in sync with the -# update feed when 4.3 lands. the command is `plist`; the file keeps the -# repo name so it can never shadow Scripts/plist.py on import. +# VERSION is the single source of truth. the command is `plist`; the +# file keeps the repo name so it can never shadow Scripts/plist.py on +# import. VERSION = "0.5.0" import argparse @@ -1027,7 +1029,7 @@ def main(argv=None): if os.path.exists("config.plist"): return _start_editor("config.plist") if _tty() else open_and_show("config.plist") err("nothing to open: no config.plist in this directory") - hint_err("try: plist test.plist (open a plist)") + hint_err("try: plist (open a plist)") hint_err(" plist new my.plist (start from scratch)") hint_err(" plist help (everything)") return 1 diff --git a/test.plist b/tests/sample.plist similarity index 100% rename from test.plist rename to tests/sample.plist