updates 38.1-164
This commit is contained in:
@@ -63,7 +63,10 @@ Conventions it encodes, so they stop being re-decided:
|
|||||||
--name/-n what to call it --force/-f write anyway
|
--name/-n what to call it --force/-f write anyway
|
||||||
--dry-run print, do not do
|
--dry-run print, do not do
|
||||||
|
|
||||||
Progress goes to stdout as plain print(). Errors go to stderr prefixed
|
Progress goes through say(), NOT print(). say() writes to stdout normally and
|
||||||
|
to stderr when --output is "-", because that is when stdout carries the
|
||||||
|
artifact and a stray progress line is a JSON syntax error one pipe stage
|
||||||
|
later. See common/entity.py. Errors go to stderr prefixed
|
||||||
'Error: ' and exit 1 — never a traceback, which tells a user nothing they
|
'Error: ' and exit 1 — never a traceback, which tells a user nothing they
|
||||||
can act on. `fail()` is the only exit path for an *error*; a command that
|
can act on. `fail()` is the only exit path for an *error*; a command that
|
||||||
ends badly but not wrongly (tests failed, drift found) returns an int and
|
ends badly but not wrongly (tests failed, drift found) returns an int and
|
||||||
@@ -96,6 +99,27 @@ COMMON = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Set by Tool.run when the command's --output is "-", i.e. stdout is carrying
|
||||||
|
# the data. Process-wide because a process runs exactly one command; `say` has
|
||||||
|
# to be callable from a cmd_* that was handed `args` and never saw the Tool.
|
||||||
|
_DATA_ON_STDOUT = False
|
||||||
|
|
||||||
|
|
||||||
|
def say(*args, **kwargs):
|
||||||
|
"""Progress, printed where it cannot corrupt the data channel.
|
||||||
|
|
||||||
|
`print()` to stdout is right until the day stdout carries the artifact, and
|
||||||
|
then it silently produces invalid JSON halfway down a pipe. Tools use this
|
||||||
|
instead and stop having to think about it.
|
||||||
|
"""
|
||||||
|
kwargs.setdefault("file", sys.stderr if _DATA_ON_STDOUT else sys.stdout)
|
||||||
|
print(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def data_on_stdout():
|
||||||
|
return _DATA_ON_STDOUT
|
||||||
|
|
||||||
|
|
||||||
def fail(message, hint=None):
|
def fail(message, hint=None):
|
||||||
"""The only way out on error: a line a user can act on, never a traceback."""
|
"""The only way out on error: a line a user can act on, never a traceback."""
|
||||||
print(f"Error: {message}", file=sys.stderr)
|
print(f"Error: {message}", file=sys.stderr)
|
||||||
@@ -296,6 +320,8 @@ class Tool:
|
|||||||
command does, exits 0.
|
command does, exits 0.
|
||||||
"""
|
"""
|
||||||
args = self.parser.parse_args(argv)
|
args = self.parser.parse_args(argv)
|
||||||
|
global _DATA_ON_STDOUT
|
||||||
|
_DATA_ON_STDOUT = str(getattr(args, "output", "") or "") == "-"
|
||||||
try:
|
try:
|
||||||
result = args.func(args)
|
result = args.func(args)
|
||||||
if isinstance(result, int) and result != 0:
|
if isinstance(result, int) and result != 0:
|
||||||
|
|||||||
157
soleprint/station/tools/common/entity.py
Normal file
157
soleprint/station/tools/common/entity.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"""
|
||||||
|
One way to hand a thing from one station tool to the next.
|
||||||
|
|
||||||
|
Four tools already have this seam and each invented it privately:
|
||||||
|
|
||||||
|
histgen plan.json "the expensive half and the mechanical half, split"
|
||||||
|
modelgen {models, relationships, source} a published contract
|
||||||
|
shuntgen reads that contract
|
||||||
|
keymatrix observed/<endpoint>.json
|
||||||
|
|
||||||
|
Same idea, four spellings. This is the shared one.
|
||||||
|
|
||||||
|
## The opinion baked in: each standalone part makes sense by itself
|
||||||
|
|
||||||
|
This is deliberately NOT a pipeline runner, a DAG or a scheduler. The pipe is
|
||||||
|
`|`. All this provides is what makes `|` safe -- a declared kind, a checked
|
||||||
|
seam, and progress kept off the data channel.
|
||||||
|
|
||||||
|
And every tool keeps working with plain file paths and no pipes at all. That is
|
||||||
|
the point rather than a concession: histgen runs in a repo that has never heard
|
||||||
|
of soleprint, modelgen ships as a pip package, rig's whole contract is
|
||||||
|
`rm -rf rig/`. A tool that only works as part of a pipeline has lost the
|
||||||
|
property this convention exists to protect.
|
||||||
|
|
||||||
|
## The envelope
|
||||||
|
|
||||||
|
{
|
||||||
|
"spr": 1,
|
||||||
|
"kind": "keymatrix.observed",
|
||||||
|
"schema": 1,
|
||||||
|
"produced_by": "keymatrix 0.1.0",
|
||||||
|
"produced_at": "2026-09-14T08:21:02+00:00",
|
||||||
|
"data": { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
`kind` is **declared, never inferred**. A tool that guesses what it was handed
|
||||||
|
fails three steps down the pipe with something confusing, instead of at the seam
|
||||||
|
with something obvious.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
from ..common.entity import read_entity, write_entity # or `from .entity`
|
||||||
|
|
||||||
|
doc = read_entity(args.source, expect_kind="keymatrix.observed")
|
||||||
|
write_entity(args.output, "keymatrix.findings", 1, findings)
|
||||||
|
|
||||||
|
`-` means stdin or stdout in the usual way. A bare payload -- a file that is not
|
||||||
|
an envelope -- is accepted, because `observed/*.json` and `plan.json` already
|
||||||
|
exist in that form and `plan.json` is a seam a *human* edits between `brief` and
|
||||||
|
`export`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .cli import fail
|
||||||
|
|
||||||
|
ENVELOPE_VERSION = 1
|
||||||
|
|
||||||
|
# The entities that exist as artefacts today, named after what writes them.
|
||||||
|
# Declaring a kind here is free; changing the shape behind it is not -- see
|
||||||
|
# modelgen.ir below.
|
||||||
|
KINDS = {
|
||||||
|
"keymatrix.observed": "One endpoint's live keyboard state, as probed.",
|
||||||
|
"keymatrix.findings": "What `check` concluded from observed state.",
|
||||||
|
"histgen.plan": "Commits, ordered and cut. Edited by hand between brief and export.",
|
||||||
|
"modelgen.ir": "{models, relationships, source} -- a PUBLISHED contract. "
|
||||||
|
"modelgen, datagen, shuntgen and cfg/amar all emit or consume it, "
|
||||||
|
"and modelgen/tests/test_extractors.py:240,394 assert it. "
|
||||||
|
"Declared here; NOT to be reshaped from here.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return (datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
.replace(microsecond=0).isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
def is_envelope(obj):
|
||||||
|
return isinstance(obj, dict) and "spr" in obj and "kind" in obj
|
||||||
|
|
||||||
|
|
||||||
|
def wrap(kind, schema, data, produced_by=None):
|
||||||
|
"""Build an envelope without writing it, for callers that hold their own IO."""
|
||||||
|
if kind not in KINDS:
|
||||||
|
fail(f"unknown entity kind: {kind!r}",
|
||||||
|
f"Known: {', '.join(sorted(KINDS))}. Add it to entity.KINDS first.")
|
||||||
|
return {
|
||||||
|
"spr": ENVELOPE_VERSION,
|
||||||
|
"kind": kind,
|
||||||
|
"schema": schema,
|
||||||
|
"produced_by": produced_by,
|
||||||
|
"produced_at": _now(),
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_entity(path, expect_kind=None, what="input"):
|
||||||
|
"""The payload at `path`. `-` reads stdin.
|
||||||
|
|
||||||
|
Returns the `data` when handed an envelope, and the whole object when handed
|
||||||
|
a bare payload -- so a caller does not have to care which it got.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
text = sys.stdin.read() if str(path) == "-" else Path(path).read_text(encoding="utf-8")
|
||||||
|
except FileNotFoundError:
|
||||||
|
fail(f"{what} not found: {path}")
|
||||||
|
except OSError as exc:
|
||||||
|
fail(f"could not read {what} {path}: {exc}")
|
||||||
|
|
||||||
|
if not text.strip():
|
||||||
|
fail(f"{what} is empty: {'stdin' if str(path) == '-' else path}",
|
||||||
|
"Nothing was piped in." if str(path) == "-" else None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
obj = json.loads(text)
|
||||||
|
except ValueError as exc:
|
||||||
|
fail(f"{what} is not JSON: {exc}",
|
||||||
|
f"Read from {'stdin' if str(path) == '-' else path}.")
|
||||||
|
|
||||||
|
if not is_envelope(obj):
|
||||||
|
# A bare payload. Accepted on purpose: the files that predate this
|
||||||
|
# convention are still the ones on disk, and plan.json is edited by a
|
||||||
|
# human between two verbs.
|
||||||
|
if expect_kind:
|
||||||
|
return obj
|
||||||
|
return obj
|
||||||
|
|
||||||
|
if obj.get("spr") != ENVELOPE_VERSION:
|
||||||
|
fail(f"{what} uses envelope version {obj.get('spr')!r}, "
|
||||||
|
f"and this tool speaks {ENVELOPE_VERSION}")
|
||||||
|
|
||||||
|
if expect_kind and obj["kind"] != expect_kind:
|
||||||
|
# The whole reason `kind` is declared rather than sniffed.
|
||||||
|
fail(f"expected {expect_kind}, got {obj['kind']}",
|
||||||
|
f"That came from {obj.get('produced_by') or 'an unknown producer'}.")
|
||||||
|
|
||||||
|
return obj["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def write_entity(path, kind, schema, data, produced_by=None, indent=2):
|
||||||
|
"""Write an envelope. `-` writes stdout.
|
||||||
|
|
||||||
|
Returns the path written, or None for stdout, so a caller can report it
|
||||||
|
without re-deciding where it went.
|
||||||
|
"""
|
||||||
|
envelope = wrap(kind, schema, data, produced_by)
|
||||||
|
text = json.dumps(envelope, indent=indent, sort_keys=True) + "\n"
|
||||||
|
if str(path) == "-":
|
||||||
|
sys.stdout.write(text)
|
||||||
|
return None
|
||||||
|
target = Path(path)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text(text, encoding="utf-8")
|
||||||
|
return target
|
||||||
@@ -49,6 +49,27 @@ COMMON = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Set by Tool.run when the command's --output is "-", i.e. stdout is carrying
|
||||||
|
# the data. Process-wide because a process runs exactly one command; `say` has
|
||||||
|
# to be callable from a cmd_* that was handed `args` and never saw the Tool.
|
||||||
|
_DATA_ON_STDOUT = False
|
||||||
|
|
||||||
|
|
||||||
|
def say(*args, **kwargs):
|
||||||
|
"""Progress, printed where it cannot corrupt the data channel.
|
||||||
|
|
||||||
|
`print()` to stdout is right until the day stdout carries the artifact, and
|
||||||
|
then it silently produces invalid JSON halfway down a pipe. Tools use this
|
||||||
|
instead and stop having to think about it.
|
||||||
|
"""
|
||||||
|
kwargs.setdefault("file", sys.stderr if _DATA_ON_STDOUT else sys.stdout)
|
||||||
|
print(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def data_on_stdout():
|
||||||
|
return _DATA_ON_STDOUT
|
||||||
|
|
||||||
|
|
||||||
def fail(message, hint=None):
|
def fail(message, hint=None):
|
||||||
"""The only way out on error: a line a user can act on, never a traceback."""
|
"""The only way out on error: a line a user can act on, never a traceback."""
|
||||||
print(f"Error: {message}", file=sys.stderr)
|
print(f"Error: {message}", file=sys.stderr)
|
||||||
@@ -249,6 +270,8 @@ class Tool:
|
|||||||
command does, exits 0.
|
command does, exits 0.
|
||||||
"""
|
"""
|
||||||
args = self.parser.parse_args(argv)
|
args = self.parser.parse_args(argv)
|
||||||
|
global _DATA_ON_STDOUT
|
||||||
|
_DATA_ON_STDOUT = str(getattr(args, "output", "") or "") == "-"
|
||||||
try:
|
try:
|
||||||
result = args.func(args)
|
result = args.func(args)
|
||||||
if isinstance(result, int) and result != 0:
|
if isinstance(result, int) and result != 0:
|
||||||
|
|||||||
@@ -12,32 +12,41 @@ folder out and use its Makefile.
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .cli import Tool, fail
|
from . import __version__
|
||||||
|
from .cli import Tool, fail, say
|
||||||
|
from .entity import read_entity, write_entity
|
||||||
|
|
||||||
|
|
||||||
def _load_observed(args):
|
def _load_observed(args):
|
||||||
"""Every observed/<endpoint>.json under --source, keyed by endpoint."""
|
"""The same documents, indexed chord -> binding, which is what shadow wants."""
|
||||||
root = Path(args.source).expanduser() if args.source else Path("observed")
|
|
||||||
if root.name != "observed":
|
|
||||||
root = root / "observed"
|
|
||||||
if not root.is_dir():
|
|
||||||
fail(f"no observed state at {root}",
|
|
||||||
"Run `keymatrix probe --output <dir>` first.")
|
|
||||||
out = {}
|
out = {}
|
||||||
for path in sorted(root.glob("*.json")):
|
for endpoint, doc in _load_docs(args).items():
|
||||||
doc = json.loads(path.read_text())
|
|
||||||
index = {}
|
index = {}
|
||||||
for b in doc.get("bindings", []):
|
for b in doc.get("bindings", []):
|
||||||
index.setdefault(b["chord"], b) # first declaration wins
|
index.setdefault(b["chord"], b) # first declaration wins
|
||||||
out[doc["endpoint"]] = index
|
out[endpoint] = index
|
||||||
if not out:
|
|
||||||
fail(f"{root} holds no observed state")
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _load_docs(args):
|
def _load_docs(args):
|
||||||
"""The full observed documents, which the checks need (not the chord index)."""
|
"""Observed documents by endpoint. Accepts a pipe, a file, or a directory.
|
||||||
root = Path(args.source).expanduser() if args.source else Path("observed")
|
|
||||||
|
All three exist for a reason: `-` is the pipe, a file is one endpoint, and a
|
||||||
|
directory is how the peripheral-matrix repo stores the fleet. A tool that
|
||||||
|
only took the pipe would stop being usable on its own.
|
||||||
|
"""
|
||||||
|
source = args.source
|
||||||
|
if str(source) == "-":
|
||||||
|
doc = read_entity("-", expect_kind="keymatrix.observed",
|
||||||
|
what="observed state")
|
||||||
|
return {doc["endpoint"]: doc}
|
||||||
|
|
||||||
|
root = Path(source).expanduser() if source else Path("observed")
|
||||||
|
if root.is_file():
|
||||||
|
doc = read_entity(root, expect_kind="keymatrix.observed",
|
||||||
|
what="observed state")
|
||||||
|
return {doc["endpoint"]: doc}
|
||||||
|
|
||||||
if root.name != "observed":
|
if root.name != "observed":
|
||||||
root = root / "observed"
|
root = root / "observed"
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
@@ -45,7 +54,8 @@ def _load_docs(args):
|
|||||||
"Run `keymatrix probe --output <dir>` first.")
|
"Run `keymatrix probe --output <dir>` first.")
|
||||||
docs = {}
|
docs = {}
|
||||||
for path in sorted(root.glob("*.json")):
|
for path in sorted(root.glob("*.json")):
|
||||||
doc = json.loads(path.read_text())
|
doc = read_entity(path, expect_kind="keymatrix.observed",
|
||||||
|
what=f"observed state ({path.name})")
|
||||||
docs[doc["endpoint"]] = doc
|
docs[doc["endpoint"]] = doc
|
||||||
if not docs:
|
if not docs:
|
||||||
fail(f"{root} holds no observed state")
|
fail(f"{root} holds no observed state")
|
||||||
@@ -57,22 +67,31 @@ def cmd_probe(args):
|
|||||||
from .endpoint import resolve_endpoint
|
from .endpoint import resolve_endpoint
|
||||||
|
|
||||||
name = args.name or resolve_endpoint()
|
name = args.name or resolve_endpoint()
|
||||||
doc, warnings = probe.run(name)
|
# --ahk-root makes the Windows endpoint probeable from anywhere the tree is
|
||||||
|
# readable, including an sshfs mount -- and equally probeable from the
|
||||||
|
# fixture when no mount exists. Neither is privileged.
|
||||||
|
kind = "ahk" if args.ahk_root else None
|
||||||
|
doc, warnings = probe.run(name, kind=kind, ahk_root=args.ahk_root)
|
||||||
|
|
||||||
for severity, message in warnings:
|
for severity, message in warnings:
|
||||||
print(f" {severity}: {message}")
|
say(f" {severity}: {message}")
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
print(json.dumps(doc, indent=2))
|
say(json.dumps(doc, indent=2))
|
||||||
return
|
return
|
||||||
|
|
||||||
root = Path(args.output).expanduser() if args.output else Path(".")
|
# "-" pipes one endpoint's state onward; a directory keeps the observed/
|
||||||
out = root / "observed"
|
# layout the peripheral-matrix repo holds.
|
||||||
out.mkdir(parents=True, exist_ok=True)
|
if str(args.output) == "-":
|
||||||
path = out / f"{name}.json"
|
target = "-"
|
||||||
path.write_text(json.dumps(doc, indent=2, sort_keys=True) + "\n")
|
else:
|
||||||
print(f"Wrote {path} ({len(doc['bindings'])} bindings, "
|
root = Path(args.output).expanduser() if args.output else Path(".")
|
||||||
f"{len(doc['unreadable'])} unreadable)")
|
target = root / "observed" / f"{name}.json"
|
||||||
|
|
||||||
|
written = write_entity(target, "keymatrix.observed", 1, doc,
|
||||||
|
produced_by=f"keymatrix {__version__}")
|
||||||
|
say(f"Wrote {written or 'stdout'} ({len(doc['bindings'])} bindings, "
|
||||||
|
f"{len(doc['unreadable'])} unreadable)")
|
||||||
|
|
||||||
|
|
||||||
def cmd_shadow(args):
|
def cmd_shadow(args):
|
||||||
@@ -84,26 +103,51 @@ def cmd_shadow(args):
|
|||||||
|
|
||||||
for name in postures:
|
for name in postures:
|
||||||
p = sh.POSTURES[name]
|
p = sh.POSTURES[name]
|
||||||
print(f"\nPosture {name} — {p['summary']}")
|
say(f"\nPosture {name} — {p['summary']}")
|
||||||
print(f" peripherals on {p['peripherals']}; "
|
say(f" peripherals on {p['peripherals']}; "
|
||||||
f"chain: {' -> '.join(h['near'] + '/' + h['transport'] for h in p['hops'])}")
|
f"chain: {' -> '.join(h['near'] + '/' + h['transport'] for h in p['hops'])}")
|
||||||
print()
|
say()
|
||||||
print(f" {'chord':<22}{'verdict':<20}{'eaten by'}")
|
states = sh.grab_states(sh.TRANSPORTS[p["hops"][0]["transport"]])
|
||||||
print(" " + "-" * 72)
|
head = "".join(f"{s or 'verdict':<20}" for s in states)
|
||||||
|
say(f" {'chord':<22}{head}{'eaten by'}")
|
||||||
|
say(" " + "-" * (24 + 20 * len(states) + 28))
|
||||||
for raw in args.chord or sorted({c for e in observed.values() for c in e}):
|
for raw in args.chord or sorted({c for e in observed.values() for c in e}):
|
||||||
rows = sh.walk(Chord.parse(raw), name, observed)
|
cells, eater = [], None
|
||||||
hop, v = rows[0]
|
for st in states:
|
||||||
print(f" {raw:<22}{v.outcome:<20}{v.eater or '—'}")
|
_, v = sh.walk(Chord.parse(raw), name, observed, state=st)[0]
|
||||||
|
cells.append(f"{v.outcome:<20}")
|
||||||
|
eater = eater or v.eater
|
||||||
|
say(f" {raw:<22}{''.join(cells)}{eater or '—'}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_freespace(args):
|
||||||
|
from .freespace import survey, format_rows
|
||||||
|
|
||||||
|
observed = _load_observed(args)
|
||||||
|
say(f"Chord families across: {', '.join(sorted(observed))}")
|
||||||
|
say("")
|
||||||
|
for line in format_rows(survey(observed), verbose=args.verbose):
|
||||||
|
say(line)
|
||||||
|
|
||||||
|
|
||||||
def cmd_check(args):
|
def cmd_check(args):
|
||||||
from .check import run_checks
|
from .check import run_checks
|
||||||
|
|
||||||
findings = run_checks(_load_docs(args))
|
findings = run_checks(_load_docs(args))
|
||||||
for f in findings:
|
|
||||||
print(f" {f['severity']:<6} {f['kind']:<22} {f['message']}")
|
|
||||||
errors = sum(f["severity"] == "error" for f in findings)
|
errors = sum(f["severity"] == "error" for f in findings)
|
||||||
print(f"\n{len(findings)} finding(s), {errors} at error severity")
|
|
||||||
|
if args.output:
|
||||||
|
write_entity(args.output, "keymatrix.findings", 1,
|
||||||
|
{"findings": findings,
|
||||||
|
"counts": {"total": len(findings), "errors": errors}},
|
||||||
|
produced_by=f"keymatrix {__version__}")
|
||||||
|
|
||||||
|
for f in findings:
|
||||||
|
say(f" {f['severity']:<6} {f['kind']:<22} {f['message']}")
|
||||||
|
say(f"\n{len(findings)} finding(s), {errors} at error severity")
|
||||||
|
|
||||||
|
# A finding is a true report, not a malfunction -- so this is an int return
|
||||||
|
# rather than fail(), and it does not get an "Error:" in front of it.
|
||||||
return 1 if errors else 0
|
return 1 if errors else 0
|
||||||
|
|
||||||
|
|
||||||
@@ -116,18 +160,34 @@ def main(argv=None):
|
|||||||
help="Directory holding observed/ (default: here)"),
|
help="Directory holding observed/ (default: here)"),
|
||||||
name=dict(default=None,
|
name=dict(default=None,
|
||||||
help="Endpoint name (default: resolved from the host)"))
|
help="Endpoint name (default: resolved from the host)"))
|
||||||
|
tool.argument("probe", "--ahk-root", default=None, metavar="DIR",
|
||||||
|
help="Probe an AutoHotkey tree at DIR instead of this host. "
|
||||||
|
"Use the fixture at keymatrix/examples/ahk when no "
|
||||||
|
"Windows tree is reachable.")
|
||||||
|
|
||||||
tool.command("shadow", cmd_shadow, "Which endpoint receives a chord, per posture")
|
tool.command("shadow", cmd_shadow, "Which endpoint receives a chord, per posture")
|
||||||
tool.common("shadow", "source",
|
tool.common("shadow", "source",
|
||||||
source=dict(default=None, help="Directory holding observed/"))
|
source=dict(default=None,
|
||||||
|
help='Directory, file, or "-" for a pipe'))
|
||||||
tool.argument("shadow", "--posture", choices=("A", "B"), default=None,
|
tool.argument("shadow", "--posture", choices=("A", "B"), default=None,
|
||||||
help="Only this posture (default: both)")
|
help="Only this posture (default: both)")
|
||||||
tool.argument("shadow", "--chord", action="append", default=None,
|
tool.argument("shadow", "--chord", action="append", default=None,
|
||||||
help="Only this chord. Repeatable.")
|
help="Only this chord. Repeatable.")
|
||||||
|
|
||||||
|
tool.command("freespace", cmd_freespace,
|
||||||
|
"Chord families nobody has claimed")
|
||||||
|
tool.common("freespace", "source",
|
||||||
|
source=dict(default=None,
|
||||||
|
help='Directory, file, or "-" for a pipe'))
|
||||||
|
tool.argument("freespace", "--verbose", "-v", action="store_true",
|
||||||
|
help="Show what took each partly-claimed family")
|
||||||
|
|
||||||
tool.command("check", cmd_check, "Drift, dead commands, collisions")
|
tool.command("check", cmd_check, "Drift, dead commands, collisions")
|
||||||
tool.common("check", "source",
|
tool.common("check", "source", "output",
|
||||||
source=dict(default=None, help="Directory holding observed/"))
|
source=dict(default=None,
|
||||||
|
help='Directory, file, or "-" for a pipe'),
|
||||||
|
output=dict(default=None,
|
||||||
|
help='Also write findings here; "-" for a pipe'))
|
||||||
|
|
||||||
tool.run(argv)
|
tool.run(argv)
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,27 @@ COMMON = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Set by Tool.run when the command's --output is "-", i.e. stdout is carrying
|
||||||
|
# the data. Process-wide because a process runs exactly one command; `say` has
|
||||||
|
# to be callable from a cmd_* that was handed `args` and never saw the Tool.
|
||||||
|
_DATA_ON_STDOUT = False
|
||||||
|
|
||||||
|
|
||||||
|
def say(*args, **kwargs):
|
||||||
|
"""Progress, printed where it cannot corrupt the data channel.
|
||||||
|
|
||||||
|
`print()` to stdout is right until the day stdout carries the artifact, and
|
||||||
|
then it silently produces invalid JSON halfway down a pipe. Tools use this
|
||||||
|
instead and stop having to think about it.
|
||||||
|
"""
|
||||||
|
kwargs.setdefault("file", sys.stderr if _DATA_ON_STDOUT else sys.stdout)
|
||||||
|
print(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def data_on_stdout():
|
||||||
|
return _DATA_ON_STDOUT
|
||||||
|
|
||||||
|
|
||||||
def fail(message, hint=None):
|
def fail(message, hint=None):
|
||||||
"""The only way out on error: a line a user can act on, never a traceback."""
|
"""The only way out on error: a line a user can act on, never a traceback."""
|
||||||
print(f"Error: {message}", file=sys.stderr)
|
print(f"Error: {message}", file=sys.stderr)
|
||||||
@@ -223,6 +244,8 @@ class Tool:
|
|||||||
command does, exits 0.
|
command does, exits 0.
|
||||||
"""
|
"""
|
||||||
args = self.parser.parse_args(argv)
|
args = self.parser.parse_args(argv)
|
||||||
|
global _DATA_ON_STDOUT
|
||||||
|
_DATA_ON_STDOUT = str(getattr(args, "output", "") or "") == "-"
|
||||||
try:
|
try:
|
||||||
result = args.func(args)
|
result = args.func(args)
|
||||||
if isinstance(result, int) and result != 0:
|
if isinstance(result, int) and result != 0:
|
||||||
|
|||||||
157
soleprint/station/tools/keymatrix/entity.py
Normal file
157
soleprint/station/tools/keymatrix/entity.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"""
|
||||||
|
One way to hand a thing from one station tool to the next.
|
||||||
|
|
||||||
|
Four tools already have this seam and each invented it privately:
|
||||||
|
|
||||||
|
histgen plan.json "the expensive half and the mechanical half, split"
|
||||||
|
modelgen {models, relationships, source} a published contract
|
||||||
|
shuntgen reads that contract
|
||||||
|
keymatrix observed/<endpoint>.json
|
||||||
|
|
||||||
|
Same idea, four spellings. This is the shared one.
|
||||||
|
|
||||||
|
## The opinion baked in: each standalone part makes sense by itself
|
||||||
|
|
||||||
|
This is deliberately NOT a pipeline runner, a DAG or a scheduler. The pipe is
|
||||||
|
`|`. All this provides is what makes `|` safe -- a declared kind, a checked
|
||||||
|
seam, and progress kept off the data channel.
|
||||||
|
|
||||||
|
And every tool keeps working with plain file paths and no pipes at all. That is
|
||||||
|
the point rather than a concession: histgen runs in a repo that has never heard
|
||||||
|
of soleprint, modelgen ships as a pip package, rig's whole contract is
|
||||||
|
`rm -rf rig/`. A tool that only works as part of a pipeline has lost the
|
||||||
|
property this convention exists to protect.
|
||||||
|
|
||||||
|
## The envelope
|
||||||
|
|
||||||
|
{
|
||||||
|
"spr": 1,
|
||||||
|
"kind": "keymatrix.observed",
|
||||||
|
"schema": 1,
|
||||||
|
"produced_by": "keymatrix 0.1.0",
|
||||||
|
"produced_at": "2026-09-14T08:21:02+00:00",
|
||||||
|
"data": { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
`kind` is **declared, never inferred**. A tool that guesses what it was handed
|
||||||
|
fails three steps down the pipe with something confusing, instead of at the seam
|
||||||
|
with something obvious.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
from ..common.entity import read_entity, write_entity # or `from .entity`
|
||||||
|
|
||||||
|
doc = read_entity(args.source, expect_kind="keymatrix.observed")
|
||||||
|
write_entity(args.output, "keymatrix.findings", 1, findings)
|
||||||
|
|
||||||
|
`-` means stdin or stdout in the usual way. A bare payload -- a file that is not
|
||||||
|
an envelope -- is accepted, because `observed/*.json` and `plan.json` already
|
||||||
|
exist in that form and `plan.json` is a seam a *human* edits between `brief` and
|
||||||
|
`export`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .cli import fail
|
||||||
|
|
||||||
|
ENVELOPE_VERSION = 1
|
||||||
|
|
||||||
|
# The entities that exist as artefacts today, named after what writes them.
|
||||||
|
# Declaring a kind here is free; changing the shape behind it is not -- see
|
||||||
|
# modelgen.ir below.
|
||||||
|
KINDS = {
|
||||||
|
"keymatrix.observed": "One endpoint's live keyboard state, as probed.",
|
||||||
|
"keymatrix.findings": "What `check` concluded from observed state.",
|
||||||
|
"histgen.plan": "Commits, ordered and cut. Edited by hand between brief and export.",
|
||||||
|
"modelgen.ir": "{models, relationships, source} -- a PUBLISHED contract. "
|
||||||
|
"modelgen, datagen, shuntgen and cfg/amar all emit or consume it, "
|
||||||
|
"and modelgen/tests/test_extractors.py:240,394 assert it. "
|
||||||
|
"Declared here; NOT to be reshaped from here.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return (datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
.replace(microsecond=0).isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
def is_envelope(obj):
|
||||||
|
return isinstance(obj, dict) and "spr" in obj and "kind" in obj
|
||||||
|
|
||||||
|
|
||||||
|
def wrap(kind, schema, data, produced_by=None):
|
||||||
|
"""Build an envelope without writing it, for callers that hold their own IO."""
|
||||||
|
if kind not in KINDS:
|
||||||
|
fail(f"unknown entity kind: {kind!r}",
|
||||||
|
f"Known: {', '.join(sorted(KINDS))}. Add it to entity.KINDS first.")
|
||||||
|
return {
|
||||||
|
"spr": ENVELOPE_VERSION,
|
||||||
|
"kind": kind,
|
||||||
|
"schema": schema,
|
||||||
|
"produced_by": produced_by,
|
||||||
|
"produced_at": _now(),
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_entity(path, expect_kind=None, what="input"):
|
||||||
|
"""The payload at `path`. `-` reads stdin.
|
||||||
|
|
||||||
|
Returns the `data` when handed an envelope, and the whole object when handed
|
||||||
|
a bare payload -- so a caller does not have to care which it got.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
text = sys.stdin.read() if str(path) == "-" else Path(path).read_text(encoding="utf-8")
|
||||||
|
except FileNotFoundError:
|
||||||
|
fail(f"{what} not found: {path}")
|
||||||
|
except OSError as exc:
|
||||||
|
fail(f"could not read {what} {path}: {exc}")
|
||||||
|
|
||||||
|
if not text.strip():
|
||||||
|
fail(f"{what} is empty: {'stdin' if str(path) == '-' else path}",
|
||||||
|
"Nothing was piped in." if str(path) == "-" else None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
obj = json.loads(text)
|
||||||
|
except ValueError as exc:
|
||||||
|
fail(f"{what} is not JSON: {exc}",
|
||||||
|
f"Read from {'stdin' if str(path) == '-' else path}.")
|
||||||
|
|
||||||
|
if not is_envelope(obj):
|
||||||
|
# A bare payload. Accepted on purpose: the files that predate this
|
||||||
|
# convention are still the ones on disk, and plan.json is edited by a
|
||||||
|
# human between two verbs.
|
||||||
|
if expect_kind:
|
||||||
|
return obj
|
||||||
|
return obj
|
||||||
|
|
||||||
|
if obj.get("spr") != ENVELOPE_VERSION:
|
||||||
|
fail(f"{what} uses envelope version {obj.get('spr')!r}, "
|
||||||
|
f"and this tool speaks {ENVELOPE_VERSION}")
|
||||||
|
|
||||||
|
if expect_kind and obj["kind"] != expect_kind:
|
||||||
|
# The whole reason `kind` is declared rather than sniffed.
|
||||||
|
fail(f"expected {expect_kind}, got {obj['kind']}",
|
||||||
|
f"That came from {obj.get('produced_by') or 'an unknown producer'}.")
|
||||||
|
|
||||||
|
return obj["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def write_entity(path, kind, schema, data, produced_by=None, indent=2):
|
||||||
|
"""Write an envelope. `-` writes stdout.
|
||||||
|
|
||||||
|
Returns the path written, or None for stdout, so a caller can report it
|
||||||
|
without re-deciding where it went.
|
||||||
|
"""
|
||||||
|
envelope = wrap(kind, schema, data, produced_by)
|
||||||
|
text = json.dumps(envelope, indent=indent, sort_keys=True) + "\n"
|
||||||
|
if str(path) == "-":
|
||||||
|
sys.stdout.write(text)
|
||||||
|
return None
|
||||||
|
target = Path(path)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text(text, encoding="utf-8")
|
||||||
|
return target
|
||||||
30
soleprint/station/tools/keymatrix/examples/ahk/README.md
Normal file
30
soleprint/station/tools/keymatrix/examples/ahk/README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# examples/ahk — a fixture, not a copy
|
||||||
|
|
||||||
|
A miniature AutoHotkey v2 tree carrying every hotkey *shape* `probe/ahk.py` has to
|
||||||
|
read. It exists so the Windows probe can be built and tested with **no Windows, no
|
||||||
|
mount and nothing installed** — the same discipline `histgen/selftest.py` uses when
|
||||||
|
it builds its own repo rather than pointing at one.
|
||||||
|
|
||||||
|
## Why a fixture and not the real thing
|
||||||
|
|
||||||
|
The real tree lives on a DLP-monitored work machine reached over an sshfs mount that
|
||||||
|
**may not be there**. A probe developed against it would be untestable the moment it
|
||||||
|
was unmounted, and copying it in would carry a username, a deploy path and an NTFS
|
||||||
|
alternate data stream (`shortcuts.txt:sec.endpointdlp`) into this repo.
|
||||||
|
|
||||||
|
So these files are written from the *shapes*, never copied. No hostnames, no
|
||||||
|
usernames, no paths, no client identifiers. `distill.json`'s removal from history is
|
||||||
|
the standing precedent.
|
||||||
|
|
||||||
|
## The shapes, and why each is here
|
||||||
|
|
||||||
|
| shape | why it matters |
|
||||||
|
| --- | --- |
|
||||||
|
| `^!1::` `^+1::` | static hotkeys, the easy case |
|
||||||
|
| `^#c::` `^+#c::` | four-modifier chords, and `#` = Super |
|
||||||
|
| `#3::` | Super+digit, which a GNOME client claims upstream |
|
||||||
|
| `^!i::` | Ctrl+Alt+letter — the family that is free on both sides |
|
||||||
|
| `Hotkey chord, action` in a loop | **the load-bearing case.** Every real alias is bound this way, so a static `::`-scanner reports all of them as unbound |
|
||||||
|
| `.env`-supplied chord lists | the loop's chords are config, not code, so the probe has to resolve them |
|
||||||
|
| `; ^!q::` commented out | must NOT be reported as bound |
|
||||||
|
| `^!` (truncated) | must be REPORTED as unparseable, never silently dropped |
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
; lib/config.ahk — .env parsing, the grammar the fixture's .env follows.
|
||||||
|
; Present so the fixture tree is shaped like the real one; the probe
|
||||||
|
; reimplements these rules in Python rather than executing this.
|
||||||
|
|
||||||
|
EnvParse(path) {
|
||||||
|
global _env := Map()
|
||||||
|
Loop Read, path {
|
||||||
|
line := Trim(A_LoopReadLine)
|
||||||
|
if (line = "" || SubStr(line, 1, 1) = "#")
|
||||||
|
continue
|
||||||
|
pos := InStr(line, "=")
|
||||||
|
if (!pos)
|
||||||
|
continue
|
||||||
|
_env[Trim(SubStr(line, 1, pos - 1))] := Trim(SubStr(line, pos + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EnvVal(key, fallback := "") {
|
||||||
|
global _env
|
||||||
|
return _env.Has(key) ? _env[key] : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
; Each key also becomes a global of the same name, which is how the modules
|
||||||
|
; refer to them — VNC_CLIP_PUSH, not EnvVal("VNC_CLIP_PUSH").
|
||||||
|
EnvExport() {
|
||||||
|
global
|
||||||
|
for k, v in _env
|
||||||
|
%k% := v
|
||||||
|
}
|
||||||
9
soleprint/station/tools/keymatrix/examples/ahk/main.ahk
Normal file
9
soleprint/station/tools/keymatrix/examples/ahk/main.ahk
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
; main.ahk — include order is load-bearing: config first, then modules.
|
||||||
|
#SingleInstance Force
|
||||||
|
SendMode "Input"
|
||||||
|
|
||||||
|
#Include %A_ScriptDir%\lib\config.ahk
|
||||||
|
EnvParse(A_ScriptDir . "\.env")
|
||||||
|
|
||||||
|
#Include %A_ScriptDir%\modules\static.ahk
|
||||||
|
#Include %A_ScriptDir%\modules\dynamic.ahk
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
; modules/dynamic.ahk — chords that are CONFIG, not code.
|
||||||
|
;
|
||||||
|
; This is the shape that breaks a naive scanner. Nothing here contains a `::`,
|
||||||
|
; so a static scan reports every one of these as unbound — while they are in
|
||||||
|
; fact the aliases that carry the fleet when the primary chord cannot cross the
|
||||||
|
; wire.
|
||||||
|
;
|
||||||
|
; lib/config.ahk turns each .env key into a global of the same name, so the
|
||||||
|
; chord is never a literal anywhere in the source. Note there is a WRAPPER in
|
||||||
|
; between: nothing here calls Hotkey directly except Bind() itself. A matcher
|
||||||
|
; keyed on the wrapper's name would stop working the day it is renamed; the
|
||||||
|
; durable signal is "an identifier that is also an .env key, in a file that
|
||||||
|
; calls Hotkey at all".
|
||||||
|
|
||||||
|
Bind(chord, action, label) {
|
||||||
|
if (Trim(chord) = "")
|
||||||
|
return
|
||||||
|
try {
|
||||||
|
Hotkey chord, action
|
||||||
|
Log("bound " chord " -> " label)
|
||||||
|
} catch as e
|
||||||
|
Log("could NOT bind '" chord "' (" label "): " e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
; Bare globals, one per alias.
|
||||||
|
Bind(VNC_CLIP_PUSH, (*) => ClipPush(), "clip push")
|
||||||
|
Bind(VNC_CLIP_TYPE, (*) => ClipType(), "clip type-in")
|
||||||
|
Bind(VNC_EDITOR, (*) => Apps.Activate("editor"), "editor")
|
||||||
|
Bind(VNC_BROWSER, (*) => Apps.Activate("browser"), "browser")
|
||||||
|
|
||||||
|
; A family: a modifier prefix concatenated with a digit at bind time, so the
|
||||||
|
; chord never appears whole in the source.
|
||||||
|
if (Trim(VNC_SLOT_MODS) != "") {
|
||||||
|
Loop 9 {
|
||||||
|
n := A_Index
|
||||||
|
Bind(VNC_SLOT_MODS . n, AppSlot.Bind(n), "app slot " n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
; A list global, iterated. Bind several and use whichever gets through the
|
||||||
|
; client, without editing code.
|
||||||
|
for _tvKey in TASKVIEW_HOTKEYS {
|
||||||
|
try {
|
||||||
|
Hotkey _tvKey, (*) => TaskView()
|
||||||
|
} catch as e
|
||||||
|
Log("taskview: could NOT bind '" _tvKey "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
; Deliberately malformed: modifiers with no key. The probe must REPORT this,
|
||||||
|
; not skip it. An unparsed hotkey is an invisible binding, which is the exact
|
||||||
|
; failure this tool exists to end.
|
||||||
|
Hotkey "^!", (*) => Broken()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
; modules/static.ahk — every static hotkey form the probe must read.
|
||||||
|
; ^ = Ctrl, ! = Alt, + = Shift, # = Super/Win.
|
||||||
|
|
||||||
|
^!1::SwitchDesktop(1)
|
||||||
|
^!2::SwitchDesktop(2)
|
||||||
|
|
||||||
|
^+1::MoveWindowToDesktop(1)
|
||||||
|
|
||||||
|
; Super+digit. A GNOME client binds <Super>1..9 for switch-to-application, so
|
||||||
|
; this never arrives when driven from one — which is the whole point of shadow.
|
||||||
|
#3::Apps.Activate("editor")
|
||||||
|
#4::Apps.Activate("browser")
|
||||||
|
|
||||||
|
; Four modifiers at once.
|
||||||
|
^#c::ClipPush()
|
||||||
|
^+#c::ClipType()
|
||||||
|
|
||||||
|
; Ctrl+Alt+letter: unclaimed upstream, which is why the aliases live here.
|
||||||
|
^!i::ToggleIndicator()
|
||||||
|
|
||||||
|
; Commented out. Must NOT be reported as bound.
|
||||||
|
; ^!q::NeverBound()
|
||||||
|
|
||||||
|
; A block-bodied hotkey, which AHK v2 allows.
|
||||||
|
^!m:: {
|
||||||
|
DoSomething()
|
||||||
|
}
|
||||||
80
soleprint/station/tools/keymatrix/freespace.py
Normal file
80
soleprint/station/tools/keymatrix/freespace.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
Which chord families nobody has claimed — recomputed, so a hand result stays true.
|
||||||
|
|
||||||
|
`modules/vnckeys.ahk`'s header contains a survey done by hand:
|
||||||
|
|
||||||
|
Surveying every binding nrft claims (all five schemas plus its custom
|
||||||
|
keybindings) leaves one roomy gap:
|
||||||
|
Ctrl+Alt+<letter> -- entirely unclaimed. Ctrl+Alt+<digit> is NOT.
|
||||||
|
|
||||||
|
That survey is **correct** — checked family by family against a live probe. This
|
||||||
|
module exists because it will not stay correct on its own. It is a regression
|
||||||
|
net for a right answer, not a correction of a wrong one: the day someone binds
|
||||||
|
`Ctrl+Alt+e` in GNOME, four aliases silently stop arriving and nothing says so.
|
||||||
|
|
||||||
|
A family is free on an endpoint when the endpoint binds no member of it. Free
|
||||||
|
*across* endpoints is the interesting question, since an alias has to survive
|
||||||
|
the near side and still mean something on the far side.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import string
|
||||||
|
|
||||||
|
from .chord import Chord
|
||||||
|
|
||||||
|
DIGITS = tuple(str(n) for n in range(1, 10))
|
||||||
|
LETTERS = tuple(string.ascii_lowercase)
|
||||||
|
FKEYS = tuple(f"F{n}" for n in range(1, 13))
|
||||||
|
|
||||||
|
# The modifier sets worth asking about. Bare and single-modifier families are
|
||||||
|
# left out deliberately: they collide with ordinary typing and application
|
||||||
|
# shortcuts, so "free" there would be misleading.
|
||||||
|
FAMILIES = [
|
||||||
|
("Ctrl+Alt", ("Ctrl", "Alt")),
|
||||||
|
("Ctrl+Shift", ("Ctrl", "Shift")),
|
||||||
|
("Ctrl+Super", ("Ctrl", "Super")),
|
||||||
|
("Alt+Super", ("Alt", "Super")),
|
||||||
|
("Super", ("Super",)),
|
||||||
|
("Ctrl+Alt+Shift", ("Ctrl", "Alt", "Shift")),
|
||||||
|
("Ctrl+Shift+Super", ("Ctrl", "Shift", "Super")),
|
||||||
|
]
|
||||||
|
|
||||||
|
RANGES = [("digit", DIGITS), ("letter", LETTERS), ("F-key", FKEYS)]
|
||||||
|
|
||||||
|
|
||||||
|
def survey(observed_by_endpoint):
|
||||||
|
"""One row per (family, range, endpoint set): what is free and what took it."""
|
||||||
|
rows = []
|
||||||
|
for fam_name, mods in FAMILIES:
|
||||||
|
for range_name, keys in RANGES:
|
||||||
|
per_endpoint = {}
|
||||||
|
for endpoint, index in sorted(observed_by_endpoint.items()):
|
||||||
|
taken = {}
|
||||||
|
for key in keys:
|
||||||
|
hit = index.get(str(Chord(mods, key)))
|
||||||
|
if hit:
|
||||||
|
taken[key] = hit.get("label") or hit["source"]
|
||||||
|
per_endpoint[endpoint] = taken
|
||||||
|
free_everywhere = [k for k in keys
|
||||||
|
if not any(k in t for t in per_endpoint.values())]
|
||||||
|
rows.append({
|
||||||
|
"family": f"{fam_name}+<{range_name}>",
|
||||||
|
"total": len(keys),
|
||||||
|
"free_everywhere": free_everywhere,
|
||||||
|
"taken": per_endpoint,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def format_rows(rows, verbose=False):
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
n_free, total = len(r["free_everywhere"]), r["total"]
|
||||||
|
mark = "FREE" if n_free == total else ("none" if n_free == 0 else "part")
|
||||||
|
out.append(f" {r['family']:<26} {n_free:>2}/{total:<3} {mark}")
|
||||||
|
if verbose or 0 < n_free < total:
|
||||||
|
for endpoint, taken in r["taken"].items():
|
||||||
|
if taken:
|
||||||
|
shown = ", ".join(f"{k} ({v})" for k, v in sorted(taken.items())[:4])
|
||||||
|
more = "" if len(taken) <= 4 else f", +{len(taken) - 4} more"
|
||||||
|
out.append(f" {endpoint}: {shown}{more}")
|
||||||
|
return out
|
||||||
@@ -13,7 +13,7 @@ import datetime
|
|||||||
from ..endpoint import platform_kind
|
from ..endpoint import platform_kind
|
||||||
|
|
||||||
|
|
||||||
def run(endpoint, kind=None):
|
def run(endpoint, kind=None, ahk_root=None):
|
||||||
"""(observed document, warnings) for this machine."""
|
"""(observed document, warnings) for this machine."""
|
||||||
kind = kind or platform_kind()
|
kind = kind or platform_kind()
|
||||||
warnings = []
|
warnings = []
|
||||||
@@ -46,6 +46,11 @@ def run(endpoint, kind=None):
|
|||||||
doc["layers"]["keyd"] = keyd.collect(report, doc["unreadable"])
|
doc["layers"]["keyd"] = keyd.collect(report, doc["unreadable"])
|
||||||
doc["input"] = xkb.collect(report)
|
doc["input"] = xkb.collect(report)
|
||||||
doc["transports"]["deskflow"] = deskflow.collect(report)
|
doc["transports"]["deskflow"] = deskflow.collect(report)
|
||||||
|
elif kind == "ahk":
|
||||||
|
from . import ahk
|
||||||
|
doc["platform"] = {"kind": "windows-ahk"}
|
||||||
|
doc["bindings"], meta = ahk.collect(ahk_root, report, doc["unreadable"])
|
||||||
|
doc["platform"]["ahk"] = meta
|
||||||
else:
|
else:
|
||||||
report("warn", f"no probe backend for {kind!r}; nothing was collected")
|
report("warn", f"no probe backend for {kind!r}; nothing was collected")
|
||||||
doc["unreadable"].append(
|
doc["unreadable"].append(
|
||||||
|
|||||||
249
soleprint/station/tools/keymatrix/probe/ahk.py
Normal file
249
soleprint/station/tools/keymatrix/probe/ahk.py
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"""
|
||||||
|
Read an AutoHotkey tree's declared chords, from wherever it happens to be.
|
||||||
|
|
||||||
|
Takes a path. There is no default location and no mount assumed: the real tree
|
||||||
|
lives on a DLP-monitored machine behind an sshfs mount that **may not be there**,
|
||||||
|
and a probe that required it would be untestable the moment it was unmounted.
|
||||||
|
An absent root is recorded as unreadable and the run continues -- the same
|
||||||
|
discipline `probe/keyd.py` uses for the root-only config it cannot read.
|
||||||
|
|
||||||
|
`examples/ahk/` in this package is a fixture carrying every shape below, so this
|
||||||
|
module is developed and tested with no Windows, no mount and nothing installed.
|
||||||
|
|
||||||
|
## Static is the easy half
|
||||||
|
|
||||||
|
^!1::SwitchDesktop(1) Ctrl+Alt+1
|
||||||
|
^+#c::ClipType() Ctrl+Shift+Super+c
|
||||||
|
|
||||||
|
## Dynamic is the half that matters
|
||||||
|
|
||||||
|
`lib/config.ahk` turns every `.env` key into a global, and the real code binds
|
||||||
|
through those globals -- never through a literal:
|
||||||
|
|
||||||
|
VncBind(VNC_CLIP_PUSH, (*) => ClipPush(), "clip push") ; a bare global
|
||||||
|
Loop 9 {
|
||||||
|
VncBind(VNC_SLOT_MODS . n, ...) ; a family
|
||||||
|
}
|
||||||
|
for _tvKey in TASKVIEW_HOTKEYS { Hotkey _tvKey, ... } ; a list global
|
||||||
|
|
||||||
|
None of that contains `::`, so a static scan reports every one as unbound -- and
|
||||||
|
those are precisely the aliases that carry the fleet when the primary chord
|
||||||
|
cannot cross the wire.
|
||||||
|
|
||||||
|
So the signal is not a wrapper's name, which varies (`VncBind`, `Hotkey`,
|
||||||
|
whatever comes next). It is: **an identifier that matches a key in `.env`,
|
||||||
|
appearing in a file that calls `Hotkey` at all.** That holds regardless of how
|
||||||
|
many helper layers sit in between, which is the property worth having -- the
|
||||||
|
alternative is a matcher that silently stops working when someone renames a
|
||||||
|
function.
|
||||||
|
|
||||||
|
## What is not read
|
||||||
|
|
||||||
|
Anything whose path contains `:`. NTFS alternate data streams surface over sshfs
|
||||||
|
and reading one touches DLP classification metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..chord import Chord
|
||||||
|
|
||||||
|
# `KEY=value`, `#` comments on their own line, no quotes, trim both sides, last
|
||||||
|
# wins. Reimplemented from lib/config.ahk:EnvParse() rather than executed.
|
||||||
|
_ENV_LINE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$")
|
||||||
|
|
||||||
|
# A static hotkey: chord, then `::`, at the start of a line.
|
||||||
|
_STATIC = re.compile(r"^\s*([\^!+#<>*~$]*[^\s:;][^\s:]*?)::")
|
||||||
|
|
||||||
|
# `Hotkey <something>, <action>` -- the dynamic form.
|
||||||
|
_DYNAMIC = re.compile(r"""^\s*Hotkey\s+(?P<what>"[^"]*"|EnvVal\(\s*"[^"]*"\s*\)|[A-Za-z_]\w*)""")
|
||||||
|
|
||||||
|
# A bare modifier run: a family, not a chord.
|
||||||
|
_BARE_MODS = re.compile(r"^[\^!+#]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_env(text):
|
||||||
|
out = {}
|
||||||
|
for line in text.split("\n"):
|
||||||
|
if not line.strip() or line.lstrip().startswith("#"):
|
||||||
|
continue
|
||||||
|
m = _ENV_LINE.match(line)
|
||||||
|
if m:
|
||||||
|
out[m.group(1).strip()] = m.group(2).strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_files(root):
|
||||||
|
"""Every .ahk under root, skipping alternate data streams."""
|
||||||
|
for path in sorted(root.rglob("*.ahk")):
|
||||||
|
if ":" in path.name:
|
||||||
|
continue
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def _record(bindings, chord_text, source, kind, report, detail=None):
|
||||||
|
try:
|
||||||
|
chord = Chord.parse_ahk(chord_text)
|
||||||
|
except ValueError as exc:
|
||||||
|
report("warn", f"unparsed AHK hotkey {chord_text!r} in {source}: {exc}")
|
||||||
|
return False
|
||||||
|
if not chord.key:
|
||||||
|
# `^!` with no key. An unparsed hotkey is an invisible binding, which is
|
||||||
|
# the exact failure this tool exists to end, so it is reported rather
|
||||||
|
# than dropped.
|
||||||
|
report("warn", f"AHK hotkey {chord_text!r} in {source} has modifiers but "
|
||||||
|
f"no key; it binds nothing")
|
||||||
|
return False
|
||||||
|
bindings.append({
|
||||||
|
"chord": str(chord),
|
||||||
|
"raw": chord_text,
|
||||||
|
"source": f"ahk:{source}",
|
||||||
|
"label": detail,
|
||||||
|
"command": None,
|
||||||
|
"binding_kind": kind,
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def collect(root, report, unreadable, env_overrides=None):
|
||||||
|
"""(bindings, meta) for the AHK tree at `root`, which may not exist."""
|
||||||
|
bindings, meta = [], {"root": str(root) if root else None,
|
||||||
|
"env": {}, "files": [], "dynamic_unresolved": []}
|
||||||
|
|
||||||
|
if not root:
|
||||||
|
unreadable.append({"what": "the AHK tree",
|
||||||
|
"reason": "no root given; pass --ahk-root"})
|
||||||
|
return bindings, meta
|
||||||
|
|
||||||
|
# Absolute, once, here. %A_ScriptDir% expands to this string, and a
|
||||||
|
# relative root made the expansion look like a relative include -- which
|
||||||
|
# then got root prepended a second time. It worked against the real tree
|
||||||
|
# only because that path happened to be absolute.
|
||||||
|
root = Path(root).expanduser().resolve()
|
||||||
|
if not root.is_dir():
|
||||||
|
# The mount is gone, or this endpoint simply has no AHK. Both are facts,
|
||||||
|
# not failures.
|
||||||
|
report("warn", f"no AHK tree at {root}; nothing was collected from it")
|
||||||
|
unreadable.append({"what": f"the AHK tree at {root}",
|
||||||
|
"reason": "path does not exist (the mount may be absent)"})
|
||||||
|
return bindings, meta
|
||||||
|
|
||||||
|
env_path = root / ".env"
|
||||||
|
env = {}
|
||||||
|
if env_path.exists():
|
||||||
|
env = parse_env(env_path.read_text(encoding="utf-8", errors="replace"))
|
||||||
|
else:
|
||||||
|
report("warn", f"no .env beside {root}; dynamic hotkeys cannot be resolved")
|
||||||
|
env.update(env_overrides or {})
|
||||||
|
meta["env"] = env
|
||||||
|
|
||||||
|
live = _reachable(root, report)
|
||||||
|
meta["entry_reachable"] = sorted(p.relative_to(root).as_posix() for p in live)
|
||||||
|
|
||||||
|
for path in _iter_files(root):
|
||||||
|
rel = path.relative_to(root).as_posix()
|
||||||
|
meta["files"].append(rel)
|
||||||
|
if live and path not in live:
|
||||||
|
# ref/, debug/ and 3rd/ hold prototypes and scratch. They declare
|
||||||
|
# chords that nothing binds, and counting them would report the
|
||||||
|
# near side claiming keys it does not.
|
||||||
|
meta.setdefault("skipped_unreachable", []).append(rel)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError as exc:
|
||||||
|
report("warn", f"could not read {rel}: {exc}")
|
||||||
|
unreadable.append({"what": rel, "reason": str(exc)})
|
||||||
|
continue
|
||||||
|
|
||||||
|
for raw_line in text.split("\n"):
|
||||||
|
line = raw_line.rstrip()
|
||||||
|
if not line or line.lstrip().startswith(";"):
|
||||||
|
continue # a commented-out hotkey is not bound
|
||||||
|
m = _STATIC.match(line)
|
||||||
|
if m:
|
||||||
|
_record(bindings, m.group(1), rel, "static", report)
|
||||||
|
continue
|
||||||
|
# `Hotkey "^!c", ...` -- a literal, which is rare but is also the
|
||||||
|
# only place a malformed chord shows up as itself. Keeping this is
|
||||||
|
# what exercises the report-what-you-cannot-read path.
|
||||||
|
lit = re.match(r'^\s*Hotkey\s+"([^"]*)"', line)
|
||||||
|
if lit:
|
||||||
|
_record(bindings, lit.group(1), rel, "dynamic-literal", report)
|
||||||
|
|
||||||
|
if "Hotkey" not in text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Every .env key named as an identifier in a file that binds hotkeys.
|
||||||
|
for key, value in sorted(env.items()):
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
for use in re.finditer(rf"\b{re.escape(key)}\b(\s*\.\s*\w+)?", text):
|
||||||
|
concatenated = bool(use.group(1))
|
||||||
|
_from_env(bindings, env, key, rel, report,
|
||||||
|
expand_digits=concatenated)
|
||||||
|
break # one record per key per file, not per mention
|
||||||
|
|
||||||
|
return bindings, meta
|
||||||
|
|
||||||
|
|
||||||
|
def _reachable(root, report):
|
||||||
|
"""Files reachable from main.ahk by #Include, transitively.
|
||||||
|
|
||||||
|
Include order is load-bearing in these trees and the entry point is the only
|
||||||
|
authority on what is live. Without this, `ref/` prototypes get counted as
|
||||||
|
bindings -- on the real tree that was 103 static hotkeys, most of them from
|
||||||
|
three abandoned OSD experiments.
|
||||||
|
|
||||||
|
Returns an empty set if there is no entry point, in which case everything is
|
||||||
|
scanned rather than nothing.
|
||||||
|
"""
|
||||||
|
entry = root / "main.ahk"
|
||||||
|
if not entry.exists():
|
||||||
|
report("note", f"no main.ahk in {root}; scanning every .ahk instead of "
|
||||||
|
f"only what the entry point includes")
|
||||||
|
return set()
|
||||||
|
|
||||||
|
seen, queue = set(), [entry.resolve()]
|
||||||
|
while queue:
|
||||||
|
path = queue.pop()
|
||||||
|
if path in seen or not path.exists():
|
||||||
|
continue
|
||||||
|
seen.add(path.resolve())
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for inc in re.findall(r"^\s*#Include\s+(.+)$", text, re.M):
|
||||||
|
inc = inc.strip().strip('"')
|
||||||
|
inc = inc.replace("%A_ScriptDir%", str(root)).replace("\\", "/")
|
||||||
|
candidate = Path(inc)
|
||||||
|
if not candidate.is_absolute():
|
||||||
|
candidate = root / inc
|
||||||
|
queue.append(candidate.resolve())
|
||||||
|
return seen
|
||||||
|
|
||||||
|
return bindings, meta
|
||||||
|
|
||||||
|
|
||||||
|
def _from_env(bindings, env, key, source, report, expand_digits=False):
|
||||||
|
value = env.get(key, "")
|
||||||
|
if not value:
|
||||||
|
report("note", f"{source}: {key} is unset in .env, so nothing is bound for it")
|
||||||
|
return
|
||||||
|
if "," in value and not _BARE_MODS.match(value):
|
||||||
|
# A list global, iterated with `for x in KEY`.
|
||||||
|
for part in (v.strip() for v in value.split(",")):
|
||||||
|
if part:
|
||||||
|
_record(bindings, part, source, "dynamic", report, detail=key)
|
||||||
|
return
|
||||||
|
if _BARE_MODS.match(value) or expand_digits:
|
||||||
|
if not _BARE_MODS.match(value):
|
||||||
|
report("warn", f"{source}: {key}={value!r} is used as a family prefix "
|
||||||
|
f"but is not a bare modifier run")
|
||||||
|
return
|
||||||
|
for n in range(1, 10):
|
||||||
|
_record(bindings, f"{value}{n}", source, "dynamic-family", report,
|
||||||
|
detail=f"{key} slot {n}")
|
||||||
|
return
|
||||||
|
_record(bindings, value, source, "dynamic", report, detail=key)
|
||||||
@@ -59,13 +59,32 @@ class Verdict:
|
|||||||
return f"<{self.outcome}{' by ' + self.eater if self.eater else ''}>"
|
return f"<{self.outcome}{' by ' + self.eater if self.eater else ''}>"
|
||||||
|
|
||||||
|
|
||||||
def reaches(chord, transport, near_observed):
|
def grab_states(transport):
|
||||||
|
"""The client states this transport behaves differently in.
|
||||||
|
|
||||||
|
One entry for every transport but RDP, which has a documented knob. Keeping
|
||||||
|
this a lookup rather than an expression language is deliberate: one setting,
|
||||||
|
in one transport, does not justify a grammar.
|
||||||
|
"""
|
||||||
|
grab = transport.get("grab", "unknown")
|
||||||
|
return tuple(grab) if isinstance(grab, dict) else (None,)
|
||||||
|
|
||||||
|
|
||||||
|
def reaches(chord, transport, near_observed, state=None):
|
||||||
"""Does `chord`, pressed on the near side, arrive at the far side?
|
"""Does `chord`, pressed on the near side, arrive at the far side?
|
||||||
|
|
||||||
`near_observed` maps a normal-form chord string to the binding that claims
|
`near_observed` maps a normal-form chord string to the binding that claims
|
||||||
it -- the probe's output for whichever endpoint is near in this posture.
|
it -- the probe's output for whichever endpoint is near in this posture.
|
||||||
|
`state` picks a column when the transport's grab is conditional.
|
||||||
"""
|
"""
|
||||||
grab = transport.get("grab", "unknown")
|
grab = transport.get("grab", "unknown")
|
||||||
|
if isinstance(grab, dict):
|
||||||
|
state = state or transport.get("default_state")
|
||||||
|
if state not in grab:
|
||||||
|
raise ValueError(
|
||||||
|
f"{transport['name']} needs a client state, one of "
|
||||||
|
f"{sorted(grab)}; got {state!r}")
|
||||||
|
grab = grab[state]
|
||||||
if grab not in GRABS:
|
if grab not in GRABS:
|
||||||
raise ValueError(f"unknown grab kind {grab!r}")
|
raise ValueError(f"unknown grab kind {grab!r}")
|
||||||
|
|
||||||
@@ -110,15 +129,21 @@ TRANSPORTS = {
|
|||||||
},
|
},
|
||||||
"rdp-grd": {
|
"rdp-grd": {
|
||||||
"name": "rdp-grd",
|
"name": "rdp-grd",
|
||||||
"grab": "unknown",
|
# Conditional, and this is the only conditional grab in the fleet. RDP
|
||||||
|
# has a knob RealVNC simply does not: keyboardhook decides whether
|
||||||
|
# Windows key combinations are applied locally or sent to the remote.
|
||||||
|
# So posture B is *configurable* where posture A is not.
|
||||||
|
"grab": {"fullscreen": "effective", "windowed": "none"},
|
||||||
|
"default_state": "fullscreen",
|
||||||
"intercepts": [],
|
"intercepts": [],
|
||||||
"evidence": (
|
"evidence": (
|
||||||
"Posture B: ethbox's RDP client into nrft's gnome-remote-desktop "
|
"Posture B: ethbox's RDP client into nrft's gnome-remote-desktop "
|
||||||
"(system unit active on *:3389, so a HEADLESS session -- a separate "
|
"(system unit active on *:3389, so a HEADLESS session -- a separate "
|
||||||
"GNOME instance, same dconf). mstsc has a documented 'Apply Windows "
|
"GNOME instance, same dconf). nrft.rdp carries `keyboardhook:i:2` = "
|
||||||
"key combinations' setting with no RealVNC equivalent, so this "
|
"'apply Windows key combinations only when using the full screen', "
|
||||||
"direction may be configurable where posture A is not. UNTESTED: the "
|
"and `redirectclipboard:i:1`. READ FROM CONFIG, NOT OBSERVED: this "
|
||||||
"setting's value here is unknown, so the grab is unknown."),
|
"is what the profile says the client will do, not a keypress anyone "
|
||||||
|
"has watched arrive."),
|
||||||
},
|
},
|
||||||
"physical-switch": {
|
"physical-switch": {
|
||||||
"name": "physical-switch",
|
"name": "physical-switch",
|
||||||
@@ -171,7 +196,7 @@ POSTURES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def walk(chord, posture, observed_by_endpoint):
|
def walk(chord, posture, observed_by_endpoint, state=None):
|
||||||
"""Follow a chord along a posture's chain until something eats it.
|
"""Follow a chord along a posture's chain until something eats it.
|
||||||
|
|
||||||
Returns one row per hop, so a three-hop posture shows where the chord died
|
Returns one row per hop, so a three-hop posture shows where the chord died
|
||||||
@@ -183,7 +208,7 @@ def walk(chord, posture, observed_by_endpoint):
|
|||||||
if near is None:
|
if near is None:
|
||||||
rows.append((hop, Verdict("UNKNOWN", f"{hop['near']} has never been probed")))
|
rows.append((hop, Verdict("UNKNOWN", f"{hop['near']} has never been probed")))
|
||||||
break
|
break
|
||||||
v = reaches(chord, TRANSPORTS[hop["transport"]], near)
|
v = reaches(chord, TRANSPORTS[hop["transport"]], near, state=state)
|
||||||
rows.append((hop, v))
|
rows.append((hop, v))
|
||||||
if not v.reaches_far_side:
|
if not v.reaches_far_side:
|
||||||
break
|
break
|
||||||
|
|||||||
Reference in New Issue
Block a user