@junghanacs/entwurf 0.12.0
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.
- package/AGENTS.md +240 -0
- package/BASELINE.md +227 -0
- package/CHANGELOG.md +1210 -0
- package/CONTRIBUTING.md +63 -0
- package/DELIVERY.md +209 -0
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/VERIFY.md +260 -0
- package/demo/README.md +188 -0
- package/demo/demo-baseline.sh +156 -0
- package/demo/demo.sh +183 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/assets/entwurf-doomemacs.gif +0 -0
- package/docs/assets/entwurf-entwurf.gif +0 -0
- package/docs/assets/entwurf-hero.jpg +0 -0
- package/docs/setup-clean-host.md +305 -0
- package/mcp/entwurf-bridge/src/index.ts +513 -0
- package/mcp/entwurf-bridge/start.sh +25 -0
- package/mcp/entwurf-bridge/test.sh +54 -0
- package/mcp/tsconfig.json +29 -0
- package/package.json +130 -0
- package/pi/entwurf-capabilities.json +9 -0
- package/pi/entwurf-targets.json +20 -0
- package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
- package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
- package/pi/settings.reference.json +43 -0
- package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
- package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
- package/pi-extensions/acp-provider.ts +63 -0
- package/pi-extensions/entwurf-control.ts +1692 -0
- package/pi-extensions/lib/acp/acp-client.ts +90 -0
- package/pi-extensions/lib/acp/augment.ts +238 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
- package/pi-extensions/lib/acp/backend.ts +873 -0
- package/pi-extensions/lib/acp/config.ts +552 -0
- package/pi-extensions/lib/acp/context.ts +177 -0
- package/pi-extensions/lib/acp/engraving.ts +123 -0
- package/pi-extensions/lib/acp/event-mapper.ts +339 -0
- package/pi-extensions/lib/acp/models.ts +102 -0
- package/pi-extensions/lib/acp/overlay.ts +220 -0
- package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
- package/pi-extensions/lib/acp/session-store.ts +418 -0
- package/pi-extensions/lib/acp/tool-surface.ts +184 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
- package/pi-extensions/lib/entwurf-core.ts +2033 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
- package/pi-extensions/lib/entwurf-facts.ts +251 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
- package/pi-extensions/lib/entwurf-preflight.ts +247 -0
- package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
- package/pi-extensions/lib/entwurf-self-address.ts +117 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
- package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
- package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
- package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
- package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
- package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
- package/pi-extensions/lib/meta-session.ts +1951 -0
- package/pi-extensions/lib/project-trust-handler.ts +154 -0
- package/pi-extensions/lib/session-id.js +57 -0
- package/pi-extensions/lib/socket-discovery.ts +346 -0
- package/pi-extensions/lib/socket-probe.ts +92 -0
- package/pi-extensions/meta-bridge-hook.ts +250 -0
- package/pi-extensions/model-lock.ts +235 -0
- package/prompts/engraving.md +27 -0
- package/protocol.js +31 -0
- package/run.sh +2832 -0
- package/scripts/check-acp-backend-preflight.ts +130 -0
- package/scripts/check-acp-carrier-augment.ts +297 -0
- package/scripts/check-acp-config.ts +322 -0
- package/scripts/check-acp-event-mapper.ts +253 -0
- package/scripts/check-acp-overlay.ts +154 -0
- package/scripts/check-acp-prompt-builder.ts +191 -0
- package/scripts/check-acp-provider-surface.ts +159 -0
- package/scripts/check-acp-sdk-surface.ts +211 -0
- package/scripts/check-acp-session-reuse.ts +756 -0
- package/scripts/check-acp-session-store.ts +387 -0
- package/scripts/check-acp-tool-surface.ts +159 -0
- package/scripts/check-entwurf-bridge-boot.ts +199 -0
- package/scripts/check-entwurf-capabilities.ts +123 -0
- package/scripts/check-entwurf-control-rpc.ts +187 -0
- package/scripts/check-entwurf-deliverability.ts +158 -0
- package/scripts/check-entwurf-fact-provider.ts +316 -0
- package/scripts/check-entwurf-facts.ts +358 -0
- package/scripts/check-entwurf-mailbox-guard.ts +264 -0
- package/scripts/check-entwurf-peers-surface.ts +235 -0
- package/scripts/check-entwurf-resume-args.ts +149 -0
- package/scripts/check-entwurf-self-address.ts +209 -0
- package/scripts/check-entwurf-session-identity.ts +703 -0
- package/scripts/check-entwurf-v2-contract.ts +536 -0
- package/scripts/check-entwurf-v2-decider.ts +795 -0
- package/scripts/check-entwurf-v2-lock.ts +368 -0
- package/scripts/check-entwurf-v2-mailbox.ts +228 -0
- package/scripts/check-entwurf-v2-matrix.ts +437 -0
- package/scripts/check-entwurf-v2-production.ts +406 -0
- package/scripts/check-entwurf-v2-release.ts +245 -0
- package/scripts/check-entwurf-v2-runner.ts +452 -0
- package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
- package/scripts/check-entwurf-v2-send.ts +454 -0
- package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
- package/scripts/check-entwurf-v2-spawn.ts +398 -0
- package/scripts/check-entwurf-v2-surface.ts +352 -0
- package/scripts/check-keyset-overlap.py +120 -0
- package/scripts/check-mailbox-receipt-state.ts +170 -0
- package/scripts/check-meta-capability-source.ts +112 -0
- package/scripts/check-meta-dual-consumers.ts +154 -0
- package/scripts/check-meta-dual-read.ts +158 -0
- package/scripts/check-meta-listing.ts +138 -0
- package/scripts/check-meta-mailbox-state-write.ts +135 -0
- package/scripts/check-meta-migration.ts +212 -0
- package/scripts/check-meta-receiver-marker.ts +185 -0
- package/scripts/check-meta-record-v2.ts +191 -0
- package/scripts/check-meta-session.ts +673 -0
- package/scripts/check-model-lock.ts +408 -0
- package/scripts/check-package-source-routing.ts +253 -0
- package/scripts/check-pi-preflight.ts +304 -0
- package/scripts/check-project-trust-handler.ts +265 -0
- package/scripts/check-shell-quote.ts +121 -0
- package/scripts/check-socket-discovery.ts +428 -0
- package/scripts/check-socket-probe.ts +106 -0
- package/scripts/fixtures/probe-mcp-server.ts +33 -0
- package/scripts/gnew-rpc-drive.ts +211 -0
- package/scripts/lib/acp-child-cleanup.ts +116 -0
- package/scripts/meta-bridge-doctor.sh +315 -0
- package/scripts/meta-bridge-hook-log.sh +26 -0
- package/scripts/meta-bridge-install.sh +135 -0
- package/scripts/meta-bridge-prune.ts +199 -0
- package/scripts/meta-bridge-state.py +549 -0
- package/scripts/meta-bridge-statusline.sh +192 -0
- package/scripts/meta-bridge-store-doctor.ts +64 -0
- package/scripts/meta-bridge-uninstall.sh +39 -0
- package/scripts/new-session-id.ts +25 -0
- package/scripts/postinstall-chmod.cjs +58 -0
- package/scripts/raw-async-delivery/README.md +258 -0
- package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
- package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
- package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
- package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
- package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
- package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
- package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
- package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
- package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
- package/scripts/resolve-acp-bridge.ts +25 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
- package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
- package/scripts/smoke-acp-mcp-live.ts +129 -0
- package/scripts/smoke-acp-memory-containment-live.ts +389 -0
- package/scripts/smoke-acp-overlay-live.ts +314 -0
- package/scripts/smoke-acp-provider-live.ts +162 -0
- package/scripts/smoke-acp-raw-turn-live.ts +261 -0
- package/scripts/smoke-acp-session-reuse-live.ts +172 -0
- package/scripts/smoke-acp-skill-live.ts +144 -0
- package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
- package/scripts/smoke-claude-native-resume-live.sh +198 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
- package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
- package/scripts/smoke-meta-async-drift.sh +171 -0
- package/scripts/smoke-meta-honesty.sh +147 -0
- package/scripts/smoke-meta-install-state.sh +403 -0
- package/scripts/smoke-meta-keyset-guard.sh +111 -0
- package/scripts/smoke-meta-prune.sh +174 -0
- package/scripts/smoke-resident-garden-guard.sh +433 -0
- package/scripts/smoke-session-id-name.ts +187 -0
- package/scripts/tsconfig.json +34 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stateful install/uninstall config manager for entwurf meta-bridge.
|
|
3
|
+
|
|
4
|
+
This is the Phase-2 honesty core: install records exactly what it touched before
|
|
5
|
+
writing, and uninstall restores/removes only those keys/items. No blind jq merge.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import copy
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
PLUGIN = "entwurf-meta-receive"
|
|
20
|
+
MARKETPLACE = "meta-bridge-local"
|
|
21
|
+
PLUGIN_REF = f"{PLUGIN}@{MARKETPLACE}"
|
|
22
|
+
STATE_VERSION = 1
|
|
23
|
+
OWNER = "entwurf meta-bridge"
|
|
24
|
+
|
|
25
|
+
PERMISSION_ALLOW = [
|
|
26
|
+
"Bash",
|
|
27
|
+
"Read",
|
|
28
|
+
"Write",
|
|
29
|
+
"Edit",
|
|
30
|
+
"Grep",
|
|
31
|
+
"Glob",
|
|
32
|
+
"WebFetch",
|
|
33
|
+
"WebSearch",
|
|
34
|
+
"Skill",
|
|
35
|
+
"mcp__entwurf-bridge__*",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
PERMISSION_DENY = [
|
|
39
|
+
"Agent",
|
|
40
|
+
"AskUserQuestion",
|
|
41
|
+
"CronCreate",
|
|
42
|
+
"CronDelete",
|
|
43
|
+
"CronList",
|
|
44
|
+
"EnterPlanMode",
|
|
45
|
+
"ExitPlanMode",
|
|
46
|
+
"EnterWorktree",
|
|
47
|
+
"ExitWorktree",
|
|
48
|
+
"Monitor",
|
|
49
|
+
"NotebookEdit",
|
|
50
|
+
"PushNotification",
|
|
51
|
+
"TaskCreate",
|
|
52
|
+
"TaskGet",
|
|
53
|
+
"TaskList",
|
|
54
|
+
"TaskOutput",
|
|
55
|
+
"TaskStop",
|
|
56
|
+
"TaskUpdate",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# Legacy permission-allow items from the pre-rename bridge name (pi-tools-bridge
|
|
60
|
+
# → entwurf-bridge). apply() prunes these so an old install's stale allow does
|
|
61
|
+
# not linger forever (append_unique only adds; it never removes). Parallel to
|
|
62
|
+
# install.sh's one-shot `claude mcp remove pi-tools-bridge`. Uninstall does not
|
|
63
|
+
# restore them — the tool no longer exists under the old name, and they were
|
|
64
|
+
# never user-authored items.
|
|
65
|
+
LEGACY_PERMISSION_ALLOW = ["mcp__pi-tools-bridge__*"]
|
|
66
|
+
|
|
67
|
+
# Claude Code single-driver policy scalars owned by entwurf for the native
|
|
68
|
+
# meta-bridge install. These are not theming/personal hooks; they close background
|
|
69
|
+
# autonomy/suggestion/compaction surfaces so Claude Code behaves like the same
|
|
70
|
+
# single forged screwdriver that entwurf already enforces for ACP backends.
|
|
71
|
+
MANAGED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
|
|
72
|
+
("cleanupPeriodDays", ["cleanupPeriodDays"], 365),
|
|
73
|
+
("env.DISABLE_AUTOCOMPACT", ["env", "DISABLE_AUTOCOMPACT"], "1"),
|
|
74
|
+
("promptSuggestionEnabled", ["promptSuggestionEnabled"], False),
|
|
75
|
+
("awaySummaryEnabled", ["awaySummaryEnabled"], False),
|
|
76
|
+
("autoMemoryEnabled", ["autoMemoryEnabled"], False),
|
|
77
|
+
("skipDangerousModePermissionPrompt", ["skipDangerousModePermissionPrompt"], True),
|
|
78
|
+
("verbose", ["verbose"], False),
|
|
79
|
+
("autoCompactEnabled", ["autoCompactEnabled"], False),
|
|
80
|
+
("showTurnDuration", ["showTurnDuration"], False),
|
|
81
|
+
("terminalProgressBarEnabled", ["terminalProgressBarEnabled"], False),
|
|
82
|
+
("useAutoModeDuringPlan", ["useAutoModeDuringPlan"], False),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class StateError(RuntimeError):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def die(msg: str) -> None:
|
|
91
|
+
raise StateError(msg)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def iso_now() -> str:
|
|
95
|
+
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def claude_config_dir() -> Path:
|
|
99
|
+
raw = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
100
|
+
return Path(raw).expanduser().resolve() if raw else (Path.home() / ".claude")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def settings_path() -> Path:
|
|
104
|
+
return claude_config_dir() / "settings.json"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def state_path() -> Path:
|
|
108
|
+
return claude_config_dir() / "entwurf.install-state.json"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def claude_root_config_path() -> Path:
|
|
112
|
+
# Claude Code user-scope MCP is stored in ~/.claude.json (not in
|
|
113
|
+
# ~/.claude/settings.json). In tests HOME is isolated, so this remains safe.
|
|
114
|
+
return Path.home() / ".claude.json"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def read_json(path: Path, default: Any) -> Any:
|
|
118
|
+
if not path.exists():
|
|
119
|
+
return copy.deepcopy(default)
|
|
120
|
+
try:
|
|
121
|
+
with path.open("r", encoding="utf-8") as f:
|
|
122
|
+
return json.load(f)
|
|
123
|
+
except json.JSONDecodeError as exc:
|
|
124
|
+
die(f"{path} is not valid JSON: {exc}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def write_json(path: Path, value: Any, mode: int | None = None) -> None:
|
|
128
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
tmp = path.with_name(f".{path.name}.tmp-{os.getpid()}")
|
|
130
|
+
with tmp.open("w", encoding="utf-8") as f:
|
|
131
|
+
json.dump(value, f, ensure_ascii=False, indent=2)
|
|
132
|
+
f.write("\n")
|
|
133
|
+
if mode is not None:
|
|
134
|
+
os.chmod(tmp, mode)
|
|
135
|
+
tmp.replace(path)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def load_state(required: bool) -> dict[str, Any] | None:
|
|
139
|
+
path = state_path()
|
|
140
|
+
if not path.exists():
|
|
141
|
+
if required:
|
|
142
|
+
die(f"install state missing: {path}. Honest uninstall/check requires the state file.")
|
|
143
|
+
return None
|
|
144
|
+
state = read_json(path, {})
|
|
145
|
+
if not isinstance(state, dict) or state.get("schemaVersion") != STATE_VERSION or state.get("owner") != OWNER:
|
|
146
|
+
die(f"invalid install state schema: {path}")
|
|
147
|
+
return state
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def init_state(repo: Path, asm: Path) -> dict[str, Any]:
|
|
151
|
+
return {
|
|
152
|
+
"schemaVersion": STATE_VERSION,
|
|
153
|
+
"owner": OWNER,
|
|
154
|
+
"createdAt": iso_now(),
|
|
155
|
+
"updatedAt": iso_now(),
|
|
156
|
+
"repo": str(repo.resolve()),
|
|
157
|
+
"assembledMarketplacePath": str(asm.resolve()),
|
|
158
|
+
"files": {
|
|
159
|
+
"settings": {"path": str(settings_path()), "keys": {}},
|
|
160
|
+
"claudeRoot": {"path": str(claude_root_config_path()), "keys": {}},
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def ensure_object(parent: dict[str, Any], key: str, label: str) -> dict[str, Any]:
|
|
166
|
+
value = parent.get(key)
|
|
167
|
+
if value is None:
|
|
168
|
+
value = {}
|
|
169
|
+
parent[key] = value
|
|
170
|
+
if not isinstance(value, dict):
|
|
171
|
+
die(f"{label} must be an object before entwurf can manage a child key")
|
|
172
|
+
return value
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_nested(obj: dict[str, Any], path: list[str]) -> tuple[bool, Any]:
|
|
176
|
+
cur: Any = obj
|
|
177
|
+
for key in path[:-1]:
|
|
178
|
+
if not isinstance(cur, dict) or key not in cur:
|
|
179
|
+
return False, None
|
|
180
|
+
cur = cur[key]
|
|
181
|
+
if not isinstance(cur, dict) or path[-1] not in cur:
|
|
182
|
+
return False, None
|
|
183
|
+
return True, cur[path[-1]]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def set_nested(obj: dict[str, Any], path: list[str], value: Any) -> None:
|
|
187
|
+
cur = obj
|
|
188
|
+
for key in path[:-1]:
|
|
189
|
+
cur = ensure_object(cur, key, ".".join(path[:-1]))
|
|
190
|
+
cur[path[-1]] = value
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def delete_nested(obj: dict[str, Any], path: list[str]) -> None:
|
|
194
|
+
cur: Any = obj
|
|
195
|
+
parents: list[tuple[dict[str, Any], str]] = []
|
|
196
|
+
for key in path[:-1]:
|
|
197
|
+
if not isinstance(cur, dict) or key not in cur:
|
|
198
|
+
return
|
|
199
|
+
parents.append((cur, key))
|
|
200
|
+
cur = cur[key]
|
|
201
|
+
if isinstance(cur, dict):
|
|
202
|
+
cur.pop(path[-1], None)
|
|
203
|
+
# Prune empty objects created only for our key path. Stop before deleting the
|
|
204
|
+
# root document.
|
|
205
|
+
for parent, key in reversed(parents):
|
|
206
|
+
child = parent.get(key)
|
|
207
|
+
if isinstance(child, dict) and not child:
|
|
208
|
+
parent.pop(key, None)
|
|
209
|
+
else:
|
|
210
|
+
break
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def snapshot_value(
|
|
214
|
+
state: dict[str, Any],
|
|
215
|
+
file_key: str,
|
|
216
|
+
name: str,
|
|
217
|
+
obj: dict[str, Any],
|
|
218
|
+
path: list[str],
|
|
219
|
+
kind: str,
|
|
220
|
+
legacy_absent_if_equal: Any = None,
|
|
221
|
+
) -> None:
|
|
222
|
+
keys = state["files"][file_key]["keys"]
|
|
223
|
+
if name in keys:
|
|
224
|
+
return
|
|
225
|
+
existed, value = get_nested(obj, path)
|
|
226
|
+
# Phase-2 migration path: GLG's machine already had the Phase-0/1 tribal
|
|
227
|
+
# installer live before the state file existed. If an exact entwurf
|
|
228
|
+
# managed value is present with no state, treating it as "original" would make
|
|
229
|
+
# uninstall restore the legacy install instead of removing it. Exact-match
|
|
230
|
+
# migration is limited to pi-owned identity keys (plugin/marketplace/MCP), not
|
|
231
|
+
# user-like policy keys such as permissions/env.
|
|
232
|
+
if existed and legacy_absent_if_equal is not None and value == legacy_absent_if_equal:
|
|
233
|
+
existed = False
|
|
234
|
+
value = None
|
|
235
|
+
keys[name] = {"kind": kind, "path": path, "original": {"existed": existed, "value": copy.deepcopy(value)}}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def snapshot_array_items(
|
|
239
|
+
state: dict[str, Any], file_key: str, name: str, obj: dict[str, Any], path: list[str], desired: list[str]
|
|
240
|
+
) -> None:
|
|
241
|
+
keys = state["files"][file_key]["keys"]
|
|
242
|
+
existed, value = get_nested(obj, path)
|
|
243
|
+
if existed and not isinstance(value, list):
|
|
244
|
+
die(f"{'.'.join(path)} exists but is not an array; refusing to merge managed permission items")
|
|
245
|
+
current = value if isinstance(value, list) else []
|
|
246
|
+
if name in keys:
|
|
247
|
+
# Preserve the first-install additions, but if the managed baseline grew
|
|
248
|
+
# since then, add only the truly new managed entries.
|
|
249
|
+
entry = keys[name]
|
|
250
|
+
if entry.get("kind") != "array-items":
|
|
251
|
+
die(f"state entry {name} kind mismatch")
|
|
252
|
+
added = list(entry.get("added", []))
|
|
253
|
+
original_values = set(current) - set(added)
|
|
254
|
+
for item in desired:
|
|
255
|
+
if item not in original_values and item not in added:
|
|
256
|
+
added.append(item)
|
|
257
|
+
entry["added"] = added
|
|
258
|
+
return
|
|
259
|
+
added = [item for item in desired if item not in current]
|
|
260
|
+
keys[name] = {"kind": "array-items", "path": path, "originalExisted": existed, "added": added}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def desired_marketplace(asm: Path) -> dict[str, Any]:
|
|
264
|
+
return {"source": {"source": "directory", "path": str(asm.resolve())}}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def desired_mcp(repo: Path) -> dict[str, Any]:
|
|
268
|
+
return {
|
|
269
|
+
"type": "stdio",
|
|
270
|
+
"command": "bash",
|
|
271
|
+
"args": [str((repo / "mcp" / "entwurf-bridge" / "start.sh").resolve())],
|
|
272
|
+
"env": {
|
|
273
|
+
"ENTWURF_BRIDGE_EXTERNAL_AGENT_ID": "external-mcp/claude-code",
|
|
274
|
+
# Anonymous sends are forbidden on the Claude Code install path: a send
|
|
275
|
+
# with no pi-session identity AND no meta-sender marker is refused, not
|
|
276
|
+
# delivered as an unidentified external. The SessionStart hook writes
|
|
277
|
+
# the marker (parent-pid keyed), so a normally-opened session always has
|
|
278
|
+
# an authoritative garden-id sender.
|
|
279
|
+
"ENTWURF_BRIDGE_REQUIRE_META_SENDER": "1",
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def desired_statusline(repo: Path) -> dict[str, Any]:
|
|
285
|
+
return {"type": "command", "command": str((repo / "scripts" / "meta-bridge-statusline.sh").resolve())}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def prepare(repo: Path, asm: Path) -> None:
|
|
289
|
+
existing = load_state(required=False)
|
|
290
|
+
state = existing if existing is not None else init_state(repo, asm)
|
|
291
|
+
state["updatedAt"] = iso_now()
|
|
292
|
+
state["repo"] = str(repo.resolve())
|
|
293
|
+
state["assembledMarketplacePath"] = str(asm.resolve())
|
|
294
|
+
|
|
295
|
+
settings = read_json(settings_path(), {})
|
|
296
|
+
root = read_json(claude_root_config_path(), {})
|
|
297
|
+
if not isinstance(settings, dict):
|
|
298
|
+
die(f"{settings_path()} root must be a JSON object")
|
|
299
|
+
if not isinstance(root, dict):
|
|
300
|
+
die(f"{claude_root_config_path()} root must be a JSON object")
|
|
301
|
+
|
|
302
|
+
snapshot_value(
|
|
303
|
+
state,
|
|
304
|
+
"settings",
|
|
305
|
+
f"enabledPlugins.{PLUGIN_REF}",
|
|
306
|
+
settings,
|
|
307
|
+
["enabledPlugins", PLUGIN_REF],
|
|
308
|
+
"map-entry",
|
|
309
|
+
legacy_absent_if_equal=True,
|
|
310
|
+
)
|
|
311
|
+
snapshot_value(
|
|
312
|
+
state,
|
|
313
|
+
"settings",
|
|
314
|
+
f"extraKnownMarketplaces.{MARKETPLACE}",
|
|
315
|
+
settings,
|
|
316
|
+
["extraKnownMarketplaces", MARKETPLACE],
|
|
317
|
+
"map-entry",
|
|
318
|
+
legacy_absent_if_equal=desired_marketplace(asm),
|
|
319
|
+
)
|
|
320
|
+
snapshot_array_items(state, "settings", "permissions.allow", settings, ["permissions", "allow"], PERMISSION_ALLOW)
|
|
321
|
+
snapshot_array_items(state, "settings", "permissions.deny", settings, ["permissions", "deny"], PERMISSION_DENY)
|
|
322
|
+
for name, path_, _desired in MANAGED_SETTINGS_SCALARS:
|
|
323
|
+
snapshot_value(state, "settings", name, settings, path_, "scalar")
|
|
324
|
+
snapshot_value(
|
|
325
|
+
state,
|
|
326
|
+
"settings",
|
|
327
|
+
"statusLine",
|
|
328
|
+
settings,
|
|
329
|
+
["statusLine"],
|
|
330
|
+
"map-entry",
|
|
331
|
+
legacy_absent_if_equal=desired_statusline(repo),
|
|
332
|
+
)
|
|
333
|
+
snapshot_value(
|
|
334
|
+
state,
|
|
335
|
+
"claudeRoot",
|
|
336
|
+
"mcpServers.entwurf-bridge",
|
|
337
|
+
root,
|
|
338
|
+
["mcpServers", "entwurf-bridge"],
|
|
339
|
+
"map-entry",
|
|
340
|
+
legacy_absent_if_equal=desired_mcp(repo),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
write_json(state_path(), state, mode=0o600)
|
|
344
|
+
print(f"[meta-bridge-state] prepared {state_path()}")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def append_unique(current: list[Any], additions: list[str]) -> list[Any]:
|
|
348
|
+
out = list(current)
|
|
349
|
+
for item in additions:
|
|
350
|
+
if item not in out:
|
|
351
|
+
out.append(item)
|
|
352
|
+
return out
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def apply(repo: Path, asm: Path) -> None:
|
|
356
|
+
state = load_state(required=True)
|
|
357
|
+
assert state is not None
|
|
358
|
+
settings = read_json(settings_path(), {})
|
|
359
|
+
root = read_json(claude_root_config_path(), {})
|
|
360
|
+
if not isinstance(settings, dict):
|
|
361
|
+
die(f"{settings_path()} root must be a JSON object")
|
|
362
|
+
if not isinstance(root, dict):
|
|
363
|
+
die(f"{claude_root_config_path()} root must be a JSON object")
|
|
364
|
+
|
|
365
|
+
set_nested(settings, ["enabledPlugins", PLUGIN_REF], True)
|
|
366
|
+
set_nested(settings, ["extraKnownMarketplaces", MARKETPLACE], desired_marketplace(asm))
|
|
367
|
+
for path_, desired in [(["permissions", "allow"], PERMISSION_ALLOW), (["permissions", "deny"], PERMISSION_DENY)]:
|
|
368
|
+
existed, value = get_nested(settings, path_)
|
|
369
|
+
if existed and not isinstance(value, list):
|
|
370
|
+
die(f"{'.'.join(path_)} exists but is not an array; refusing to merge managed permission items")
|
|
371
|
+
merged = append_unique(value if isinstance(value, list) else [], desired)
|
|
372
|
+
if path_ == ["permissions", "allow"]:
|
|
373
|
+
merged = [item for item in merged if item not in LEGACY_PERMISSION_ALLOW]
|
|
374
|
+
set_nested(settings, path_, merged)
|
|
375
|
+
for _name, path_, desired in MANAGED_SETTINGS_SCALARS:
|
|
376
|
+
set_nested(settings, path_, desired)
|
|
377
|
+
set_nested(settings, ["statusLine"], desired_statusline(repo))
|
|
378
|
+
set_nested(root, ["mcpServers", "entwurf-bridge"], desired_mcp(repo))
|
|
379
|
+
|
|
380
|
+
state["updatedAt"] = iso_now()
|
|
381
|
+
state["repo"] = str(repo.resolve())
|
|
382
|
+
state["assembledMarketplacePath"] = str(asm.resolve())
|
|
383
|
+
write_json(settings_path(), settings)
|
|
384
|
+
write_json(claude_root_config_path(), root)
|
|
385
|
+
write_json(state_path(), state, mode=0o600)
|
|
386
|
+
print("[meta-bridge-state] applied managed keyset (settings.json + user MCP)")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def restore_entry(obj: dict[str, Any], entry: dict[str, Any]) -> None:
|
|
390
|
+
path = entry.get("path")
|
|
391
|
+
if not isinstance(path, list) or not all(isinstance(p, str) for p in path):
|
|
392
|
+
die("bad state entry path")
|
|
393
|
+
kind = entry.get("kind")
|
|
394
|
+
if kind in ("map-entry", "scalar"):
|
|
395
|
+
original = entry.get("original")
|
|
396
|
+
if not isinstance(original, dict):
|
|
397
|
+
die("bad scalar/map original in state")
|
|
398
|
+
if original.get("existed"):
|
|
399
|
+
set_nested(obj, path, copy.deepcopy(original.get("value")))
|
|
400
|
+
else:
|
|
401
|
+
delete_nested(obj, path)
|
|
402
|
+
return
|
|
403
|
+
if kind == "array-items":
|
|
404
|
+
existed, value = get_nested(obj, path)
|
|
405
|
+
if existed and not isinstance(value, list):
|
|
406
|
+
die(f"{'.'.join(path)} exists but is not an array; refusing to uninstall managed permission items")
|
|
407
|
+
added = entry.get("added", [])
|
|
408
|
+
if not isinstance(added, list):
|
|
409
|
+
die("bad array added list in state")
|
|
410
|
+
current = value if isinstance(value, list) else []
|
|
411
|
+
remaining = [item for item in current if item not in added]
|
|
412
|
+
if remaining or entry.get("originalExisted"):
|
|
413
|
+
set_nested(obj, path, remaining)
|
|
414
|
+
else:
|
|
415
|
+
delete_nested(obj, path)
|
|
416
|
+
return
|
|
417
|
+
die(f"unknown state entry kind: {kind}")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def preflight_uninstall() -> None:
|
|
421
|
+
load_state(required=True)
|
|
422
|
+
print(f"[meta-bridge-state] uninstall preflight ok ({state_path()})")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def uninstall() -> None:
|
|
426
|
+
state = load_state(required=True)
|
|
427
|
+
assert state is not None
|
|
428
|
+
settings = read_json(settings_path(), {})
|
|
429
|
+
root = read_json(claude_root_config_path(), {})
|
|
430
|
+
if not isinstance(settings, dict):
|
|
431
|
+
die(f"{settings_path()} root must be a JSON object")
|
|
432
|
+
if not isinstance(root, dict):
|
|
433
|
+
die(f"{claude_root_config_path()} root must be a JSON object")
|
|
434
|
+
|
|
435
|
+
for entry in state["files"]["settings"]["keys"].values():
|
|
436
|
+
restore_entry(settings, entry)
|
|
437
|
+
for entry in state["files"]["claudeRoot"]["keys"].values():
|
|
438
|
+
restore_entry(root, entry)
|
|
439
|
+
|
|
440
|
+
write_json(settings_path(), settings)
|
|
441
|
+
write_json(claude_root_config_path(), root)
|
|
442
|
+
state_path().unlink(missing_ok=True)
|
|
443
|
+
print("[meta-bridge-state] restored managed keyset and removed install state")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def managed_keys() -> dict[str, Any]:
|
|
447
|
+
"""The SSOT of settings.json / ~/.claude.json keys entwurf OWNS.
|
|
448
|
+
|
|
449
|
+
Derived from the same constants install/apply/check use, so there is one
|
|
450
|
+
source of truth for "which keys are ours". Cross-repo consumers (the keyset
|
|
451
|
+
overlap guard, agent-config's fragment) read THIS to know which keys they
|
|
452
|
+
must NOT also set — the keyset-owner invariant ("each side sets only its own
|
|
453
|
+
keys, never breaks the other's"). Paths are dotted; a parent path (e.g.
|
|
454
|
+
`statusLine`) owns the whole subtree below it.
|
|
455
|
+
"""
|
|
456
|
+
return {
|
|
457
|
+
"owner": OWNER,
|
|
458
|
+
"settings": {
|
|
459
|
+
"scalar": [name for name, _path, _desired in MANAGED_SETTINGS_SCALARS],
|
|
460
|
+
"array-items": ["permissions.allow", "permissions.deny"],
|
|
461
|
+
"map-entry": [
|
|
462
|
+
f"enabledPlugins.{PLUGIN_REF}",
|
|
463
|
+
f"extraKnownMarketplaces.{MARKETPLACE}",
|
|
464
|
+
"statusLine",
|
|
465
|
+
],
|
|
466
|
+
},
|
|
467
|
+
"claudeRoot": {
|
|
468
|
+
"map-entry": ["mcpServers.entwurf-bridge"],
|
|
469
|
+
},
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def check(repo: Path, asm: Path) -> None:
|
|
474
|
+
state = load_state(required=True)
|
|
475
|
+
assert state is not None
|
|
476
|
+
settings = read_json(settings_path(), {})
|
|
477
|
+
root = read_json(claude_root_config_path(), {})
|
|
478
|
+
failures: list[str] = []
|
|
479
|
+
if not isinstance(settings, dict):
|
|
480
|
+
failures.append(f"{settings_path()} root is not an object")
|
|
481
|
+
settings = {}
|
|
482
|
+
if not isinstance(root, dict):
|
|
483
|
+
failures.append(f"{claude_root_config_path()} root is not an object")
|
|
484
|
+
root = {}
|
|
485
|
+
|
|
486
|
+
checks = [
|
|
487
|
+
(["enabledPlugins", PLUGIN_REF], True, "enabled plugin"),
|
|
488
|
+
(["extraKnownMarketplaces", MARKETPLACE], desired_marketplace(asm), "known marketplace"),
|
|
489
|
+
(["statusLine"], desired_statusline(repo), "statusLine"),
|
|
490
|
+
] + [(path_, desired, name) for name, path_, desired in MANAGED_SETTINGS_SCALARS]
|
|
491
|
+
for path_, expected, label in checks:
|
|
492
|
+
existed, value = get_nested(settings, path_)
|
|
493
|
+
if not existed or value != expected:
|
|
494
|
+
failures.append(f"settings {label} missing/drifted at {'.'.join(path_)}")
|
|
495
|
+
for path_, desired, label in [(["permissions", "allow"], PERMISSION_ALLOW, "allow"), (["permissions", "deny"], PERMISSION_DENY, "deny")]:
|
|
496
|
+
existed, value = get_nested(settings, path_)
|
|
497
|
+
if not existed or not isinstance(value, list):
|
|
498
|
+
failures.append(f"settings permissions.{label} missing/not array")
|
|
499
|
+
else:
|
|
500
|
+
missing = [item for item in desired if item not in value]
|
|
501
|
+
if missing:
|
|
502
|
+
failures.append(f"settings permissions.{label} missing managed item(s): {', '.join(missing)}")
|
|
503
|
+
if path_ == ["permissions", "allow"]:
|
|
504
|
+
legacy = [item for item in LEGACY_PERMISSION_ALLOW if item in value]
|
|
505
|
+
if legacy:
|
|
506
|
+
failures.append(f"settings permissions.allow carries pruned legacy item(s): {', '.join(legacy)} (re-inject — re-run install-meta-bridge to prune)")
|
|
507
|
+
existed, value = get_nested(root, ["mcpServers", "entwurf-bridge"])
|
|
508
|
+
if not existed or value != desired_mcp(repo):
|
|
509
|
+
failures.append("user MCP entwurf-bridge missing/drifted in ~/.claude.json")
|
|
510
|
+
|
|
511
|
+
if failures:
|
|
512
|
+
for f in failures:
|
|
513
|
+
print(f"FAIL: {f}", file=sys.stderr)
|
|
514
|
+
raise SystemExit(1)
|
|
515
|
+
print(f"[meta-bridge-state] check ok ({state_path()})")
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def main() -> int:
|
|
519
|
+
parser = argparse.ArgumentParser(description="entwurf meta-bridge state manager")
|
|
520
|
+
parser.add_argument(
|
|
521
|
+
"command",
|
|
522
|
+
choices=["prepare", "apply", "preflight-uninstall", "uninstall", "check", "managed-keys"],
|
|
523
|
+
)
|
|
524
|
+
parser.add_argument("--repo", default=Path(__file__).resolve().parents[1], type=Path)
|
|
525
|
+
parser.add_argument("--asm", default=None, type=Path)
|
|
526
|
+
args = parser.parse_args()
|
|
527
|
+
repo = args.repo.resolve()
|
|
528
|
+
asm = (args.asm or (repo / "pi" / "meta-bridge" / ".assembled")).resolve()
|
|
529
|
+
try:
|
|
530
|
+
if args.command == "prepare":
|
|
531
|
+
prepare(repo, asm)
|
|
532
|
+
elif args.command == "apply":
|
|
533
|
+
apply(repo, asm)
|
|
534
|
+
elif args.command == "preflight-uninstall":
|
|
535
|
+
preflight_uninstall()
|
|
536
|
+
elif args.command == "uninstall":
|
|
537
|
+
uninstall()
|
|
538
|
+
elif args.command == "check":
|
|
539
|
+
check(repo, asm)
|
|
540
|
+
elif args.command == "managed-keys":
|
|
541
|
+
print(json.dumps(managed_keys(), indent=2))
|
|
542
|
+
except StateError as exc:
|
|
543
|
+
print(f"meta-bridge-state: {exc}", file=sys.stderr)
|
|
544
|
+
return 1
|
|
545
|
+
return 0
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
if __name__ == "__main__":
|
|
549
|
+
raise SystemExit(main())
|