@junghanacs/entwurf 0.12.6 → 0.12.7

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.
Files changed (92) hide show
  1. package/AGENTS.md +31 -17
  2. package/BASELINE.md +42 -8
  3. package/CHANGELOG.md +22 -0
  4. package/DELIVERY.md +71 -14
  5. package/README.md +77 -45
  6. package/VERIFY.md +18 -13
  7. package/docs/setup-clean-host.md +80 -22
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
  30. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
  31. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
  32. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
  33. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
  34. package/mcp/entwurf-bridge/src/index.ts +101 -67
  35. package/mcp/entwurf-bridge/test.sh +1 -1
  36. package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
  37. package/package.json +10 -5
  38. package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
  39. package/pi-extensions/lib/entwurf-self-address.ts +58 -15
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
  41. package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
  42. package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
  43. package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
  44. package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
  45. package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
  46. package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
  48. package/pi-extensions/lib/meta-sender-identity.ts +154 -0
  49. package/pi-extensions/lib/native-push/adapter.ts +255 -0
  50. package/pi-extensions/lib/native-push/register.ts +99 -0
  51. package/run.sh +756 -214
  52. package/scripts/agy-bridge-config.py +446 -0
  53. package/scripts/agy-bridge.sh +359 -0
  54. package/scripts/agy-hooks-bridge.sh +193 -0
  55. package/scripts/agy-hooks-config.py +257 -0
  56. package/scripts/agy-imprint.sh +28 -0
  57. package/scripts/agy-imprint.ts +193 -0
  58. package/scripts/agy-statusline-bridge.sh +176 -0
  59. package/scripts/agy-statusline-config.py +213 -0
  60. package/scripts/agy-statusline.sh +256 -0
  61. package/scripts/build-bridge.sh +20 -0
  62. package/scripts/check-agy-sender-identity.ts +364 -0
  63. package/scripts/check-entwurf-bridge-boot.ts +8 -2
  64. package/scripts/check-entwurf-deliverability.ts +34 -0
  65. package/scripts/check-entwurf-self-address.ts +78 -11
  66. package/scripts/check-entwurf-v2-contract.ts +136 -1
  67. package/scripts/check-entwurf-v2-decider.ts +95 -1
  68. package/scripts/check-entwurf-v2-matrix.ts +14 -3
  69. package/scripts/check-entwurf-v2-native-push.ts +193 -0
  70. package/scripts/check-entwurf-v2-production.ts +68 -1
  71. package/scripts/check-entwurf-v2-runner.ts +58 -0
  72. package/scripts/check-entwurf-v2-surface.ts +35 -0
  73. package/scripts/check-install-surface.ts +357 -0
  74. package/scripts/check-native-push-adapter.ts +319 -0
  75. package/scripts/check-native-push-register.ts +130 -0
  76. package/scripts/dev-bin.sh +195 -0
  77. package/scripts/doctor-pi-provider.ts +140 -0
  78. package/scripts/meta-bridge-doctor.sh +36 -2
  79. package/scripts/register-pi-package.py +37 -3
  80. package/scripts/register-pi-provider.py +287 -0
  81. package/scripts/smoke-agy-hooks-state.sh +172 -0
  82. package/scripts/smoke-agy-install-state.sh +660 -0
  83. package/scripts/smoke-agy-native-push-live.ts +243 -0
  84. package/scripts/smoke-agy-statusline-state.sh +300 -0
  85. package/scripts/smoke-meta-async-drift.sh +9 -2
  86. package/scripts/smoke-meta-install-state.sh +20 -0
  87. package/scripts/smoke-pi-provider-state.sh +182 -0
  88. package/scripts/smoke-user-scope-citizen.sh +62 -0
  89. package/scripts/with-dist-lock.sh +81 -0
  90. package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
  91. package/scripts/__pycache__/meta-bridge-state.cpython-313.pyc +0 -0
  92. package/scripts/__pycache__/register-pi-package.cpython-313.pyc +0 -0
@@ -0,0 +1,446 @@
1
+ #!/usr/bin/env python3
2
+ """agy-bridge-config — the JSON state operations for the agy (Antigravity) MCP install
3
+ adapter (봉인 7). SEPARATE from the Claude marketplace install (no generalization); only
4
+ the reporting/runner is shared, in agy-bridge.sh. stdlib only.
5
+
6
+ Subcommands (argv[1]):
7
+ install <config_path> <command> <state_path>
8
+ Adopt an agy mcp_config.json and register ONE server entry (serverKey
9
+ "entwurf-bridge") pointing at <command> (a STABLE bin — never a repo/git hash path).
10
+ REFUSES a symlink target (exit 3 — it is someone else's SSOT, e.g. an agent-config
11
+ link; clobbering it would write into that repo). A regular file is adopted (the prior
12
+ value of the key is captured as the preimage for an honest inverse); an absent file is
13
+ created. Invalid JSON fails loud (exit 4). Writes install-state atomically to
14
+ <state_path> (checkout-outside XDG path — the caller resolves it).
15
+
16
+ uninstall <state_path>
17
+ Honest inverse from install-state: restore the captured preimage (remove the key if it
18
+ was absent, else set it back). If WE created the whole file and it now carries nothing
19
+ else, remove the file. REFUSES if the managed config became a symlink since install
20
+ (exit 3). No state → nothing to do (exit 2).
21
+
22
+ clean-legacy <config_path>
23
+ One-way MIGRATION cleanup: remove OUR server key from a LEGACY (wrong-root) config that
24
+ live agy does NOT read as the global MCP config (the doc-correct global is
25
+ ~/.gemini/config/mcp_config.json; the antigravity-cli copy was a mis-wiring). Idempotent.
26
+ Preserves unrelated servers. Removes the file only if it becomes an empty mcpServers-only
27
+ object. REFUSES to touch a symlink (someone else's SSOT) — reports skip, never clobbers.
28
+ This is NOT tracked for an honest inverse: the legacy entry was wrong and stays gone.
29
+
30
+ doctor-static <config_path>
31
+ Print one line describing the candidate for the shell doctor: `absent` / `symlink ->
32
+ <target>` prefix / `invalid-json` / `not-configured` / `command <cmd>`. Never mutates.
33
+
34
+ permission-install <settings_path> <state_path>
35
+ The OTHER half of "agy can call our bridge": registering the server (above) makes the tool
36
+ reachable, but agy's permission engine defaults every `mcp` action to Ask, so every single
37
+ entwurf_v2 call stops for a y/n. This adds our allow rule to agy's settings.json.
38
+
39
+ Ownership is ELEMENT-level, not subtree: we own exactly the string
40
+ `mcp(entwurf-bridge/entwurf_v2)` inside `permissions.allow`. The operator's own rules
41
+ (command(*), their ask/deny lists, everything else) are ours to preserve, never to manage —
42
+ granting ourselves broad permissions would be a trust decision that is not the installer's
43
+ to make. Same discipline as the single mcpServers key above. Idempotent; refuses a symlink.
44
+
45
+ permission-uninstall <state_path>
46
+ Honest inverse: remove OUR rule only if WE added it (an operator who already had the rule
47
+ keeps it), then drop the `allow`/`permissions` containers only if we created them and they
48
+ are now empty. Removes the settings file only if we created it and nothing else remains.
49
+
50
+ permission-doctor <settings_path>
51
+ One token line: `absent` / `invalid-json` / `not-configured` / `configured` /
52
+ `shadowed-by-<list> <rule>`. The shadow check exists because agy evaluates
53
+ Deny > Ask > Allow: an operator rule like `mcp(*)` in their ask list SILENTLY defeats our
54
+ allow, and agy starts prompting again with our install still green. That would be a
55
+ debugging hole ("why is it asking me every time?"), so the doctor names it instead.
56
+
57
+ Exit codes: 0 ok · 2 no-state · 3 refuse-symlink · 4 invalid-json · 5 usage.
58
+ """
59
+
60
+ import json
61
+ import os
62
+ import sys
63
+
64
+ SERVER_KEY = "entwurf-bridge"
65
+ STATE_SCHEMA_VERSION = 1
66
+
67
+ # The ONE permission rule we own. Scoped to a single tool on our own server — not mcp(*), not the
68
+ # server-wide mcp(entwurf-bridge): an installer grants itself the narrowest rule that makes the
69
+ # thing it installed work, and nothing more. INSTALL still writes only this.
70
+ ALLOW_RULE = f"mcp({SERVER_KEY}/entwurf_v2)"
71
+ # Rules that MATCH our tool: the exact grant, the server-wide rule, and the action wildcard.
72
+ # Membership here is a statement about agy's matcher, not about which list the rule sits in — the
73
+ # same three rules cover entwurf_v2 wherever they appear. From a higher-precedence list they
74
+ # override our allow (shadowing); from `allow` itself they already grant it (covering).
75
+ #
76
+ # The doctor used to read them ONE WAY ONLY — shadowing — and demanded a literal ALLOW_RULE string
77
+ # in `allow`. So a host whose operator had granted a broad `mcp(*)` (their trust decision, not
78
+ # ours) was reported as "NOT granted, agy prompts on EVERY entwurf_v2 call": a false red about a
79
+ # surface that in fact works. Reading the same coverage in both directions is the fix; treating
80
+ # mcp(*) as matching in deny/ask but not in allow was never defensible.
81
+ MATCHING_RULES = (ALLOW_RULE, f"mcp({SERVER_KEY})", "mcp(*)")
82
+ SHADOWING_RULES = MATCHING_RULES # same set, higher-precedence lists
83
+ # Deny > Ask > Allow (agy permissions engine).
84
+ SHADOWING_LISTS = ("deny", "ask")
85
+
86
+
87
+ def _die(code: int, msg: str) -> "None":
88
+ sys.stderr.write(msg.rstrip("\n") + "\n")
89
+ sys.exit(code)
90
+
91
+
92
+ def _atomic_write(path: str, text: str) -> None:
93
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
94
+ tmp = f"{path}.tmp-{os.getpid()}"
95
+ with open(tmp, "w", encoding="utf-8") as fh:
96
+ fh.write(text)
97
+ os.replace(tmp, path)
98
+
99
+
100
+ def _load_config(path: str) -> dict:
101
+ with open(path, "r", encoding="utf-8") as fh:
102
+ raw = fh.read()
103
+ if raw.strip() == "":
104
+ return {}
105
+ try:
106
+ data = json.loads(raw)
107
+ except json.JSONDecodeError as err:
108
+ _die(4, f"agy-bridge: {path} is not valid JSON: {err}")
109
+ if not isinstance(data, dict):
110
+ _die(4, f"agy-bridge: {path} top-level must be a JSON object")
111
+ return data
112
+
113
+
114
+ def _dump(data: dict) -> str:
115
+ return json.dumps(data, indent=2) + "\n"
116
+
117
+
118
+ def _prior_state(state_path: str, managed_key: str, managed_path: str) -> dict:
119
+ """The state of OUR FIRST install of this exact target, if we are re-installing over it.
120
+
121
+ An installer is re-run routinely (every package upgrade). Re-capturing the preimage on each run
122
+ would capture OUR OWN previous write as "what was there before" — and then uninstall faithfully
123
+ restores us, leaving the very entry it was supposed to take away. Provenance must be recorded
124
+ once, at the first install, and carried forward unchanged: preimage, and whether the file
125
+ existed before us, are facts about a moment that has already passed.
126
+ """
127
+ if not os.path.exists(state_path):
128
+ return {}
129
+ try:
130
+ prior = _load_config(state_path)
131
+ except SystemExit:
132
+ raise
133
+ if prior.get(managed_key) != os.path.abspath(managed_path):
134
+ return {} # re-targeted somewhere else — this is a fresh install for that path
135
+ return prior
136
+
137
+
138
+ def cmd_install(config_path: str, command: str, state_path: str) -> None:
139
+ # REFUSE a symlink — it is someone else's SSOT (an agent-config link). Never clobber.
140
+ if os.path.islink(config_path):
141
+ target = os.readlink(config_path)
142
+ _die(3, f"agy-bridge: refusing to adopt {config_path} — it is a symlink to {target} (someone else's SSOT). "
143
+ f"Manage it there, or replace it with a regular file, then retry.")
144
+
145
+ config_existed = os.path.exists(config_path)
146
+ if config_existed:
147
+ data = _load_config(config_path)
148
+ detect_mode = "adopt-regular-file"
149
+ else:
150
+ data = {}
151
+ detect_mode = "created-new"
152
+
153
+ servers = data.get("mcpServers")
154
+ if servers is None:
155
+ servers = {}
156
+ data["mcpServers"] = servers
157
+ if not isinstance(servers, dict):
158
+ _die(4, f"agy-bridge: {config_path} mcpServers must be a JSON object")
159
+
160
+ # Capture the preimage of OUR key (None = the key was absent) for the honest inverse — but ONLY
161
+ # on the first install of this target. On a re-install the key on disk is our own previous
162
+ # write; capturing it would make uninstall "restore" us and leave the entry behind.
163
+ prior = _prior_state(state_path, "managedConfigPath", config_path)
164
+ if prior:
165
+ preimage = prior.get("preimage", None)
166
+ detect_mode = prior.get("detectMode", detect_mode)
167
+ config_existed = prior.get("configExistedBefore", config_existed)
168
+ else:
169
+ preimage = servers.get(SERVER_KEY, None)
170
+ servers[SERVER_KEY] = {"command": command, "args": []}
171
+
172
+ _atomic_write(config_path, _dump(data))
173
+
174
+ state = {
175
+ "schemaVersion": STATE_SCHEMA_VERSION,
176
+ "managedConfigPath": os.path.abspath(config_path),
177
+ "serverKey": SERVER_KEY,
178
+ "command": command,
179
+ "detectMode": detect_mode,
180
+ "configExistedBefore": config_existed,
181
+ "preimage": preimage, # null = key was absent before install
182
+ "installedAt": _now(),
183
+ }
184
+ _atomic_write(state_path, _dump(state))
185
+ sys.stdout.write(f"{detect_mode} {os.path.abspath(config_path)}\n")
186
+
187
+
188
+ def cmd_uninstall(state_path: str) -> None:
189
+ if not os.path.exists(state_path):
190
+ _die(2, f"agy-bridge: no install-state at {state_path} — nothing to uninstall "
191
+ f"(never installed, or already uninstalled).")
192
+ state = _load_config(state_path)
193
+ config_path = state.get("managedConfigPath")
194
+ if not isinstance(config_path, str):
195
+ _die(4, f"agy-bridge: install-state {state_path} has no managedConfigPath")
196
+
197
+ if os.path.islink(config_path):
198
+ _die(3, f"agy-bridge: refusing to uninstall — {config_path} became a symlink since install "
199
+ f"(someone else's SSOT now). Resolve by hand.")
200
+
201
+ if os.path.exists(config_path):
202
+ data = _load_config(config_path)
203
+ servers = data.get("mcpServers")
204
+ if isinstance(servers, dict):
205
+ preimage = state.get("preimage", None)
206
+ if preimage is None:
207
+ servers.pop(SERVER_KEY, None)
208
+ else:
209
+ servers[SERVER_KEY] = preimage
210
+ # If WE created the whole file and nothing else remains, remove it (honest inverse).
211
+ created_new = state.get("detectMode") == "created-new" and state.get("configExistedBefore") is False
212
+ only_our_empty = created_new and len(servers) == 0 and set(data.keys()) == {"mcpServers"}
213
+ if only_our_empty:
214
+ os.remove(config_path)
215
+ else:
216
+ _atomic_write(config_path, _dump(data))
217
+
218
+ os.remove(state_path)
219
+ sys.stdout.write(f"uninstalled {config_path}\n")
220
+
221
+
222
+ def cmd_clean_legacy(config_path: str) -> None:
223
+ # Symlink = someone else's SSOT (e.g. an agent-config link). Never clobber; report + skip.
224
+ if os.path.islink(config_path):
225
+ sys.stdout.write(f"skip-symlink {config_path}\n")
226
+ return
227
+ if not os.path.exists(config_path):
228
+ sys.stdout.write(f"absent {config_path}\n")
229
+ return
230
+ data = _load_config(config_path)
231
+ servers = data.get("mcpServers")
232
+ if not isinstance(servers, dict) or SERVER_KEY not in servers:
233
+ sys.stdout.write(f"not-present {config_path}\n")
234
+ return
235
+ servers.pop(SERVER_KEY, None)
236
+ # Remove the file only if nothing else remains and it was a pure mcpServers object (ours to
237
+ # tidy). Otherwise rewrite it, preserving unrelated servers / top-level keys.
238
+ if len(servers) == 0 and set(data.keys()) == {"mcpServers"}:
239
+ os.remove(config_path)
240
+ sys.stdout.write(f"cleaned-removed {config_path}\n")
241
+ else:
242
+ _atomic_write(config_path, _dump(data))
243
+ sys.stdout.write(f"cleaned-kept {config_path}\n")
244
+
245
+
246
+ def cmd_doctor_static(config_path: str) -> None:
247
+ # Report the RESOLVED path's config status in one shell-parseable token line. Symlink
248
+ # detection/reporting is the shell's job (realpath here just follows any link).
249
+ real = os.path.realpath(config_path)
250
+ if not os.path.exists(real):
251
+ sys.stdout.write("absent\n")
252
+ return
253
+ try:
254
+ with open(real, "r", encoding="utf-8") as fh:
255
+ data = json.loads(fh.read() or "{}")
256
+ except (json.JSONDecodeError, OSError):
257
+ sys.stdout.write("invalid-json\n")
258
+ return
259
+ server = (data.get("mcpServers") or {}).get(SERVER_KEY) if isinstance(data, dict) else None
260
+ if not isinstance(server, dict) or not server.get("command"):
261
+ sys.stdout.write("not-configured\n")
262
+ return
263
+ # "configured <command>" — command is the trailing token(s); shell takes field 2..N.
264
+ sys.stdout.write(f"configured {server['command']}\n")
265
+
266
+
267
+ def cmd_permission_install(settings_path: str, state_path: str) -> None:
268
+ if os.path.islink(settings_path):
269
+ target = os.readlink(settings_path)
270
+ _die(3, f"agy-bridge: refusing to adopt {settings_path} — it is a symlink to {target} (someone else's SSOT). "
271
+ f"Manage it there, or replace it with a regular file, then retry.")
272
+
273
+ settings_existed = os.path.exists(settings_path)
274
+ data = _load_config(settings_path) if settings_existed else {}
275
+ detect_mode = "adopt-regular-file" if settings_existed else "created-new"
276
+
277
+ perms = data.get("permissions")
278
+ permissions_existed = isinstance(perms, dict)
279
+ if perms is None:
280
+ perms = {}
281
+ data["permissions"] = perms
282
+ if not isinstance(perms, dict):
283
+ _die(4, f"agy-bridge: {settings_path} permissions must be a JSON object")
284
+
285
+ allow = perms.get("allow")
286
+ allow_existed = isinstance(allow, list)
287
+ if allow is None:
288
+ allow = []
289
+ perms["allow"] = allow
290
+ if not isinstance(allow, list):
291
+ _die(4, f"agy-bridge: {settings_path} permissions.allow must be a JSON array")
292
+
293
+ # Idempotent, and the provenance that makes the inverse honest: if the operator ALREADY had this
294
+ # rule, we did not add it, so uninstall must not take it away. On a RE-install the rule on disk
295
+ # is our own previous write — reading it as "pre-existing" would hand the operator credit for
296
+ # our entry and strand it forever. So provenance is taken from the first install and carried.
297
+ on_disk = ALLOW_RULE in allow # what the file says NOW (may be our own earlier write)
298
+ rule_existed = on_disk
299
+ prior = _prior_state(state_path, "managedSettingsPath", settings_path)
300
+ if prior:
301
+ rule_existed = prior.get("ruleExistedBefore", rule_existed)
302
+ detect_mode = prior.get("detectMode", detect_mode)
303
+ settings_existed = prior.get("settingsExistedBefore", settings_existed)
304
+ permissions_existed = prior.get("permissionsExistedBefore", permissions_existed)
305
+ allow_existed = prior.get("allowExistedBefore", allow_existed)
306
+ if ALLOW_RULE not in allow:
307
+ allow.append(ALLOW_RULE)
308
+
309
+ _atomic_write(settings_path, _dump(data))
310
+
311
+ state = {
312
+ "schemaVersion": STATE_SCHEMA_VERSION,
313
+ "managedSettingsPath": os.path.abspath(settings_path),
314
+ "rule": ALLOW_RULE,
315
+ "detectMode": detect_mode,
316
+ "settingsExistedBefore": settings_existed,
317
+ "permissionsExistedBefore": permissions_existed,
318
+ "allowExistedBefore": allow_existed,
319
+ "ruleExistedBefore": rule_existed, # true = the operator's rule, not ours to remove
320
+ "installedAt": _now(),
321
+ }
322
+ _atomic_write(state_path, _dump(state))
323
+ sys.stdout.write(f"{'already-present' if on_disk else 'added'} {ALLOW_RULE}\n")
324
+
325
+
326
+ def cmd_permission_uninstall(state_path: str) -> None:
327
+ if not os.path.exists(state_path):
328
+ _die(2, f"agy-bridge: no permission install-state at {state_path} — nothing to uninstall.")
329
+ state = _load_config(state_path)
330
+ settings_path = state.get("managedSettingsPath")
331
+ if not isinstance(settings_path, str):
332
+ _die(4, f"agy-bridge: permission install-state {state_path} has no managedSettingsPath")
333
+
334
+ if os.path.islink(settings_path):
335
+ _die(3, f"agy-bridge: refusing to uninstall — {settings_path} became a symlink since install "
336
+ f"(someone else's SSOT now). Resolve by hand.")
337
+
338
+ if os.path.exists(settings_path):
339
+ data = _load_config(settings_path)
340
+ perms = data.get("permissions")
341
+ if isinstance(perms, dict):
342
+ allow = perms.get("allow")
343
+ # The operator already had the rule before us → it is theirs; leave it.
344
+ if isinstance(allow, list) and not state.get("ruleExistedBefore", False):
345
+ perms["allow"] = [r for r in allow if r != ALLOW_RULE]
346
+ # Drop only the containers WE created, and only while they are empty. An operator
347
+ # who has since added their own rules keeps their structure untouched.
348
+ if not state.get("allowExistedBefore", False) and perms["allow"] == []:
349
+ perms.pop("allow", None)
350
+ if not state.get("permissionsExistedBefore", False) and perms == {}:
351
+ data.pop("permissions", None)
352
+ created_new = state.get("detectMode") == "created-new" and state.get("settingsExistedBefore") is False
353
+ if created_new and data == {}:
354
+ os.remove(settings_path)
355
+ else:
356
+ _atomic_write(settings_path, _dump(data))
357
+
358
+ os.remove(state_path)
359
+ sys.stdout.write(f"uninstalled {ALLOW_RULE} from {settings_path}\n")
360
+
361
+
362
+ def cmd_permission_doctor(settings_path: str) -> None:
363
+ real = os.path.realpath(settings_path)
364
+ if not os.path.exists(real):
365
+ sys.stdout.write("absent\n")
366
+ return
367
+ try:
368
+ with open(real, "r", encoding="utf-8") as fh:
369
+ data = json.loads(fh.read() or "{}")
370
+ except (json.JSONDecodeError, OSError):
371
+ sys.stdout.write("invalid-json\n")
372
+ return
373
+ perms = data.get("permissions") if isinstance(data, dict) else None
374
+ perms = perms if isinstance(perms, dict) else {}
375
+
376
+ # Precedence FIRST (Deny > Ask > Allow): a matching rule in a higher list means agy prompts (or
377
+ # blocks) no matter what our allow says. Reporting `configured` there would be a green light on
378
+ # a surface that is actually still stopping every call.
379
+ for list_name in SHADOWING_LISTS:
380
+ rules = perms.get(list_name)
381
+ if not isinstance(rules, list):
382
+ continue
383
+ for rule in rules:
384
+ if rule in SHADOWING_RULES:
385
+ sys.stdout.write(f"shadowed-by-{list_name} {rule}\n")
386
+ return
387
+
388
+ allow = perms.get("allow")
389
+ if isinstance(allow, list):
390
+ if ALLOW_RULE in allow:
391
+ sys.stdout.write("configured\n")
392
+ return
393
+ # No rule of ours, but a BROADER operator rule in the same list already matches our tool.
394
+ # agy will not prompt; the call works. Say so — and say whose rule is carrying it, because
395
+ # the day the operator narrows that wildcard, our unowned grant disappears with it.
396
+ for rule in MATCHING_RULES:
397
+ if rule != ALLOW_RULE and rule in allow:
398
+ sys.stdout.write(f"covered-by-allow {rule}\n")
399
+ return
400
+ sys.stdout.write("not-configured\n")
401
+
402
+
403
+ def _now() -> str:
404
+ import datetime
405
+
406
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
407
+
408
+
409
+ def main(argv: list) -> None:
410
+ if len(argv) < 2:
411
+ _die(5, "usage: agy-bridge-config.py <install|uninstall|doctor-static> ...")
412
+ sub = argv[1]
413
+ if sub == "install":
414
+ if len(argv) != 5:
415
+ _die(5, "usage: agy-bridge-config.py install <config_path> <command> <state_path>")
416
+ cmd_install(argv[2], argv[3], argv[4])
417
+ elif sub == "uninstall":
418
+ if len(argv) != 3:
419
+ _die(5, "usage: agy-bridge-config.py uninstall <state_path>")
420
+ cmd_uninstall(argv[2])
421
+ elif sub == "clean-legacy":
422
+ if len(argv) != 3:
423
+ _die(5, "usage: agy-bridge-config.py clean-legacy <config_path>")
424
+ cmd_clean_legacy(argv[2])
425
+ elif sub == "doctor-static":
426
+ if len(argv) != 3:
427
+ _die(5, "usage: agy-bridge-config.py doctor-static <config_path>")
428
+ cmd_doctor_static(argv[2])
429
+ elif sub == "permission-install":
430
+ if len(argv) != 4:
431
+ _die(5, "usage: agy-bridge-config.py permission-install <settings_path> <state_path>")
432
+ cmd_permission_install(argv[2], argv[3])
433
+ elif sub == "permission-uninstall":
434
+ if len(argv) != 3:
435
+ _die(5, "usage: agy-bridge-config.py permission-uninstall <state_path>")
436
+ cmd_permission_uninstall(argv[2])
437
+ elif sub == "permission-doctor":
438
+ if len(argv) != 3:
439
+ _die(5, "usage: agy-bridge-config.py permission-doctor <settings_path>")
440
+ cmd_permission_doctor(argv[2])
441
+ else:
442
+ _die(5, f"agy-bridge-config.py: unknown subcommand {sub!r}")
443
+
444
+
445
+ if __name__ == "__main__":
446
+ main(sys.argv)