awsettings 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
awsettings/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """awsettings — your agent's permissions and config, following you to the next machine.
2
+
3
+ An agent harness keeps its permission allowlist, enabled tool servers and hooks in
4
+ a local file. Work from a second machine and none of it is there: you re-approve
5
+ the same action, by hand, once per surface, forever, while the copies drift apart
6
+ and every one of them looks correct.
7
+
8
+ awsettings status
9
+ awsettings pull
10
+ awsettings push
11
+ awsettings hook install
12
+ """
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = ["__version__"]
awsettings/cli.py ADDED
@@ -0,0 +1,323 @@
1
+ """awsettings — your agent's permissions and config, following you to the next machine.
2
+
3
+ awsettings status what is here, what is remote, what differs
4
+ awsettings pull remote -> local (union; never drops a deny)
5
+ awsettings push local -> remote (credentials stripped)
6
+ awsettings hook install run it automatically from now on
7
+ awsettings hook uninstall
8
+ awsettings --self-test
9
+
10
+ Exit **0** did the thing · **1** a rule refused it · **2** could not judge — the
11
+ profile was unreachable, or a settings file would not parse. Never 0 for "I could
12
+ not look": a sync that silently did nothing and one that had nothing to do are
13
+ the same green run otherwise, and telling them apart is the whole point.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+ import time
22
+ from pathlib import Path
23
+
24
+ from . import __version__
25
+ from .core import SECRET_KEYS, diff_summary, merge, redact
26
+ from .hooks import install_to, installed, uninstall_from
27
+ from .profile import resolve
28
+ from .store import CouldNotRunError, local_settings_path, read_json, write_json
29
+ from .trust import UntrustedProfileError
30
+
31
+ DEBOUNCE_STAMP = Path.home() / ".awsettings" / "last-push"
32
+ DEBOUNCE_SECONDS = 5.0
33
+
34
+
35
+ def _root(args) -> Path | None:
36
+ if args.user:
37
+ return None
38
+ return Path(args.root or os.getcwd())
39
+
40
+
41
+ def cmd_status(args) -> int:
42
+ target = local_settings_path(_root(args))
43
+ backend = resolve(args.url, args.profile)
44
+ local = read_json(target)
45
+ try:
46
+ remote = backend.get()
47
+ except CouldNotRunError as exc:
48
+ print(f"local: {target}")
49
+ print(f"remote: {backend.describe()}")
50
+ print(f"DEAD: {exc}")
51
+ return 2
52
+ merged = merge(local, remote)
53
+ lines = diff_summary(local, merged)
54
+ print(f"local: {target} ({'present' if target.is_file() else 'absent'})")
55
+ print(f"remote: {backend.describe()}")
56
+ print(f"hooks: {', '.join(installed(local)) or 'not installed'}")
57
+ if not lines:
58
+ print("in step: a pull would change nothing")
59
+ return 0
60
+ print(f"a pull would apply {len(lines)} change(s):")
61
+ for ln in lines:
62
+ print(" " + ln)
63
+ return 0
64
+
65
+
66
+ def cmd_pull(args) -> int:
67
+ target = local_settings_path(_root(args))
68
+ backend = resolve(args.url, args.profile)
69
+ try:
70
+ local = read_json(target)
71
+ remote = backend.get()
72
+ except CouldNotRunError as exc:
73
+ # Offline is the normal state of a laptop. Say so and keep the local file.
74
+ if not args.quiet:
75
+ print(f"DEAD: {exc}")
76
+ return 2
77
+ merged = merge(local, remote, prune_denies=args.prune_denies)
78
+ lines = diff_summary(local, merged)
79
+ if not lines:
80
+ if not args.quiet:
81
+ print("already in step")
82
+ return 0
83
+ if args.dry_run:
84
+ print(f"would apply {len(lines)} change(s) to {target}:")
85
+ for ln in lines:
86
+ print(" " + ln)
87
+ return 0
88
+ write_json(target, merged)
89
+ if not args.quiet:
90
+ print(f"applied {len(lines)} change(s) to {target}:")
91
+ for ln in lines:
92
+ print(" " + ln)
93
+ return 0
94
+
95
+
96
+ def cmd_push(args) -> int:
97
+ if args.debounce:
98
+ try:
99
+ last = DEBOUNCE_STAMP.stat().st_mtime
100
+ if time.time() - last < DEBOUNCE_SECONDS:
101
+ return 0
102
+ except OSError:
103
+ pass
104
+ target = local_settings_path(_root(args))
105
+ backend = resolve(args.url, args.profile)
106
+ try:
107
+ local = read_json(target)
108
+ except CouldNotRunError as exc:
109
+ if not args.quiet:
110
+ print(f"DEAD: {exc}")
111
+ return 2
112
+ snapshot = redact(local)
113
+ if args.dry_run:
114
+ print(json.dumps(snapshot, indent=2))
115
+ return 0
116
+ try:
117
+ backend.put(snapshot)
118
+ except CouldNotRunError as exc:
119
+ if not args.quiet:
120
+ print(f"DEAD: {exc}")
121
+ return 2
122
+ DEBOUNCE_STAMP.parent.mkdir(parents=True, exist_ok=True)
123
+ DEBOUNCE_STAMP.write_text(str(time.time()), encoding="utf-8")
124
+ if not args.quiet:
125
+ kept = sorted(snapshot)
126
+ dropped = sorted(k for k in local if k not in snapshot)
127
+ print(f"pushed {len(kept)} key(s) to {backend.describe()}")
128
+ if kept:
129
+ print(" sent: " + ", ".join(kept))
130
+ if dropped:
131
+ print(" kept local: " + ", ".join(dropped))
132
+ return 0
133
+
134
+
135
+ def cmd_hook(args) -> int:
136
+ target = local_settings_path(_root(args))
137
+ if args.action == "install":
138
+ path, evs = install_to(target)
139
+ print(f"installed into {path}: {', '.join(evs) or 'nothing'}")
140
+ print(" SessionStart -> pull (a new machine is stale before its first tool call)")
141
+ print(" PostToolUse -> push (debounced; a rule you approve here is on its way)")
142
+ return 0
143
+ path, evs = uninstall_from(target)
144
+ print(f"removed from {path}; still installed: {', '.join(evs) or 'nothing'}")
145
+ return 0
146
+
147
+
148
+ def self_test() -> int:
149
+ """Offline proof of the properties that can do harm. No network, no profile."""
150
+ import tempfile
151
+ problems: list[str] = []
152
+
153
+ # --- redaction: by NAME, not by shape --------------------------------
154
+ src = {
155
+ "env": {"OPENAI_API_KEY": "sk-real"},
156
+ "apiKeyHelper": "/bin/print-my-token",
157
+ "sandbox": {"enabled": True, "credentials": {"envVars": [{"name": "X"}]}},
158
+ "permissions": {"allow": ["Bash(git *)"]},
159
+ "someLocalExperiment": 1,
160
+ }
161
+ red = redact(src)
162
+ for k in ("env", "apiKeyHelper"):
163
+ if k in red:
164
+ problems.append(f"redact() let the credential key {k!r} through")
165
+ # Both halves, because the first alone passes VACUOUSLY when `sandbox` is not
166
+ # synced at all — which is exactly the state this shipped in, making the guard
167
+ # unreachable while its check stayed green.
168
+ if "sandbox" not in red:
169
+ problems.append("redact() dropped `sandbox` entirely, so the credential-subkey "
170
+ "guard is unreachable — a protection nothing can run")
171
+ elif "credentials" in red["sandbox"]:
172
+ problems.append("redact() left sandbox.credentials in the snapshot")
173
+ elif red["sandbox"].get("enabled") is not True:
174
+ problems.append("redact() stripped more of `sandbox` than the credentials")
175
+ if "someLocalExperiment" in red:
176
+ problems.append("redact() pushed an unknown local key instead of leaving it home")
177
+ if src["env"]["OPENAI_API_KEY"] != "sk-real":
178
+ problems.append("redact() MUTATED its input")
179
+
180
+ # --- merge: union, not replace ---------------------------------------
181
+ local = {"permissions": {"allow": ["Bash(local *)"], "deny": ["Bash(rm -rf *)"]},
182
+ "enabledMcpjsonServers": ["aitheros"], "env": {"S": "keep"}}
183
+ remote = {"permissions": {"allow": ["Bash(portal *)"]},
184
+ "enabledMcpjsonServers": ["awsh"]}
185
+ m = merge(local, remote)
186
+ if sorted(m["permissions"]["allow"]) != ["Bash(local *)", "Bash(portal *)"]:
187
+ problems.append("merge() did not UNION allow rules — a device silently loses "
188
+ "the rule the other one never had")
189
+ if m["permissions"]["deny"] != ["Bash(rm -rf *)"]:
190
+ problems.append("merge() dropped a local deny the remote did not carry")
191
+ if sorted(m["enabledMcpjsonServers"]) != ["aitheros", "awsh"]:
192
+ problems.append("merge() did not union enabledMcpjsonServers")
193
+ if m.get("env", {}).get("S") != "keep":
194
+ problems.append("merge() lost a device-local secret it must never touch")
195
+ if local["permissions"]["allow"] != ["Bash(local *)"]:
196
+ problems.append("merge() MUTATED its local input")
197
+
198
+ # --- a profile may not push credentials DOWN --------------------------
199
+ if merge({}, {"env": {"X": "y"}}).get("env") is not None:
200
+ problems.append("merge() accepted an env block pushed from the profile — a "
201
+ "settings profile is not a secret channel")
202
+
203
+ # --- deny is one-way unless explicitly pruned -------------------------
204
+ if merge({"permissions": {"deny": ["Bash(curl *)"]}},
205
+ {"permissions": {"deny": []}})["permissions"]["deny"] != ["Bash(curl *)"]:
206
+ problems.append("a deny was dropped by a default sync — the one direction this "
207
+ "must never go")
208
+ if merge({"permissions": {"deny": ["Bash(curl *)"]}}, {"permissions": {"deny": []}},
209
+ prune_denies=True)["permissions"]["deny"] != []:
210
+ problems.append("prune_denies=True did not prune")
211
+
212
+ # --- hooks are idempotent --------------------------------------------
213
+ from .hooks import plan
214
+ once = plan({})
215
+ twice = plan(once)
216
+ n = len(twice["hooks"]["SessionStart"])
217
+ if n != 1:
218
+ problems.append(f"installing twice left {n} SessionStart hooks — a hook that "
219
+ f"accumulates runs N times per event")
220
+ if plan({"hooks": {"SessionStart": [{"matcher": "", "hooks": [
221
+ {"type": "command", "command": "somebody-elses-hook"}]}]}
222
+ })["hooks"]["SessionStart"].__len__() != 2:
223
+ problems.append("install clobbered somebody else's hook instead of adding to it")
224
+ from .hooks import uninstall as hk_uninstall
225
+ if installed(hk_uninstall(twice)):
226
+ problems.append("uninstall left one of our hooks behind")
227
+
228
+ # --- a malformed settings file RAISES, never reads as empty -----------
229
+ with tempfile.TemporaryDirectory() as td:
230
+ bad = Path(td) / "settings.local.json"
231
+ bad.write_text("{ not json", encoding="utf-8")
232
+ try:
233
+ read_json(bad)
234
+ problems.append("a malformed settings file read as EMPTY — that is how a "
235
+ "sync overwrites a half-finished edit")
236
+ except CouldNotRunError:
237
+ pass
238
+ # ...and a write is atomic: no .tmp left behind.
239
+ good = Path(td) / "out.json"
240
+ write_json(good, {"a": 1})
241
+ if list(Path(td).glob("*.tmp")):
242
+ problems.append("write_json left a .tmp file behind")
243
+ if json.loads(good.read_text(encoding="utf-8")) != {"a": 1}:
244
+ problems.append("write_json did not write what it was given")
245
+
246
+ # --- the write target is the personal file ---------------------------
247
+ with tempfile.TemporaryDirectory() as td:
248
+ if local_settings_path(Path(td)).name != "settings.local.json":
249
+ problems.append("the write target is not settings.local.json — syncing into "
250
+ "the committed settings.json hands the whole team one "
251
+ "person's permission rules")
252
+
253
+ # --- the secret list is not empty ------------------------------------
254
+ if not SECRET_KEYS:
255
+ problems.append("the credential denylist is empty, so redact() is a no-op")
256
+
257
+ if problems:
258
+ print("SELF-TEST FAILED:")
259
+ for p in problems:
260
+ print(" x " + p)
261
+ return 1
262
+ print("self-test ok: credentials never leave or arrive, arrays union instead of "
263
+ "replacing, a deny is never dropped by default, hooks install once and do not "
264
+ "clobber, a malformed file refuses rather than reading empty, writes are "
265
+ "atomic, and the target is the personal settings file")
266
+ return 0
267
+
268
+
269
+ def main(argv: list[str] | None = None) -> int:
270
+ ap = argparse.ArgumentParser(prog="awsettings",
271
+ description=__doc__.splitlines()[0])
272
+ ap.add_argument("--version", action="version", version=f"awsettings {__version__}")
273
+ ap.add_argument("--self-test", action="store_true")
274
+ ap.add_argument("--root", help="project root (default: cwd)")
275
+ ap.add_argument("--user", action="store_true",
276
+ help="act on the user-level settings instead of a project's")
277
+ ap.add_argument("--url", help="profile endpoint (default: $AWSETTINGS_URL)")
278
+ ap.add_argument("--profile", help="profile FILE (default: $AWSETTINGS_PROFILE)")
279
+ ap.add_argument("--quiet", action="store_true")
280
+ sub = ap.add_subparsers(dest="cmd")
281
+
282
+ sub.add_parser("status")
283
+ p = sub.add_parser("pull")
284
+ p.add_argument("--dry-run", action="store_true")
285
+ p.add_argument("--prune-denies", action="store_true",
286
+ help="allow the profile to REMOVE deny/ask rules (off by default: a "
287
+ "lost deny costs the thing it prevented)")
288
+ p = sub.add_parser("push")
289
+ p.add_argument("--dry-run", action="store_true")
290
+ p.add_argument("--debounce", action="store_true")
291
+ p = sub.add_parser("hook")
292
+ p.add_argument("action", choices=["install", "uninstall"])
293
+
294
+ args = ap.parse_args(argv)
295
+ if args.self_test:
296
+ return self_test()
297
+ for name in ("dry_run", "prune_denies", "debounce"):
298
+ if not hasattr(args, name):
299
+ setattr(args, name, False)
300
+
301
+ try:
302
+ if args.cmd == "status":
303
+ return cmd_status(args)
304
+ if args.cmd == "pull":
305
+ return cmd_pull(args)
306
+ if args.cmd == "push":
307
+ return cmd_push(args)
308
+ if args.cmd == "hook":
309
+ return cmd_hook(args)
310
+ except UntrustedProfileError as exc:
311
+ # A profile that cannot be proven yours is REFUSED, and the settings
312
+ # are not applied. This is the one failure that must never be quiet.
313
+ print(f"REFUSED: {exc}")
314
+ return 2
315
+ except CouldNotRunError as exc:
316
+ print(f"DEAD: {exc}")
317
+ return 2
318
+ ap.print_help()
319
+ return 0
320
+
321
+
322
+ if __name__ == "__main__":
323
+ sys.exit(main())
awsettings/core.py ADDED
@@ -0,0 +1,180 @@
1
+ """The merge and the redaction. Pure, offline, and the only place harm can happen.
2
+
3
+ Everything in this module is a function of its arguments. That is deliberate: the
4
+ two operations that can hurt you — sending a credential somewhere, and losing a
5
+ rule you were relying on — need no network to test, so they are tested without
6
+ one.
7
+
8
+ THE THREE RULES, each already paid for:
9
+
10
+ 1. **Credentials never travel, and are dropped by NAME.** Not by inspecting the
11
+ value. A "does this look like a token" heuristic passes every secret that does
12
+ not look like one, and that failure is invisible until the secret is already
13
+ published. A name-based denylist is auditable; a shape-based one is a hope.
14
+
15
+ 2. **Arrays UNION; they are never replaced.** A settings file is edited from more
16
+ than one place, so replace semantics means device B silently loses the rule
17
+ device A never had. This is not hypothetical: it is what happens to a shared
18
+ config any time one writer's copy is stamped over another's.
19
+
20
+ 3. **A deny is ONE-WAY.** A sync may ADD a deny or ask rule; it may never drop
21
+ one. A lost allow rule costs a prompt. A lost deny rule costs the thing the
22
+ deny existed to prevent — silently, on a machine whose owner still believes it
23
+ is there. `prune_denies` exists for the deliberate case and is never a default.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import copy
28
+ from typing import Any
29
+
30
+ #: Array-valued keys that MERGE as a union rather than replacing (rule 2).
31
+ UNION_ARRAYS: tuple[tuple[str, ...], ...] = (
32
+ ("permissions", "allow"),
33
+ ("permissions", "deny"),
34
+ ("permissions", "ask"),
35
+ ("permissions", "additionalDirectories"),
36
+ ("enabledMcpjsonServers",),
37
+ ("disabledMcpjsonServers",),
38
+ )
39
+
40
+ #: Keys whose VALUES are credentials, or commands that fetch one. Dropped whole
41
+ #: from anything that leaves this machine, and refused on anything arriving.
42
+ SECRET_KEYS = frozenset({
43
+ "env", # arbitrary values, routinely tokens
44
+ "apiKeyHelper",
45
+ "proxyAuthHelper",
46
+ "awsCredentialExport",
47
+ "awsAuthRefresh",
48
+ "gcpAuthRefresh",
49
+ "otelHeadersHelper",
50
+ "policyHelper",
51
+ })
52
+
53
+ #: Sub-objects that hold credential material inside an otherwise-syncable key.
54
+ SECRET_SUBKEYS: dict[str, frozenset[str]] = {
55
+ "sandbox": frozenset({"credentials"}),
56
+ }
57
+
58
+ #: What is worth syncing. An unknown key is left at home rather than guessed at:
59
+ #: it is far more likely a local experiment than a preference somebody wants
60
+ #: pushed to every machine they own.
61
+ SYNCED_KEYS = frozenset({
62
+ "permissions",
63
+ # `sandbox` carries the network allowlist and filesystem rules, which are
64
+ # exactly the kind of thing you want on every machine — and `sandbox.credentials`,
65
+ # which is exactly what must not travel. That pairing is why SECRET_SUBKEYS
66
+ # exists at all; leaving `sandbox` out of this set made that guard UNREACHABLE,
67
+ # i.e. deleted code wearing the shape of a protection. Caught by a test that
68
+ # asserted the strip, not by reading it.
69
+ "sandbox",
70
+ "enabledMcpjsonServers",
71
+ "disabledMcpjsonServers",
72
+ "enableAllProjectMcpServers",
73
+ "hooks",
74
+ "outputStyle",
75
+ "statusLine",
76
+ "alwaysThinkingEnabled",
77
+ "autoCompactEnabled",
78
+ "spinnerTipsEnabled",
79
+ "todoFeatureEnabled",
80
+ "attribution",
81
+ })
82
+
83
+
84
+ def redact(settings: dict[str, Any]) -> dict[str, Any]:
85
+ """The snapshot that may leave this machine. Never mutates the input."""
86
+ out: dict[str, Any] = {}
87
+ for k, v in settings.items():
88
+ if k in SECRET_KEYS or k not in SYNCED_KEYS:
89
+ continue
90
+ if k in SECRET_SUBKEYS and isinstance(v, dict):
91
+ v = {sk: sv for sk, sv in v.items() if sk not in SECRET_SUBKEYS[k]}
92
+ out[k] = copy.deepcopy(v)
93
+ return out
94
+
95
+
96
+ def _get(d: dict[str, Any], path: tuple[str, ...]) -> Any:
97
+ cur: Any = d
98
+ for seg in path:
99
+ if not isinstance(cur, dict) or seg not in cur:
100
+ return None
101
+ cur = cur[seg]
102
+ return cur
103
+
104
+
105
+ def _set(d: dict[str, Any], path: tuple[str, ...], value: Any) -> None:
106
+ cur = d
107
+ for seg in path[:-1]:
108
+ nxt = cur.get(seg)
109
+ if not isinstance(nxt, dict):
110
+ nxt = {}
111
+ cur[seg] = nxt
112
+ cur = nxt
113
+ cur[path[-1]] = value
114
+
115
+
116
+ def merge(local: dict[str, Any], remote: dict[str, Any], *,
117
+ prune_denies: bool = False) -> dict[str, Any]:
118
+ """Remote over local, UNION on the array keys, credentials untouched.
119
+
120
+ Neither input is mutated. A credential block arriving from the remote is
121
+ REFUSED — a settings profile is not a secret channel, and a client that
122
+ accepts one becomes a way to push an `apiKeyHelper` at somebody.
123
+ """
124
+ out = copy.deepcopy(local)
125
+
126
+ for k, v in remote.items():
127
+ if k in SECRET_KEYS:
128
+ continue
129
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
130
+ merged = dict(out[k])
131
+ merged.update(v)
132
+ out[k] = merged
133
+ else:
134
+ out[k] = copy.deepcopy(v)
135
+
136
+ for path in UNION_ARRAYS:
137
+ lv, rv = _get(local, path), _get(remote, path)
138
+ if lv is None and rv is None:
139
+ continue
140
+ lv = lv if isinstance(lv, list) else []
141
+ rv = rv if isinstance(rv, list) else []
142
+ if prune_denies and path[-1] in ("deny", "ask"):
143
+ union = list(rv)
144
+ else:
145
+ union = list(lv)
146
+ union += [x for x in rv if x not in union]
147
+ _set(out, path, union)
148
+
149
+ # Device-local secrets survive verbatim. Stated explicitly rather than left
150
+ # to fall out of the loop above, because "preserved by omission" is exactly
151
+ # the kind of property a later refactor removes without noticing.
152
+ for k in SECRET_KEYS:
153
+ if k in local:
154
+ out[k] = copy.deepcopy(local[k])
155
+ return out
156
+
157
+
158
+ def diff_summary(before: dict[str, Any], after: dict[str, Any]) -> list[str]:
159
+ """Human-readable list of what a merge actually changed.
160
+
161
+ A sync that reports "ok" and cannot say what it did is indistinguishable
162
+ from one that did nothing, which is how drift goes unnoticed for months.
163
+ """
164
+ lines: list[str] = []
165
+ for path in UNION_ARRAYS:
166
+ b = _get(before, path) or []
167
+ a = _get(after, path) or []
168
+ added = [x for x in a if x not in b]
169
+ removed = [x for x in b if x not in a]
170
+ label = ".".join(path)
171
+ for x in added:
172
+ lines.append(f"+ {label}: {x}")
173
+ for x in removed:
174
+ lines.append(f"- {label}: {x}")
175
+ for k in sorted(set(before) | set(after)):
176
+ if k in SECRET_KEYS or any(k == p[0] for p in UNION_ARRAYS):
177
+ continue
178
+ if before.get(k) != after.get(k):
179
+ lines.append(f"~ {k}")
180
+ return lines
awsettings/hooks.py ADDED
@@ -0,0 +1,117 @@
1
+ """The autonomy half: run the sync when a session opens, not when someone remembers.
2
+
3
+ **Not a daemon, on purpose.** A background process is a second thing to keep
4
+ alive on every surface this exists to stop hand-maintaining — and when it dies
5
+ it dies quietly, which is the same failure as the drift it was meant to fix.
6
+ A hook runs at a moment that already exists, has an owner, and is visible in the
7
+ config file it lives in.
8
+
9
+ Two hooks, and the split matters:
10
+
11
+ * **SessionStart -> pull.** The moment a new machine, shell or container opens a
12
+ session is exactly when its settings are most likely to be stale, and it is
13
+ before any tool call has been refused.
14
+ * **A settings write -> push.** Debounced, fail-soft. The trigger is the file
15
+ actually changing, so a rule you approve on one machine is on its way to the
16
+ others before you have finished the thought.
17
+
18
+ Both are installed INTO the settings file the harness already reads, and both
19
+ are idempotent: installing twice leaves one hook, because a hook that
20
+ accumulates copies is a hook that eventually runs N times per event.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from .store import read_json, write_json
28
+
29
+ #: Marker that identifies OUR hook entries. Matching on the command string alone
30
+ #: would fail the moment the command is reworded; a marker key survives that and
31
+ #: makes removal exact rather than best-effort.
32
+ MARKER = "awsettings"
33
+
34
+ PULL_COMMAND = "awsettings pull --quiet || true"
35
+ PUSH_COMMAND = "awsettings push --quiet --debounce || true"
36
+
37
+ #: `|| true` on both is deliberate. A settings sync must never be able to fail a
38
+ #: session open or a tool call: offline is the normal state of a laptop, and the
39
+ #: correct behaviour offline is to keep the local file and carry on.
40
+
41
+
42
+ def _entry(command: str, status_message: str) -> dict[str, Any]:
43
+ return {
44
+ "matcher": "",
45
+ "hooks": [{
46
+ "type": "command",
47
+ "command": command,
48
+ "timeout": 20,
49
+ "statusMessage": status_message, # harness schema key: camelCase is theirs
50
+ MARKER: True,
51
+ }],
52
+ }
53
+
54
+
55
+ def _is_ours(group: dict[str, Any]) -> bool:
56
+ return any(h.get(MARKER) for h in group.get("hooks", []) if isinstance(h, dict))
57
+
58
+
59
+ def plan(settings: dict[str, Any]) -> dict[str, Any]:
60
+ """Return settings with our two hooks installed exactly once. Pure."""
61
+ import copy
62
+ out = copy.deepcopy(settings)
63
+ hooks = out.setdefault("hooks", {})
64
+ if not isinstance(hooks, dict):
65
+ raise TypeError("settings.hooks is not an object")
66
+
67
+ for event, command, msg in (
68
+ ("SessionStart", PULL_COMMAND, "Syncing agent settings..."),
69
+ ("PostToolUse", PUSH_COMMAND, "Pushing agent settings..."),
70
+ ):
71
+ groups = [g for g in hooks.get(event, []) if isinstance(g, dict)]
72
+ # Drop any previous copy of ours before adding, so install is idempotent
73
+ # and a reworded command replaces rather than accumulates.
74
+ groups = [g for g in groups if not _is_ours(g)]
75
+ groups.append(_entry(command, msg))
76
+ hooks[event] = groups
77
+ return out
78
+
79
+
80
+ def uninstall(settings: dict[str, Any]) -> dict[str, Any]:
81
+ """Remove only our entries, leaving every other hook untouched."""
82
+ import copy
83
+ out = copy.deepcopy(settings)
84
+ hooks = out.get("hooks")
85
+ if not isinstance(hooks, dict):
86
+ return out
87
+ for event in list(hooks):
88
+ groups = [g for g in hooks.get(event, [])
89
+ if isinstance(g, dict) and not _is_ours(g)]
90
+ if groups:
91
+ hooks[event] = groups
92
+ else:
93
+ del hooks[event]
94
+ if not hooks:
95
+ out.pop("hooks", None)
96
+ return out
97
+
98
+
99
+ def installed(settings: dict[str, Any]) -> list[str]:
100
+ """Which events currently carry our hook."""
101
+ hooks = settings.get("hooks")
102
+ if not isinstance(hooks, dict):
103
+ return []
104
+ return sorted(ev for ev, groups in hooks.items()
105
+ if any(_is_ours(g) for g in groups if isinstance(g, dict)))
106
+
107
+
108
+ def install_to(path: Path) -> tuple[Path, list[str]]:
109
+ data = read_json(path)
110
+ write_json(path, plan(data))
111
+ return path, installed(read_json(path))
112
+
113
+
114
+ def uninstall_from(path: Path) -> tuple[Path, list[str]]:
115
+ data = read_json(path)
116
+ write_json(path, uninstall(data))
117
+ return path, installed(read_json(path))