@junghanacs/entwurf 0.12.5 → 0.12.6
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/CHANGELOG.md +22 -0
- package/VERIFY.md +18 -28
- package/package.json +2 -3
- package/run.sh +224 -40
- package/scripts/__pycache__/meta-bridge-state.cpython-313.pyc +0 -0
- package/scripts/__pycache__/register-pi-package.cpython-313.pyc +0 -0
- package/scripts/meta-bridge-doctor.sh +11 -7
- package/scripts/meta-bridge-install.sh +20 -17
- package/scripts/meta-bridge-state.py +40 -2
- package/scripts/meta-bridge-uninstall.sh +23 -12
- package/scripts/register-pi-package.py +149 -0
- package/scripts/smoke-meta-install-state.sh +158 -8
- package/scripts/smoke-user-scope-citizen.sh +129 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ All notable changes to this project will be documented here. Format follows [Kee
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.12.6 — 2026-07-03
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Claude Code meta-bridge live marketplace sources now live outside the checkout for dev and npm installs alike.** Development clones and installed packages both assemble the plugin bundle under `$XDG_DATA_HOME/entwurf/meta-bridge/.assembled`; the checkout or `node_modules` tree is now only the source origin. This removes the class where `pnpm check`, `git clean -xfd`, or a repo-local uninstall smoke could delete a user-scope Claude marketplace source and leave new Claude Code sessions at statusline `?`. The installer still chooses `.ts` vs compiled `.js` hook artifacts by source origin, but the live registration path is the same XDG artifact lane.
|
|
12
|
+
- **Meta-bridge uninstall now uses the recorded live-artifact path and fails before side effects when the state is corrupt.** The install state's `assembledMarketplacePath` is the SSOT for uninstall, doctor, and state checks. Uninstall validates the full `…/entwurf/meta-bridge/.assembled` suffix before removing Claude plugin/MCP registrations or deleting state, so malformed paths such as `/` or `…/entwurf/meta-bridge/not-assembled` fail loud with zero Claude side effects and leave state/artifacts intact. `state.py check` also rejects missing, empty, or malformed recorded paths even when settings agree with the corruption.
|
|
13
|
+
- **User-scope pi package registration is now part of setup.** `./run.sh setup` registers `entwurf` in user-scope pi settings so `--entwurf-control` and the provider surface load from foreign cwd sessions, not only from a project-local `.pi/settings.json`. A hermetic `smoke-user-scope-citizen` covers idempotency, stale normalization, remove symmetry, unrelated-key preservation, and corrupt-shape fail-loud behavior.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **The install toolchain is now aligned to one pnpm 11 lane and one setup surface.** The repo dropped the obsolete `packageManager` pin and `.npmrc`, moved pnpm policy to `pnpm-workspace.yaml`, updated CI to pnpm 11.9, and treats `./run.sh setup` as the single local install path instead of a split `pi install` flow.
|
|
18
|
+
- **The next delivery lane is pinned before implementation.** `NEXT.md` now records the agy/Antigravity delivery adapter constraints: direct-inject/native-push is separate from pi's control-socket liveness domain, registration is explicit, receiver markers are not reused for native-push replyability, and install/doctor/uninstall must inherit the source-origin/live-artifact boundary proven by this release.
|
|
19
|
+
- **The future fresh-spawn lane stays mux-visible by default.** The mux driver plan now keeps minting and mux transport separate, treats tmux/zmx as drivers behind a leaf interface, and keeps fresh pi-native GPT launch visible instead of reviving hidden background one-shots as the default.
|
|
20
|
+
|
|
21
|
+
### Verification
|
|
22
|
+
|
|
23
|
+
- `pnpm check` passed on 2026-07-03 after the XDG live-artifact move, recorded-path uninstall hardening, user-scope citizen smoke, pnpm 11 setup cleanup, and NEXT lane updates.
|
|
24
|
+
- `./run.sh smoke-meta-install-state` passed with the new direct install→XDG proof, recorded-path mismatch case, corrupt basename/missing-field fail-loud cases, and checkout-internal `.assembled` boundary fingerprint.
|
|
25
|
+
- `./run.sh doctor-meta-bridge` passed on 2026-07-03 with source/assembled/installed writer parity all v2 and the assembled bundle under `$XDG_DATA_HOME/entwurf/meta-bridge/.assembled`.
|
|
26
|
+
- `./run.sh check-pack-install` passed on 2026-07-03, proving the npm consumer path keeps the stable XDG marketplace source and user-scope pi citizen registration.
|
|
27
|
+
- `LIVE=1 ./run.sh release-gate /tmp/psa-release-gate-0.12.6.ddCcmz` passed on 2026-07-03; log `/tmp/pi-tmux-entwurf-release-gate-0126.log`; summary `MUST: PASS=17 FAIL=0 SKIP=0`, advisory `BEHAVIOR: PASS=0 FAIL=1` (`smoke-resident-garden-guard` positive `/gnew T3` model-in-loop identity turn; non-blocking).
|
|
28
|
+
|
|
7
29
|
## 0.12.5 — 2026-07-01
|
|
8
30
|
|
|
9
31
|
### Fixed
|
package/VERIFY.md
CHANGED
|
@@ -88,46 +88,36 @@ The goal is not merely "invoke Claude Code." We want:
|
|
|
88
88
|
|
|
89
89
|
## 1. Setup
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
**One install command to remember: `./run.sh setup <project>`.** It is idempotent — re-run the exact same command whenever anything looks wrong. There is no second install surface to juggle: from a clone `setup` runs the whole floor in order.
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
### 1.1 Path A — consumer install
|
|
93
|
+
1. `pnpm install` — bundles pi (a dev/peer dependency; no separate `pi install` step) and builds the bridge
|
|
94
|
+
2. project wiring → `<project>/.pi/settings.json` `entwurfProvider.mcpServers.entwurf-bridge`
|
|
95
|
+
3. meta-bridge global plugin — only when a native harness (`claude`) is on PATH; a pi-only host skips it cleanly
|
|
96
|
+
4. `entwurf-bridge` install smoke (`validate_entwurf_bridge`)
|
|
99
97
|
|
|
100
98
|
```bash
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
pi --list-models entwurf # curated model surface
|
|
105
|
-
pi --provider entwurf --model claude-sonnet-5 -p "reply with ok only" # one-turn smoke
|
|
99
|
+
git clone https://github.com/junghan0611/entwurf /path/to/entwurf && cd $_
|
|
100
|
+
./run.sh setup /path/to/consumer-project
|
|
101
|
+
# re-run the SAME command any time to repair a broken install
|
|
106
102
|
```
|
|
107
103
|
|
|
108
|
-
Expected:
|
|
104
|
+
Expected tail: `DONE: entwurf setup (pi package + meta-bridge + v2 install smoke) green.` On a host with `claude`, verify the native wiring with `./run.sh doctor-meta-bridge`.
|
|
105
|
+
|
|
106
|
+
The wiring / meta-bridge / smoke steps are internal building blocks of `setup` (`install_local_package`, `scripts/meta-bridge-install.sh`, `validate_entwurf_bridge`) — call `setup`, never the parts. Consumers who `npm install @junghanacs/entwurf` get the obvious npm surface; that path is not the developer concern here.
|
|
109
107
|
|
|
110
|
-
### 1.
|
|
108
|
+
### 1.1 Variables (optional)
|
|
111
109
|
|
|
112
110
|
```bash
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
./run.sh install /path/to/consumer-project
|
|
117
|
-
pnpm check # full deterministic floor
|
|
118
|
-
pi --provider entwurf --model claude-sonnet-5 -p "reply with ok only"
|
|
119
|
-
LIVE=1 ./run.sh release-gate /path/to/consumer-project
|
|
111
|
+
export REPO_DIR=/path/to/entwurf
|
|
112
|
+
export PROJECT_DIR=/path/to/consumer-project
|
|
113
|
+
cd "$REPO_DIR" && ./run.sh setup "$PROJECT_DIR"
|
|
120
114
|
```
|
|
121
115
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
### 1.3 Setup shortcut + variables
|
|
116
|
+
### 1.2 Live acceptance (optional)
|
|
125
117
|
|
|
126
118
|
```bash
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
export CACHE_DIR=$HOME/.pi/agent/cache/entwurf/sessions
|
|
130
|
-
cd "$REPO_DIR" && ./run.sh setup "$PROJECT_DIR"
|
|
119
|
+
LIVE=1 ./run.sh release-gate /path/to/consumer-project
|
|
120
|
+
pi --provider entwurf --model claude-sonnet-5 -p "reply with ok only" # one-turn smoke
|
|
131
121
|
```
|
|
132
122
|
|
|
133
123
|
`setup` runs `pnpm install` + `install` + meta-bridge (native harness) + the v2 install smoke; a green `setup` implies the settings.json wiring and install surface are healthy. The full live floor is still `LIVE=1 ./run.sh release-gate`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junghanacs/entwurf",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.6",
|
|
4
4
|
"description": "Garden-citizen dispatch substrate and meta-bridge for Claude Code, Codex, Antigravity, and pi harnesses.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -65,7 +65,6 @@
|
|
|
65
65
|
"engines": {
|
|
66
66
|
"node": ">=22.6.0"
|
|
67
67
|
},
|
|
68
|
-
"packageManager": "pnpm@10.33.0",
|
|
69
68
|
"dependencies": {
|
|
70
69
|
"@agentclientprotocol/claude-agent-acp": "0.54.1",
|
|
71
70
|
"@agentclientprotocol/sdk": "1.1.0",
|
|
@@ -120,7 +119,7 @@
|
|
|
120
119
|
"format": "biome check --write .",
|
|
121
120
|
"check-meta-manifest-schema": "./run.sh check-meta-manifest-schema",
|
|
122
121
|
"check-package-source-routing": "./run.sh check-package-source-routing",
|
|
123
|
-
"check": "pnpm lint && pnpm typecheck && ./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-record-v2 && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-meta-dual-read && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-migration && ./run.sh check-meta-dual-consumers && ./run.sh check-meta-capability-source && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh smoke-meta-install-state && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-package-source-routing && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-install-preflight && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-lock && ./run.sh check-entwurf-v2-decider && ./run.sh check-entwurf-v2-matrix && ./run.sh check-entwurf-v2-release && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-send-fallback && ./run.sh check-entwurf-v2-mailbox && ./run.sh check-entwurf-v2-runner && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-boot && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-entwurf-v2-spawn && ./run.sh check-entwurf-resume-args && ./run.sh check-entwurf-v2-spawn-production && ./run.sh check-entwurf-facts && ./run.sh check-socket-discovery && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-entwurf-mailbox-guard && ./run.sh check-auth-boundary && ./run.sh check-acp-provider-surface && ./run.sh check-acp-sdk-surface && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-acp-carrier-augment && ./run.sh check-pack"
|
|
122
|
+
"check": "pnpm lint && pnpm typecheck && ./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-record-v2 && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-meta-dual-read && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-migration && ./run.sh check-meta-dual-consumers && ./run.sh check-meta-capability-source && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh smoke-meta-install-state && ./run.sh smoke-user-scope-citizen && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-package-source-routing && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-install-preflight && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-lock && ./run.sh check-entwurf-v2-decider && ./run.sh check-entwurf-v2-matrix && ./run.sh check-entwurf-v2-release && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-send-fallback && ./run.sh check-entwurf-v2-mailbox && ./run.sh check-entwurf-v2-runner && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-boot && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-entwurf-v2-spawn && ./run.sh check-entwurf-resume-args && ./run.sh check-entwurf-v2-spawn-production && ./run.sh check-entwurf-facts && ./run.sh check-socket-discovery && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-entwurf-mailbox-guard && ./run.sh check-auth-boundary && ./run.sh check-acp-provider-surface && ./run.sh check-acp-sdk-surface && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-acp-carrier-augment && ./run.sh check-pack"
|
|
124
123
|
},
|
|
125
124
|
"pi": {
|
|
126
125
|
"extensions": [
|
package/run.sh
CHANGED
|
@@ -91,12 +91,13 @@ Usage:
|
|
|
91
91
|
./run.sh smoke-meta-async-drift # 1.0.0 meta-bridge step 1: drift sentinel — version pins + Claude binary undocumented-behavior markers (LIVE=1 adds plugin watch-arm probe)
|
|
92
92
|
./run.sh smoke-meta-honesty # 1.0.0 meta-bridge: honesty regression gate (#30 blockers) — doorbell counts ALL msgs honestly + hook logs failures as ERROR (best-effort, no scream). Offline/deterministic (deps: bash+node+python3)
|
|
93
93
|
./run.sh smoke-meta-install-state # 1.0.0 meta-bridge Phase 2: stateful install/uninstall + store-doctor regression gate. Offline/deterministic (deps: bash+node+python3)
|
|
94
|
+
./run.sh smoke-user-scope-citizen # 0.12.6 install-boundary: pi packages[] registration SSOT (register-pi-package.py) — idempotent + preserves unrelated + normalizes stale + remove symmetry + fails loud. Offline/hermetic (deps: bash+python3)
|
|
94
95
|
./run.sh smoke-meta-prune # 1.0.0 meta-bridge Phase 4: listing-only store janitor regression gate — classify keep/orphan/stale/ambiguous, delete nothing. Offline/deterministic (deps: bash+node)
|
|
95
96
|
./run.sh smoke-meta-keyset-guard # 0.10.0 meta-bridge: keyset-owner guard regression — check-keyset-overlap + managed-keys SSOT (disjoint passes, collisions fail). Offline/hermetic (deps: bash+python3)
|
|
96
97
|
./run.sh check-meta-manifest-schema # 0.12.2 meta-bridge: CLI-version-INDEPENDENT static guard — plugin manifests pinned to the minimal keyset that validates on the lowest supported Claude (closed-schema regression that broke 0.12.1 install on floor) + desired_mcp installed-vs-clone dual-mode. Offline (deps: python3)
|
|
97
98
|
./run.sh smoke-claude-native-resume-live # LIVE-only: Claude Code native fresh→--resume continuity + meta-record uniqueness; proves meta-bridge records identity without touching the backend resume path
|
|
98
99
|
|
|
99
|
-
./run.sh install-meta-bridge #
|
|
100
|
+
./run.sh install-meta-bridge # INTERNAL part of `setup` (native-harness plugin) + doctor recovery path — prefer `setup`; stateful GLOBAL install (plugin + USER MCP + settings keyset, honest uninstall state)
|
|
100
101
|
./run.sh uninstall-meta-bridge # 1.0.0 meta-bridge Phase 2: stateful GLOBAL uninstall (restore only keys/items captured in install-state)
|
|
101
102
|
./run.sh doctor-meta-bridge # 1.0.0 meta-bridge Phase 2: fail-loud doctor — toolchain + state + plugin/MCP + store scan + hook errors + SessionStart evidence + writer-version parity (source↔assembled↔installed: FAIL on a stale deployed meta-record writer)
|
|
102
103
|
./run.sh meta-bridge-prune # 1.0.0 meta-bridge Phase 4: LISTING-ONLY store hygiene — classify orphan/stale/ambiguous/keep, print manual rm commands, delete NOTHING ([dir] [--ttl-days N])
|
|
@@ -106,7 +107,7 @@ Usage:
|
|
|
106
107
|
./run.sh check-pack # publish gate (dry-run): npm pack --dry-run + tarball invariants (runtime-critical present, dev residue absent)
|
|
107
108
|
./run.sh check-pack-install # heavy publish gate (prepublishOnly): actual npm pack + tar -tf + fresh-temp install smoke with 0.80.x peers
|
|
108
109
|
./run.sh sync-auth # copy ~/.pi/agent/auth.json anthropic OAuth credentials to entwurf alias
|
|
109
|
-
./run.sh install [project-dir] #
|
|
110
|
+
./run.sh install [project-dir] # INTERNAL part of `setup` (project .pi/settings.json wiring) + npm-consumer entry — prefer `setup`, don't call directly for dev
|
|
110
111
|
./run.sh setup:links [--force] # repair ~/.pi/agent/entwurf-targets.json link (use --force to replace a stale operator file or wrong symlink; a .bak is taken)
|
|
111
112
|
./run.sh remove [project-dir] # remove entwurf entries from project .pi/settings.json
|
|
112
113
|
|
|
@@ -239,11 +240,67 @@ preflight_dep_integrity() {
|
|
|
239
240
|
fi
|
|
240
241
|
}
|
|
241
242
|
|
|
243
|
+
preflight_pi_settings_shapes() {
|
|
244
|
+
local project_settings="$1"
|
|
245
|
+
local user_settings="$2"
|
|
246
|
+
python3 - "$project_settings" "$user_settings" <<'PY'
|
|
247
|
+
import json, sys
|
|
248
|
+
from pathlib import Path
|
|
249
|
+
|
|
250
|
+
project = Path(sys.argv[1])
|
|
251
|
+
user = Path(sys.argv[2])
|
|
252
|
+
|
|
253
|
+
def load_object(path: Path, label: str):
|
|
254
|
+
if not path.exists():
|
|
255
|
+
return None
|
|
256
|
+
data = json.loads(path.read_text())
|
|
257
|
+
if not isinstance(data, dict):
|
|
258
|
+
raise SystemExit(f"{label} settings is not a JSON object: {path}")
|
|
259
|
+
return data
|
|
260
|
+
|
|
261
|
+
def check_packages(data, path: Path, label: str):
|
|
262
|
+
if data is None:
|
|
263
|
+
return
|
|
264
|
+
packages = data.get("packages")
|
|
265
|
+
if packages is not None and not isinstance(packages, list):
|
|
266
|
+
raise SystemExit(f"{label} settings packages is not a JSON array: {path}")
|
|
267
|
+
|
|
268
|
+
def check_project_provider(data, path: Path):
|
|
269
|
+
if data is None:
|
|
270
|
+
return
|
|
271
|
+
provider = data.get("entwurfProvider")
|
|
272
|
+
if provider is None:
|
|
273
|
+
return
|
|
274
|
+
if not isinstance(provider, dict):
|
|
275
|
+
raise SystemExit(f"project settings entwurfProvider is not an object: {path}")
|
|
276
|
+
servers = provider.get("mcpServers")
|
|
277
|
+
if servers is not None and not isinstance(servers, dict):
|
|
278
|
+
raise SystemExit(f"project settings entwurfProvider.mcpServers is not an object: {path}")
|
|
279
|
+
|
|
280
|
+
project_data = load_object(project, "project")
|
|
281
|
+
user_data = load_object(user, "user")
|
|
282
|
+
check_packages(project_data, project, "project")
|
|
283
|
+
check_packages(user_data, user, "user")
|
|
284
|
+
check_project_provider(project_data, project)
|
|
285
|
+
PY
|
|
286
|
+
}
|
|
287
|
+
|
|
242
288
|
install_local_package() {
|
|
243
|
-
local project_dir
|
|
289
|
+
local project_dir agent_dir
|
|
244
290
|
project_dir=$(normalize_project_dir "$1")
|
|
291
|
+
agent_dir="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
|
|
245
292
|
preflight_dep_integrity
|
|
293
|
+
# Fail BEFORE any settings write if either target config already has a corrupt
|
|
294
|
+
# shape. The packages[] SSOT and provider writer run in two separate steps, so
|
|
295
|
+
# without this preflight a bad entwurfProvider could leave a half-installed
|
|
296
|
+
# packages[] entry behind (2026-07-03 install-boundary hardening).
|
|
297
|
+
preflight_pi_settings_shapes "$project_dir/.pi/settings.json" "$agent_dir/settings.json"
|
|
246
298
|
mkdir -p "$project_dir/.pi"
|
|
299
|
+
# packages[] registration via the shared SSOT — same is_entwurf_source
|
|
300
|
+
# predicate + idempotency as user-scope and remove (not a substring match).
|
|
301
|
+
python3 "$REPO_DIR/scripts/register-pi-package.py" "$project_dir/.pi/settings.json" "$REPO_DIR"
|
|
302
|
+
# entwurfProvider.mcpServers + legacy prune (project-scope only; packages[] was
|
|
303
|
+
# already written above by register-pi-package.py).
|
|
247
304
|
python3 - "$project_dir/.pi/settings.json" "$REPO_DIR" <<'PY'
|
|
248
305
|
import json, sys
|
|
249
306
|
from pathlib import Path
|
|
@@ -258,23 +315,6 @@ if settings_path.exists():
|
|
|
258
315
|
else:
|
|
259
316
|
data = {}
|
|
260
317
|
|
|
261
|
-
# --- packages[] registration --------------------------------------------------
|
|
262
|
-
packages = data.get("packages")
|
|
263
|
-
if not isinstance(packages, list):
|
|
264
|
-
packages = []
|
|
265
|
-
|
|
266
|
-
filtered = []
|
|
267
|
-
for item in packages:
|
|
268
|
-
source = item.get("source") if isinstance(item, dict) else item
|
|
269
|
-
if isinstance(source, str) and ("entwurf" in source) and source != repo_dir:
|
|
270
|
-
continue
|
|
271
|
-
filtered.append(item)
|
|
272
|
-
|
|
273
|
-
if repo_dir not in filtered:
|
|
274
|
-
filtered.append(repo_dir)
|
|
275
|
-
|
|
276
|
-
data["packages"] = filtered
|
|
277
|
-
|
|
278
318
|
# --- entwurfProvider.mcpServers bundled entries ---------------------------
|
|
279
319
|
# Ship the two in-repo MCP adapters pre-wired so `pi install` produces a
|
|
280
320
|
# working setup without the consumer hand-editing settings.json. User-authored
|
|
@@ -334,9 +374,25 @@ for name, reason in LEGACY_BUNDLED.items():
|
|
|
334
374
|
|
|
335
375
|
settings_path.write_text(json.dumps(data, indent=2) + "\n")
|
|
336
376
|
print(f"install: updated {settings_path}")
|
|
337
|
-
print(f"install: package source -> {repo_dir}")
|
|
338
377
|
PY
|
|
339
378
|
ensure_agent_dir_symlinks
|
|
379
|
+
register_user_scope_citizen
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
# Register entwurf as a pi USER-SCOPE citizen so its extensions
|
|
383
|
+
# (entwurf-control.ts → --entwurf-control / --emacs-agent-socket) load from ANY
|
|
384
|
+
# cwd, not only inside the entwurf checkout. project-scope `.pi/settings.json`
|
|
385
|
+
# only applies when pi runs inside the repo; the `pit`/`pia`/`pihome` global
|
|
386
|
+
# launchers and the npm consumer's "installs → just works" both need the entry
|
|
387
|
+
# in ~/.pi/agent/settings.json's packages[]. This is the wiring that dropped when
|
|
388
|
+
# `pi install` was removed from setup (2026-07-03: `--entwurf-control` unknown in
|
|
389
|
+
# a foreign cwd). Idempotent: absent → append, present → no-op; a stale entwurf
|
|
390
|
+
# entry at a different path is normalized to REPO_DIR. Every other package and key
|
|
391
|
+
# in the operator's user settings is preserved untouched.
|
|
392
|
+
register_user_scope_citizen() {
|
|
393
|
+
local agent_dir="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
|
|
394
|
+
# Shared idempotent implementation (also driven by smoke-user-scope-citizen).
|
|
395
|
+
python3 "$REPO_DIR/scripts/register-pi-package.py" "$agent_dir/settings.json" "$REPO_DIR"
|
|
340
396
|
}
|
|
341
397
|
|
|
342
398
|
# Ensure agent-level resources that entwurf code reads from
|
|
@@ -424,6 +480,14 @@ ensure_agent_dir_symlinks() {
|
|
|
424
480
|
remove_local_package() {
|
|
425
481
|
local project_dir
|
|
426
482
|
project_dir=$(normalize_project_dir "$1")
|
|
483
|
+
# Same fail-before-write rule as install: remove has two writers too
|
|
484
|
+
# (packages[] SSOT, then entwurfProvider cleanup), so a malformed provider must
|
|
485
|
+
# not leave a half-removed packages[] entry behind.
|
|
486
|
+
preflight_pi_settings_shapes "$project_dir/.pi/settings.json" "$(mktemp -u)"
|
|
487
|
+
# packages[] cleanup via the shared SSOT — same is_entwurf_source predicate as
|
|
488
|
+
# install, so remove never over-deletes a look-alike repo (entwurf-notes, …)
|
|
489
|
+
# that install would never have registered.
|
|
490
|
+
python3 "$REPO_DIR/scripts/register-pi-package.py" "$project_dir/.pi/settings.json" "$REPO_DIR" --remove
|
|
427
491
|
python3 - "$project_dir/.pi/settings.json" "$REPO_DIR" <<'PY'
|
|
428
492
|
import json, sys
|
|
429
493
|
from pathlib import Path
|
|
@@ -438,19 +502,6 @@ data = json.loads(settings_path.read_text())
|
|
|
438
502
|
if not isinstance(data, dict):
|
|
439
503
|
raise SystemExit("settings.json is not an object")
|
|
440
504
|
|
|
441
|
-
# --- packages[] cleanup -------------------------------------------------------
|
|
442
|
-
packages = data.get("packages")
|
|
443
|
-
pkg_removed = 0
|
|
444
|
-
if isinstance(packages, list):
|
|
445
|
-
filtered = []
|
|
446
|
-
for item in packages:
|
|
447
|
-
source = item.get("source") if isinstance(item, dict) else item
|
|
448
|
-
if isinstance(source, str) and ("entwurf" in source):
|
|
449
|
-
pkg_removed += 1
|
|
450
|
-
continue
|
|
451
|
-
filtered.append(item)
|
|
452
|
-
data["packages"] = filtered
|
|
453
|
-
|
|
454
505
|
# --- entwurfProvider.mcpServers cleanup ------------------------------------
|
|
455
506
|
# Only remove entries that look like they came from ./run.sh install: either
|
|
456
507
|
# the command matches the current $REPO_DIR anchor exactly, or it ends with
|
|
@@ -484,7 +535,7 @@ if isinstance(provider, dict):
|
|
|
484
535
|
data.pop("entwurfProvider", None)
|
|
485
536
|
|
|
486
537
|
settings_path.write_text(json.dumps(data, indent=2) + "\n")
|
|
487
|
-
print(f"remove: removed {
|
|
538
|
+
print(f"remove: removed {mcp_removed} mcpServers entries from {settings_path}")
|
|
488
539
|
PY
|
|
489
540
|
}
|
|
490
541
|
|
|
@@ -1469,6 +1520,87 @@ check_install_preflight() {
|
|
|
1469
1520
|
fi
|
|
1470
1521
|
rm -rf "$fake" "$proj"
|
|
1471
1522
|
ok "[check-install-preflight] representative dangling symlink (test -d blind spot) → fails before writing settings, names the dep"
|
|
1523
|
+
|
|
1524
|
+
# negative 3 — corrupt project provider shape. install_local_package now has a
|
|
1525
|
+
# two-step writer (packages[] SSOT, then entwurfProvider.mcpServers), so a
|
|
1526
|
+
# malformed provider MUST fail before the packages[] step writes anything.
|
|
1527
|
+
fake=$(mktemp -d); proj=$(mktemp -d)
|
|
1528
|
+
mkdir -p "$proj/.pi" "$fake/home"
|
|
1529
|
+
printf '{"entwurfProvider": []}\n' > "$proj/.pi/settings.json"
|
|
1530
|
+
local before after
|
|
1531
|
+
before=$(sha256sum "$proj/.pi/settings.json" | cut -d' ' -f1)
|
|
1532
|
+
rc=0; out=$(HOME="$fake/home" PI_CODING_AGENT_DIR="$fake/home/.pi/agent" "$REPO_DIR/run.sh" install "$proj" 2>&1) || rc=$?
|
|
1533
|
+
after=$(sha256sum "$proj/.pi/settings.json" | cut -d' ' -f1)
|
|
1534
|
+
if [ "$rc" -eq 0 ]; then
|
|
1535
|
+
fail "[check-install-preflight] corrupt entwurfProvider did NOT fail install"; echo "$out"; rm -rf "$fake" "$proj"; exit 1
|
|
1536
|
+
fi
|
|
1537
|
+
if [ "$before" != "$after" ]; then
|
|
1538
|
+
fail "[check-install-preflight] corrupt entwurfProvider was partially rewritten (packages[] leak)"; echo "$out"; cat "$proj/.pi/settings.json"; rm -rf "$fake" "$proj"; exit 1
|
|
1539
|
+
fi
|
|
1540
|
+
if ! printf '%s' "$out" | grep -q "entwurfProvider is not an object"; then
|
|
1541
|
+
fail "[check-install-preflight] corrupt entwurfProvider failed for the WRONG reason:"; echo "$out"; rm -rf "$fake" "$proj"; exit 1
|
|
1542
|
+
fi
|
|
1543
|
+
if [ -f "$fake/home/.pi/agent/settings.json" ]; then
|
|
1544
|
+
fail "[check-install-preflight] corrupt project provider still wrote user-scope settings"; cat "$fake/home/.pi/agent/settings.json"; rm -rf "$fake" "$proj"; exit 1
|
|
1545
|
+
fi
|
|
1546
|
+
rm -rf "$fake" "$proj"
|
|
1547
|
+
ok "[check-install-preflight] corrupt project entwurfProvider → fails before any project/user settings write"
|
|
1548
|
+
|
|
1549
|
+
# negative 4 — corrupt project mcpServers shape, same no-partial-write contract.
|
|
1550
|
+
fake=$(mktemp -d); proj=$(mktemp -d)
|
|
1551
|
+
mkdir -p "$proj/.pi" "$fake/home"
|
|
1552
|
+
printf '{"entwurfProvider": {"mcpServers": []}}\n' > "$proj/.pi/settings.json"
|
|
1553
|
+
before=$(sha256sum "$proj/.pi/settings.json" | cut -d' ' -f1)
|
|
1554
|
+
rc=0; out=$(HOME="$fake/home" PI_CODING_AGENT_DIR="$fake/home/.pi/agent" "$REPO_DIR/run.sh" install "$proj" 2>&1) || rc=$?
|
|
1555
|
+
after=$(sha256sum "$proj/.pi/settings.json" | cut -d' ' -f1)
|
|
1556
|
+
if [ "$rc" -eq 0 ]; then
|
|
1557
|
+
fail "[check-install-preflight] corrupt entwurfProvider.mcpServers did NOT fail install"; echo "$out"; rm -rf "$fake" "$proj"; exit 1
|
|
1558
|
+
fi
|
|
1559
|
+
if [ "$before" != "$after" ]; then
|
|
1560
|
+
fail "[check-install-preflight] corrupt mcpServers was partially rewritten (packages[] leak)"; echo "$out"; cat "$proj/.pi/settings.json"; rm -rf "$fake" "$proj"; exit 1
|
|
1561
|
+
fi
|
|
1562
|
+
if ! printf '%s' "$out" | grep -q "entwurfProvider.mcpServers is not an object"; then
|
|
1563
|
+
fail "[check-install-preflight] corrupt mcpServers failed for the WRONG reason:"; echo "$out"; rm -rf "$fake" "$proj"; exit 1
|
|
1564
|
+
fi
|
|
1565
|
+
rm -rf "$fake" "$proj"
|
|
1566
|
+
ok "[check-install-preflight] corrupt project entwurfProvider.mcpServers → fails before any project settings write"
|
|
1567
|
+
|
|
1568
|
+
# negative 5 — corrupt user-scope packages shape. Since install writes project
|
|
1569
|
+
# settings before user registration, this preflight must catch the user file up
|
|
1570
|
+
# front so a bad ~/.pi/agent/settings.json cannot leave the project half-wired.
|
|
1571
|
+
fake=$(mktemp -d); proj=$(mktemp -d)
|
|
1572
|
+
mkdir -p "$fake/home/.pi/agent"
|
|
1573
|
+
printf '{"packages": {"broken": true}}\n' > "$fake/home/.pi/agent/settings.json"
|
|
1574
|
+
rc=0; out=$(HOME="$fake/home" PI_CODING_AGENT_DIR="$fake/home/.pi/agent" "$REPO_DIR/run.sh" install "$proj" 2>&1) || rc=$?
|
|
1575
|
+
if [ "$rc" -eq 0 ]; then
|
|
1576
|
+
fail "[check-install-preflight] corrupt user packages did NOT fail install"; echo "$out"; rm -rf "$fake" "$proj"; exit 1
|
|
1577
|
+
fi
|
|
1578
|
+
if [ -f "$proj/.pi/settings.json" ]; then
|
|
1579
|
+
fail "[check-install-preflight] corrupt user packages still wrote project settings (partial install)"; echo "$out"; cat "$proj/.pi/settings.json"; rm -rf "$fake" "$proj"; exit 1
|
|
1580
|
+
fi
|
|
1581
|
+
if ! printf '%s' "$out" | grep -q "user settings packages is not a JSON array"; then
|
|
1582
|
+
fail "[check-install-preflight] corrupt user packages failed for the WRONG reason:"; echo "$out"; rm -rf "$fake" "$proj"; exit 1
|
|
1583
|
+
fi
|
|
1584
|
+
rm -rf "$fake" "$proj"
|
|
1585
|
+
ok "[check-install-preflight] corrupt user-scope packages → fails before project settings write"
|
|
1586
|
+
|
|
1587
|
+
# negative 6 — remove has the same two-step write risk (packages[] remove,
|
|
1588
|
+
# then provider cleanup). A corrupt provider must fail before packages[] is
|
|
1589
|
+
# altered, otherwise uninstall becomes a partial destructive write.
|
|
1590
|
+
proj=$(mktemp -d)
|
|
1591
|
+
mkdir -p "$proj/.pi"
|
|
1592
|
+
printf '{"entwurfProvider": [], "packages": ["%s"]}\n' "$REPO_DIR" > "$proj/.pi/settings.json"
|
|
1593
|
+
before=$(sha256sum "$proj/.pi/settings.json" | cut -d' ' -f1)
|
|
1594
|
+
rc=0; out=$("$REPO_DIR/run.sh" remove "$proj" 2>&1) || rc=$?
|
|
1595
|
+
after=$(sha256sum "$proj/.pi/settings.json" | cut -d' ' -f1)
|
|
1596
|
+
if [ "$rc" -eq 0 ]; then
|
|
1597
|
+
fail "[check-install-preflight] corrupt provider did NOT fail remove"; echo "$out"; rm -rf "$proj"; exit 1
|
|
1598
|
+
fi
|
|
1599
|
+
if [ "$before" != "$after" ]; then
|
|
1600
|
+
fail "[check-install-preflight] corrupt provider remove partially rewrote packages[]"; echo "$out"; cat "$proj/.pi/settings.json"; rm -rf "$proj"; exit 1
|
|
1601
|
+
fi
|
|
1602
|
+
rm -rf "$proj"
|
|
1603
|
+
ok "[check-install-preflight] remove with corrupt provider → fails before packages[] removal"
|
|
1472
1604
|
}
|
|
1473
1605
|
|
|
1474
1606
|
check_pi_preflight() {
|
|
@@ -2002,8 +2134,14 @@ check_pack_install() {
|
|
|
2002
2134
|
return 1
|
|
2003
2135
|
fi
|
|
2004
2136
|
|
|
2005
|
-
|
|
2006
|
-
|
|
2137
|
+
# HOME/PI_CODING_AGENT_DIR redirected to a throwaway dir: this loader smoke must
|
|
2138
|
+
# depend ONLY on the -e package path, never on the operator's real
|
|
2139
|
+
# ~/.pi/agent/settings.json — otherwise a live-config change could silently
|
|
2140
|
+
# pass/fail the gate (the exact "check must not depend on live wiring" impurity
|
|
2141
|
+
# this whole lane is about).
|
|
2142
|
+
local loader_out loader_home="$tmp/loader-home"
|
|
2143
|
+
mkdir -p "$loader_home/.pi/agent"
|
|
2144
|
+
loader_out=$(cd "$tmp" && HOME="$loader_home" PI_CODING_AGENT_DIR="$loader_home/.pi/agent" pi -e "$tmp/node_modules/@junghanacs/entwurf" --list-models entwurf 2>&1) || {
|
|
2007
2145
|
fail "[check-pack-install] pi loader smoke failed (exit non-zero):"
|
|
2008
2146
|
echo "$loader_out" | tail -10 | sed 's/^/ /' >&2
|
|
2009
2147
|
return 1
|
|
@@ -2075,6 +2213,43 @@ check_pack_install() {
|
|
|
2075
2213
|
fi
|
|
2076
2214
|
echo "[check-pack-install] npm-managed install regression pass (hoisted-dep run.sh install wrote settings)"
|
|
2077
2215
|
|
|
2216
|
+
# THE 2026-07-03 regression gate: removing `pi install` from setup dropped
|
|
2217
|
+
# user-scope citizen registration, so `--entwurf-control` was Unknown in a
|
|
2218
|
+
# foreign cwd. Prove the npm consumer path closes it end-to-end: run.sh install
|
|
2219
|
+
# (run above under HOME="$npmhome") must have registered the package in the USER
|
|
2220
|
+
# settings, and pi must then load the entwurf extension + its flags from a cwd
|
|
2221
|
+
# OUTSIDE the project. Fully isolated in the temp HOME — never touches ~/.pi.
|
|
2222
|
+
local npm_user_settings="$npmhome/.pi/agent/settings.json"
|
|
2223
|
+
if [ ! -f "$npm_user_settings" ]; then
|
|
2224
|
+
fail "[check-pack-install] npm-managed install did not register a user-scope citizen at $npm_user_settings"
|
|
2225
|
+
return 1
|
|
2226
|
+
fi
|
|
2227
|
+
if ! python3 -c "
|
|
2228
|
+
import json,sys
|
|
2229
|
+
p=json.load(open('$npm_user_settings')).get('packages',[])
|
|
2230
|
+
srcs=[(x if isinstance(x,str) else x.get('source')) for x in p]
|
|
2231
|
+
sys.exit(0 if any(isinstance(s,str) and s.endswith('/node_modules/@junghanacs/entwurf') for s in srcs) else 1)
|
|
2232
|
+
"; then
|
|
2233
|
+
fail "[check-pack-install] user-scope settings.json lacks the npm entwurf package in packages[]:"
|
|
2234
|
+
sed 's/^/ /' "$npm_user_settings" >&2
|
|
2235
|
+
return 1
|
|
2236
|
+
fi
|
|
2237
|
+
# Foreign cwd ($tmp — NOT the project, no project .pi): --entwurf-control must be
|
|
2238
|
+
# a KNOWN flag and the entwurf provider must load, sourced only from user scope.
|
|
2239
|
+
# Before the fix this printed "Unknown options: --entwurf-control".
|
|
2240
|
+
local foreign_out
|
|
2241
|
+
foreign_out=$(cd "$tmp" && HOME="$npmhome" PI_CODING_AGENT_DIR="$npmhome/.pi/agent" pi --entwurf-control --list-models entwurf 2>&1) || {
|
|
2242
|
+
fail "[check-pack-install] foreign-cwd --entwurf-control smoke failed (user-scope citizen not loading?):"
|
|
2243
|
+
echo "$foreign_out" | tail -10 | sed 's/^/ /' >&2
|
|
2244
|
+
return 1
|
|
2245
|
+
}
|
|
2246
|
+
if grep -qi "Unknown option" <<<"$foreign_out" || ! grep -q "claude-opus-4-8" <<<"$foreign_out"; then
|
|
2247
|
+
fail "[check-pack-install] foreign-cwd --entwurf-control did not load the entwurf extension from user scope:"
|
|
2248
|
+
echo "$foreign_out" | tail -10 | sed 's/^/ /' >&2
|
|
2249
|
+
return 1
|
|
2250
|
+
fi
|
|
2251
|
+
echo "[check-pack-install] user-scope citizen regression pass (npm consumer: --entwurf-control loads from a foreign cwd)"
|
|
2252
|
+
|
|
2078
2253
|
# Installed meta-bridge ownership regression (0.12.5): package upgrades must not
|
|
2079
2254
|
# bake versioned pnpm-store paths into Claude settings. MCP already uses the
|
|
2080
2255
|
# stable `entwurf-bridge` bin; statusLine must now use `entwurf-statusline`, and
|
|
@@ -2998,6 +3173,14 @@ case "$cmd" in
|
|
|
2998
3173
|
# duplicate/drift records. Offline + deterministic (deps bash+node+python3).
|
|
2999
3174
|
(cd "$REPO_DIR" && bash scripts/smoke-meta-install-state.sh)
|
|
3000
3175
|
;;
|
|
3176
|
+
smoke-user-scope-citizen)
|
|
3177
|
+
# 0.12.6 install-boundary gate: register-pi-package.py is the shared
|
|
3178
|
+
# packages[] SSOT for project/user install and remove; user scope makes
|
|
3179
|
+
# --entwurf-control load from any cwd. Idempotent, preserves unrelated
|
|
3180
|
+
# packages/keys, normalizes stale entries, remove is symmetric, and corrupt
|
|
3181
|
+
# settings fail loud. The tripwire the 2026-07-03 `pi install` removal lacked.
|
|
3182
|
+
(cd "$REPO_DIR" && bash scripts/smoke-user-scope-citizen.sh)
|
|
3183
|
+
;;
|
|
3001
3184
|
smoke-claude-native-resume-live)
|
|
3002
3185
|
# LIVE-only Detour A probe: two real Claude Code native turns (fresh +
|
|
3003
3186
|
# --resume) in a scratch cwd. Verifies native resume works while the
|
|
@@ -3007,9 +3190,10 @@ case "$cmd" in
|
|
|
3007
3190
|
;;
|
|
3008
3191
|
install-meta-bridge)
|
|
3009
3192
|
# 1.0.0 meta-bridge step 5: operator-grade GLOBAL install of the garden-native
|
|
3010
|
-
# receive plugin. Assembles a self-contained, node-path-baked copy under
|
|
3011
|
-
#
|
|
3012
|
-
#
|
|
3193
|
+
# receive plugin. Assembles a self-contained, node-path-baked copy under the
|
|
3194
|
+
# XDG data dir ($XDG_DATA_HOME/entwurf/meta-bridge/.assembled — dev clone and
|
|
3195
|
+
# installed package alike, never the checkout) and runs marketplace add +
|
|
3196
|
+
# install --scope user, so every native Claude Code session auto-loads it.
|
|
3013
3197
|
# Idempotent; Linux/macOS only (Windows fail-fast).
|
|
3014
3198
|
(cd "$REPO_DIR" && bash scripts/meta-bridge-install.sh "$@")
|
|
3015
3199
|
;;
|
|
Binary file
|
|
Binary file
|
|
@@ -17,18 +17,22 @@ MKT_NAME="meta-bridge-local"
|
|
|
17
17
|
PLUGIN="entwurf-meta-receive"
|
|
18
18
|
CLAUDE_CFG="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
|
19
19
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
20
|
-
#
|
|
20
|
+
# The live artifact lives under the XDG data dir (dev clone and installed package
|
|
21
|
+
# alike — never the checkout). Prefer the path RECORDED in install-state so a doctor
|
|
22
|
+
# run under a different XDG_DATA_HOME than install still hashes the REAL assembled
|
|
23
|
+
# bundle (and state.py check compares the same recorded marketplace path); fall back
|
|
24
|
+
# to the current-XDG computation only when no state exists yet (fresh host).
|
|
25
|
+
# LIB_EXT still tracks the hook artifact mode (0.12.5): installed packages ship the
|
|
21
26
|
# tsc-emitted `.js` closure (node_modules-safe), dev clones run the `.ts` source.
|
|
22
27
|
# The writer-version parity below hashes meta-session.<LIB_EXT> so an installed
|
|
23
28
|
# `.js` bundle is compared against the SAME-pipeline dist `.js`, never against the
|
|
24
29
|
# `.ts` source (which would hash-mismatch and false-STALE).
|
|
30
|
+
ASM="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge/.assembled"
|
|
31
|
+
RECORDED_ASM="$(python3 "$REPO/scripts/meta-bridge-state.py" assembled-path --repo "$REPO" 2>/dev/null || true)"
|
|
32
|
+
[ -n "$RECORDED_ASM" ] && ASM="$RECORDED_ASM"
|
|
25
33
|
case "$REPO" in
|
|
26
|
-
*/node_modules/@junghanacs/entwurf)
|
|
27
|
-
|
|
28
|
-
LIB_EXT="js" ;;
|
|
29
|
-
*)
|
|
30
|
-
ASM="$REPO/pi/meta-bridge/.assembled"
|
|
31
|
-
LIB_EXT="ts" ;;
|
|
34
|
+
*/node_modules/@junghanacs/entwurf) LIB_EXT="js" ;;
|
|
35
|
+
*) LIB_EXT="ts" ;;
|
|
32
36
|
esac
|
|
33
37
|
# shellcheck source=scripts/meta-bridge-hook-log.sh
|
|
34
38
|
source "$REPO/scripts/meta-bridge-hook-log.sh"
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
# needs, so a user never hand-edits hook/plugin settings or passes --plugin-dir.
|
|
5
5
|
#
|
|
6
6
|
# Mechanism (all proven on 2026-06-05):
|
|
7
|
-
# 1. ASSEMBLE a self-contained plugin under
|
|
7
|
+
# 1. ASSEMBLE a self-contained plugin under the version-stable XDG data dir
|
|
8
|
+
# ($XDG_DATA_HOME/entwurf/meta-bridge/.assembled) — NEVER inside the checkout:
|
|
8
9
|
# copy the committed skeleton, copy the entry shell + its lib (so
|
|
9
10
|
# ${CLAUDE_PLUGIN_ROOT} self-locates them), and BAKE the node abspath into
|
|
10
11
|
# hooks.json. The node path is the ONLY templated surface — the mailbox /
|
|
@@ -13,9 +14,10 @@
|
|
|
13
14
|
# entwurf_inbox_read tool comes from USER-scope entwurf-bridge MCP wiring
|
|
14
15
|
# (`claude mcp add -s user ...`). Project-scoped .mcp.json is deliberately
|
|
15
16
|
# not enough: a /tmp native session would wake without a receipt tool.
|
|
16
|
-
# 2. marketplace add <stable .assembled> (dev
|
|
17
|
-
# version-stable XDG data dir; NOT /tmp —
|
|
18
|
-
# `claude plugin marketplace update
|
|
17
|
+
# 2. marketplace add <stable XDG .assembled> (both dev clone and installed
|
|
18
|
+
# package assemble into the same version-stable XDG data dir; NOT /tmp —
|
|
19
|
+
# ephemeral source would break `claude plugin marketplace update`, and NOT
|
|
20
|
+
# the checkout — repo housekeeping must never cut the live user-scope wiring).
|
|
19
21
|
# 3. install entwurf-meta-receive@meta-bridge-local --scope user (= global:
|
|
20
22
|
# every native session auto-loads it; no manual --plugin-dir).
|
|
21
23
|
# 4. install/update USER-scope entwurf-bridge MCP, so every native session has
|
|
@@ -31,26 +33,27 @@ REPO="$(cd "$HERE/.." && pwd)"
|
|
|
31
33
|
MKT_NAME="meta-bridge-local"
|
|
32
34
|
PLUGIN="entwurf-meta-receive"
|
|
33
35
|
SRC="$REPO/pi/meta-bridge"
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
36
|
+
# The live marketplace artifact ALWAYS assembles under the version-stable XDG data
|
|
37
|
+
# dir — dev clone and installed package alike. Claude settings store this directory
|
|
38
|
+
# path and package-manager upgrades do not rewrite it (a pnpm-store path would go
|
|
39
|
+
# stale on version/peer churn). Critically it lives OUTSIDE the checkout, so repo
|
|
40
|
+
# housekeeping (git clean -xfd, check/smoke) can never cut the global user-scope
|
|
41
|
+
# wiring: dev vs installed is a difference of SOURCE ORIGIN (repo tree vs npm
|
|
42
|
+
# package we assemble FROM), never of where the live artifact lands.
|
|
43
|
+
ASM="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge/.assembled"
|
|
44
|
+
# The hook ARTIFACT form still splits by install shape (0.12.5): an installed
|
|
45
|
+
# package lives below node_modules, where Node REFUSES `--experimental-strip-types`
|
|
46
|
+
# on `.ts` (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so it runs the tsc-emitted
|
|
47
|
+
# `meta-bridge-hook.js` closure (mirrors start.sh/store-doctor). A dev clone lives
|
|
48
|
+
# outside node_modules — and so does the XDG artifact — so it runs the `.ts` source
|
|
49
|
+
# directly. HOOK_ENTRY is baked into hooks.json.
|
|
45
50
|
case "$REPO" in
|
|
46
51
|
*/node_modules/@junghanacs/entwurf)
|
|
47
|
-
ASM="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge/.assembled"
|
|
48
52
|
HOOK_ENTRY="meta-bridge-hook.js"
|
|
49
53
|
HOOK_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js"
|
|
50
54
|
LIB_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
|
|
51
55
|
LIB_EXT="js" ;;
|
|
52
56
|
*)
|
|
53
|
-
ASM="$SRC/.assembled"
|
|
54
57
|
HOOK_ENTRY="meta-bridge-hook.ts"
|
|
55
58
|
HOOK_SRC="$REPO/pi-extensions/meta-bridge-hook.ts"
|
|
56
59
|
LIB_SRC="$REPO/pi-extensions/lib/meta-session.ts"
|
|
@@ -513,9 +513,33 @@ def check(repo: Path, asm: Path) -> None:
|
|
|
513
513
|
failures.append(f"{claude_root_config_path()} root is not an object")
|
|
514
514
|
root = {}
|
|
515
515
|
|
|
516
|
+
# The marketplace source path in settings was written by apply() from the
|
|
517
|
+
# RECORDED assembledMarketplacePath. Compare against that recorded value, not the
|
|
518
|
+
# --asm passed here: a doctor/check run under a different XDG_DATA_HOME than
|
|
519
|
+
# install must not false-FAIL a correctly-wired marketplace. Fall back to --asm
|
|
520
|
+
# only if the recorded field is somehow absent.
|
|
521
|
+
recorded_asm = state.get("assembledMarketplacePath")
|
|
522
|
+
# Shape-validate the recorded path (basename included). Every state our code
|
|
523
|
+
# writes carries this field (init_state/prepare/apply), so a missing/empty/
|
|
524
|
+
# non-string value is itself corruption — NOT a reason to fall back to --asm and
|
|
525
|
+
# PASS. And a malformed value that matches a same-malformed settings entry (both
|
|
526
|
+
# hand-corrupted) would otherwise slip the comparison below and greenlight a
|
|
527
|
+
# bogus marketplace source. This mirrors the uninstall.sh honest-inverse guard:
|
|
528
|
+
# only the exact install suffix …/entwurf/meta-bridge/.assembled is valid.
|
|
529
|
+
ASM_SUFFIX = "/entwurf/meta-bridge/.assembled"
|
|
530
|
+
if not isinstance(recorded_asm, str) or not recorded_asm.endswith(ASM_SUFFIX):
|
|
531
|
+
failures.append(
|
|
532
|
+
f"install-state assembledMarketplacePath is missing/malformed ('{recorded_asm}'); "
|
|
533
|
+
f"must end in {ASM_SUFFIX} — repair state or re-run install-meta-bridge"
|
|
534
|
+
)
|
|
535
|
+
marketplace_expected = (
|
|
536
|
+
{"source": {"source": "directory", "path": recorded_asm}}
|
|
537
|
+
if recorded_asm
|
|
538
|
+
else desired_marketplace(asm)
|
|
539
|
+
)
|
|
516
540
|
checks = [
|
|
517
541
|
(["enabledPlugins", PLUGIN_REF], True, "enabled plugin"),
|
|
518
|
-
(["extraKnownMarketplaces", MARKETPLACE],
|
|
542
|
+
(["extraKnownMarketplaces", MARKETPLACE], marketplace_expected, "known marketplace"),
|
|
519
543
|
(["statusLine"], desired_statusline(repo), "statusLine"),
|
|
520
544
|
] + [(path_, desired, name) for name, path_, desired in MANAGED_SETTINGS_SCALARS]
|
|
521
545
|
for path_, expected, label in checks:
|
|
@@ -554,6 +578,7 @@ def main() -> int:
|
|
|
554
578
|
"apply",
|
|
555
579
|
"preflight-uninstall",
|
|
556
580
|
"uninstall",
|
|
581
|
+
"assembled-path",
|
|
557
582
|
"check",
|
|
558
583
|
"managed-keys",
|
|
559
584
|
"desired-mcp",
|
|
@@ -564,7 +589,13 @@ def main() -> int:
|
|
|
564
589
|
parser.add_argument("--asm", default=None, type=Path)
|
|
565
590
|
args = parser.parse_args()
|
|
566
591
|
repo = args.repo.resolve()
|
|
567
|
-
|
|
592
|
+
# The live artifact always lives under the XDG data dir — dev clone and
|
|
593
|
+
# installed package alike (mirror meta-bridge-install.sh's ASM resolution).
|
|
594
|
+
# install/doctor pass --asm explicitly; this default is the same XDG path so a
|
|
595
|
+
# bare invocation never falls back to a repo-internal marketplace source.
|
|
596
|
+
xdg_data = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share"))
|
|
597
|
+
default_asm = xdg_data / "entwurf" / "meta-bridge" / ".assembled"
|
|
598
|
+
asm = (args.asm or default_asm).resolve()
|
|
568
599
|
try:
|
|
569
600
|
if args.command == "prepare":
|
|
570
601
|
prepare(repo, asm)
|
|
@@ -574,6 +605,13 @@ def main() -> int:
|
|
|
574
605
|
preflight_uninstall()
|
|
575
606
|
elif args.command == "uninstall":
|
|
576
607
|
uninstall()
|
|
608
|
+
elif args.command == "assembled-path":
|
|
609
|
+
# Print the RECORDED assembled marketplace path so the honest inverse
|
|
610
|
+
# (uninstall) removes exactly what install created — not a path
|
|
611
|
+
# recomputed from a possibly-changed XDG_DATA_HOME. Requires state.
|
|
612
|
+
state = load_state(required=True)
|
|
613
|
+
assert state is not None # load_state(required=True) dies otherwise
|
|
614
|
+
print(state.get("assembledMarketplacePath", ""))
|
|
577
615
|
elif args.command == "check":
|
|
578
616
|
check(repo, asm)
|
|
579
617
|
elif args.command == "managed-keys":
|
|
@@ -26,6 +26,23 @@ command -v python3 >/dev/null || die "'python3' not on PATH. It is required for
|
|
|
26
26
|
# delete live plugin/MCP entries before failing — guessing by side effect.
|
|
27
27
|
python3 "$REPO/scripts/meta-bridge-state.py" preflight-uninstall --repo "$REPO"
|
|
28
28
|
|
|
29
|
+
# Read + shape-validate the RECORDED assembled path BEFORE any side effect. The
|
|
30
|
+
# honest inverse must remove exactly what install created; recomputing from
|
|
31
|
+
# ${XDG_DATA_HOME} would orphan the real artifact if it changed since install. If
|
|
32
|
+
# the recorded path is missing/corrupt we CANNOT safely remove the artifact, so we
|
|
33
|
+
# fail loud HERE — before touching any Claude registration or the state file (no
|
|
34
|
+
# guessing, no partial uninstall, no side-effect-then-WARN).
|
|
35
|
+
ASM_RECORDED="$(python3 "$REPO/scripts/meta-bridge-state.py" assembled-path --repo "$REPO")"
|
|
36
|
+
# Validate the FULL recorded path — basename included, not just the parent dir. The
|
|
37
|
+
# rm below targets MB_DIR (the parent meta-bridge dir), so a parent-only check would
|
|
38
|
+
# let a corrupt basename (…/entwurf/meta-bridge/not-assembled) pass as "well-formed"
|
|
39
|
+
# and still nuke the real .assembled + Claude registrations. Only the exact install
|
|
40
|
+
# suffix …/entwurf/meta-bridge/.assembled is a safe honest-inverse target.
|
|
41
|
+
case "$ASM_RECORDED" in
|
|
42
|
+
*/entwurf/meta-bridge/.assembled) MB_DIR="$(dirname "$ASM_RECORDED")" ;; # …/entwurf/meta-bridge
|
|
43
|
+
*) die "install-state assembledMarketplacePath is missing/corrupt ('$ASM_RECORDED'); refusing to uninstall so the live artifact is not orphaned. Repair the state file or re-run install-meta-bridge, then uninstall." ;;
|
|
44
|
+
esac
|
|
45
|
+
|
|
29
46
|
if command -v claude >/dev/null; then
|
|
30
47
|
claude plugin uninstall "$PLUGIN@$MKT_NAME" >/dev/null 2>&1 || true
|
|
31
48
|
claude plugin marketplace remove "$MKT_NAME" >/dev/null 2>&1 || true
|
|
@@ -37,16 +54,10 @@ fi
|
|
|
37
54
|
|
|
38
55
|
python3 "$REPO/scripts/meta-bridge-state.py" uninstall --repo "$REPO"
|
|
39
56
|
|
|
40
|
-
# Remove the assembled marketplace source
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*/node_modules/@junghanacs/entwurf)
|
|
47
|
-
rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge"
|
|
48
|
-
rmdir "${XDG_DATA_HOME:-$HOME/.local/share}/entwurf" 2>/dev/null || true ;;
|
|
49
|
-
*)
|
|
50
|
-
rm -rf "$REPO/pi/meta-bridge/.assembled" ;;
|
|
51
|
-
esac
|
|
57
|
+
# Remove the assembled marketplace source (validated above): the parent meta-bridge
|
|
58
|
+
# dir of the RECORDED .assembled, then the now-empty entwurf dir. This rm can NEVER
|
|
59
|
+
# reach inside the checkout, so repo housekeeping and this uninstall are structurally
|
|
60
|
+
# disjoint (the 0.12.x statusline-`?` impurity class is extinct, not guarded).
|
|
61
|
+
rm -rf "$MB_DIR"
|
|
62
|
+
rmdir "$(dirname "$MB_DIR")" 2>/dev/null || true # …/entwurf if empty
|
|
52
63
|
echo "[meta-bridge-uninstall] DONE"
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Register (or --remove) entwurf in a pi settings.json packages[].
|
|
3
|
+
|
|
4
|
+
The SINGLE predicate / idempotency / fail-loud SSOT shared by BOTH scopes and by
|
|
5
|
+
remove, so install and uninstall can never drift to different meanings:
|
|
6
|
+
- project <repo>/.pi/settings.json (run.sh install_local_package / remove_local_package)
|
|
7
|
+
- user ~/.pi/agent/settings.json (run.sh register_user_scope_citizen)
|
|
8
|
+
|
|
9
|
+
Register is idempotent: absent → append REPO_DIR; already the sole canonical
|
|
10
|
+
entry → no-op (file not rewritten, mtime stable); any other entwurf entry (object
|
|
11
|
+
form, stale path, duplicate) collapses into one canonical string form. Remove
|
|
12
|
+
drops every entwurf entry. Both use is_entwurf_source(), so a look-alike repo
|
|
13
|
+
(entwurf-notes, openclaw-entwurf) is neither wrongly registered-over nor wrongly
|
|
14
|
+
removed. Every non-entwurf package and every other settings key is preserved.
|
|
15
|
+
|
|
16
|
+
This wiring (user scope) dropped when `pi install` was removed from setup
|
|
17
|
+
(2026-07-03: `--entwurf-control` unknown in a foreign cwd). Extracting it here
|
|
18
|
+
lets run.sh (both scopes + remove) and smoke-user-scope-citizen share ONE
|
|
19
|
+
implementation — mirrors the meta-bridge-state.py split.
|
|
20
|
+
|
|
21
|
+
Usage: register-pi-package.py <settings.json> <repo_dir> [--remove]
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def source_of(item: object) -> object:
|
|
32
|
+
"""The package spec of a packages[] entry — string form or {"source": …}."""
|
|
33
|
+
return item.get("source") if isinstance(item, dict) else item
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_entwurf_source(source: str, repo_dir: str) -> bool:
|
|
37
|
+
"""True iff this package entry points at THIS entwurf — the only entries
|
|
38
|
+
register/remove may touch. Strict on purpose: user-scope settings are GLOBAL,
|
|
39
|
+
so a substring "entwurf" match would wrongly eat unrelated repos like
|
|
40
|
+
entwurf-notes, openclaw-entwurf, or somebody else's git repo named entwurf.
|
|
41
|
+
|
|
42
|
+
Managed shapes:
|
|
43
|
+
- the exact resolved repo dir;
|
|
44
|
+
- an npm install path ending in node_modules/@junghanacs/entwurf;
|
|
45
|
+
- an explicit npm package source for @junghanacs/entwurf;
|
|
46
|
+
- a local filesystem path whose final directory is literally "entwurf"
|
|
47
|
+
(dev clone / stale move). Remote URL/git-like strings are NOT treated as
|
|
48
|
+
local paths merely because their last segment is "entwurf".
|
|
49
|
+
"""
|
|
50
|
+
p = source.rstrip("/")
|
|
51
|
+
if p == repo_dir or p.endswith("/node_modules/@junghanacs/entwurf"):
|
|
52
|
+
return True
|
|
53
|
+
if p == "npm:@junghanacs/entwurf" or p.startswith("npm:@junghanacs/entwurf@"):
|
|
54
|
+
return True
|
|
55
|
+
local_like = p.startswith(("/", "./", "../", "~"))
|
|
56
|
+
return local_like and Path(p).name == "entwurf"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load(settings_path: Path) -> dict:
|
|
60
|
+
if settings_path.exists():
|
|
61
|
+
data = json.loads(settings_path.read_text())
|
|
62
|
+
if not isinstance(data, dict):
|
|
63
|
+
raise SystemExit(f"{settings_path} is not a JSON object")
|
|
64
|
+
return data
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _packages(settings_path: Path, data: dict) -> list:
|
|
69
|
+
packages = data.get("packages")
|
|
70
|
+
if packages is None:
|
|
71
|
+
return []
|
|
72
|
+
if not isinstance(packages, list):
|
|
73
|
+
# A settings file with a corrupt packages shape must NOT be silently
|
|
74
|
+
# coerced to [] — that would drop the operator's real packages.
|
|
75
|
+
raise SystemExit(f"{settings_path}: packages is not a JSON array")
|
|
76
|
+
return packages
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _entwurf_matches(packages: list, repo_dir: str) -> list:
|
|
80
|
+
return [
|
|
81
|
+
item for item in packages
|
|
82
|
+
if isinstance(source_of(item), str) and is_entwurf_source(source_of(item), repo_dir) # type: ignore[arg-type]
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def register(settings_path: Path, repo_dir_arg: str) -> str:
|
|
87
|
+
""""noop" if entwurf is already the sole canonical entry (file untouched),
|
|
88
|
+
else "registered" (rewritten with a single canonical entry)."""
|
|
89
|
+
repo_dir = str(Path(repo_dir_arg).resolve())
|
|
90
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
data = _load(settings_path)
|
|
92
|
+
packages = _packages(settings_path, data)
|
|
93
|
+
|
|
94
|
+
entwurf_entries = _entwurf_matches(packages, repo_dir)
|
|
95
|
+
# Already correct iff exactly ONE entwurf entry and it is the canonical string
|
|
96
|
+
# form at repo_dir. Order-insensitive; no rewrite → mtime stable.
|
|
97
|
+
if len(entwurf_entries) == 1 and entwurf_entries[0] == repo_dir:
|
|
98
|
+
return "noop"
|
|
99
|
+
|
|
100
|
+
filtered = [item for item in packages if item not in entwurf_entries]
|
|
101
|
+
data["packages"] = filtered + [repo_dir]
|
|
102
|
+
settings_path.write_text(json.dumps(data, indent=2) + "\n")
|
|
103
|
+
return "registered"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def remove(settings_path: Path, repo_dir_arg: str) -> int:
|
|
107
|
+
"""Drop every entwurf entry (any shape/path). Returns the count removed."""
|
|
108
|
+
repo_dir = str(Path(repo_dir_arg).resolve())
|
|
109
|
+
if not settings_path.exists():
|
|
110
|
+
return 0
|
|
111
|
+
data = _load(settings_path)
|
|
112
|
+
packages = _packages(settings_path, data)
|
|
113
|
+
|
|
114
|
+
entwurf_entries = _entwurf_matches(packages, repo_dir)
|
|
115
|
+
if not entwurf_entries:
|
|
116
|
+
return 0
|
|
117
|
+
data["packages"] = [item for item in packages if item not in entwurf_entries]
|
|
118
|
+
settings_path.write_text(json.dumps(data, indent=2) + "\n")
|
|
119
|
+
return len(entwurf_entries)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(argv: list[str]) -> int:
|
|
123
|
+
args = [a for a in argv[1:] if a != "--remove"]
|
|
124
|
+
do_remove = "--remove" in argv[1:]
|
|
125
|
+
if len(args) != 2:
|
|
126
|
+
raise SystemExit("usage: register-pi-package.py <settings.json> <repo_dir> [--remove]")
|
|
127
|
+
settings_path = Path(args[0])
|
|
128
|
+
repo_dir_arg = args[1]
|
|
129
|
+
resolved = str(Path(repo_dir_arg).resolve())
|
|
130
|
+
|
|
131
|
+
if do_remove:
|
|
132
|
+
n = remove(settings_path, repo_dir_arg)
|
|
133
|
+
if n:
|
|
134
|
+
print(f"remove: removed {n} entwurf packages[] entr{'y' if n == 1 else 'ies'} from {settings_path}")
|
|
135
|
+
else:
|
|
136
|
+
print(f"remove: no entwurf packages[] entry to remove ({settings_path})")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
result = register(settings_path, repo_dir_arg)
|
|
140
|
+
if result == "noop":
|
|
141
|
+
print(f"install: entwurf package already registered (no-op) -> {resolved}")
|
|
142
|
+
else:
|
|
143
|
+
print(f"install: registered entwurf package -> {settings_path}")
|
|
144
|
+
print(f"install: package source -> {resolved}")
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
raise SystemExit(main(sys.argv))
|
|
@@ -22,10 +22,58 @@ TMP="$(mktemp -d -t psa-meta-install-state.XXXXXX)"
|
|
|
22
22
|
cleanup() { rm -rf "$TMP"; }
|
|
23
23
|
trap cleanup EXIT
|
|
24
24
|
|
|
25
|
+
# ⓪ 설치 경계 봉쇄 (2026-07-03 `?` 사건의 구조적 소멸): source origin(repo/npm)과
|
|
26
|
+
# live artifact(XDG)를 분리한다. dev clone과 installed 소비자는 같은 user-scope
|
|
27
|
+
# install이고, 둘 다 marketplace source를 XDG data dir에 조립한다 — 절대 checkout
|
|
28
|
+
# 내부가 아니다. 이 smoke의 어떤 단계도 repo 안에 live source를 만들지 않음을
|
|
29
|
+
# top/bottom fingerprint로 감싸 증명하고, 아래 fake-install로 "install → XDG"를 직접 건다.
|
|
30
|
+
# present/absent만 보면 present→present 덮어쓰기를 놓치므로 내용 해시까지 비교한다.
|
|
31
|
+
asm_fingerprint() {
|
|
32
|
+
if [ -e "$REPO/pi/meta-bridge/.assembled" ]; then (cd "$REPO/pi/meta-bridge/.assembled" && find . -type f -exec sha256sum {} + 2>/dev/null | sort); else echo ABSENT; fi
|
|
33
|
+
}
|
|
34
|
+
REPO_ASM_FP_START="$(asm_fingerprint)"
|
|
35
|
+
|
|
36
|
+
# 실제 meta-bridge-install.sh를 격리된 HOME + XDG_DATA_HOME + fake claude로 돌려
|
|
37
|
+
# live marketplace source가 XDG 아래에 조립되고 `plugin marketplace add`가 그 XDG
|
|
38
|
+
# 경로를 받는지 직접 증명한다(오프라인: 실제 claude 없음).
|
|
39
|
+
INS_HOME="$TMP/ins-home"; INS_XDG="$TMP/ins-xdg"; INS_BIN="$TMP/ins-bin"
|
|
40
|
+
mkdir -p "$INS_HOME/.claude" "$INS_BIN"
|
|
41
|
+
echo '{}' > "$INS_HOME/.claude/settings.json"
|
|
42
|
+
echo '{}' > "$INS_HOME/.claude.json"
|
|
43
|
+
INS_LOG="$TMP/ins-claude.log"
|
|
44
|
+
cat > "$INS_BIN/claude" <<'SH'
|
|
45
|
+
#!/usr/bin/env bash
|
|
46
|
+
printf '%s\n' "$*" >> "$FAKE_CLAUDE_LOG"
|
|
47
|
+
case "$1${2:+ $2}" in
|
|
48
|
+
"plugin list") printf '%s\n' "entwurf-meta-receive@meta-bridge-local" " Status: enabled" ;;
|
|
49
|
+
"mcp get") printf '%s\n' "Scope: User config" "Status: Connected" ;;
|
|
50
|
+
*) : ;;
|
|
51
|
+
esac
|
|
52
|
+
exit 0
|
|
53
|
+
SH
|
|
54
|
+
chmod +x "$INS_BIN/claude"
|
|
55
|
+
if env HOME="$INS_HOME" CLAUDE_CONFIG_DIR="$INS_HOME/.claude" XDG_DATA_HOME="$INS_XDG" \
|
|
56
|
+
FAKE_CLAUDE_LOG="$INS_LOG" PATH="$INS_BIN:$PATH" \
|
|
57
|
+
bash "$REPO/scripts/meta-bridge-install.sh" >/dev/null 2>&1; then
|
|
58
|
+
if [ -e "$INS_XDG/entwurf/meta-bridge/.assembled/entwurf-meta-receive" ]; then ok "dev install assembles the live marketplace source under XDG (not the checkout)"; else bad "dev install did not assemble under XDG_DATA_HOME"; fi
|
|
59
|
+
MKT_ADD="$(grep '^plugin marketplace add ' "$INS_LOG" | head -1 || true)"
|
|
60
|
+
if printf '%s' "$MKT_ADD" | grep -Fq "$INS_XDG/entwurf/meta-bridge/.assembled"; then ok "claude plugin marketplace add received the XDG artifact path"; else bad "marketplace add did not point at XDG: $MKT_ADD"; fi
|
|
61
|
+
else
|
|
62
|
+
bad "real install-meta-bridge failed under isolated HOME/XDG/fake-claude:"$'\n'"$(cat "$INS_LOG" 2>/dev/null)"
|
|
63
|
+
fi
|
|
64
|
+
|
|
25
65
|
export HOME="$TMP/home"
|
|
66
|
+
# The wrapper-uninstall below removes ${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/
|
|
67
|
+
# meta-bridge. Pin XDG_DATA_HOME into the sandbox too, so a developer who runs this
|
|
68
|
+
# smoke with XDG_DATA_HOME set in their shell can never have their real live
|
|
69
|
+
# artifact removed — the boundary that keeps check/smoke off global user wiring.
|
|
70
|
+
export XDG_DATA_HOME="$TMP/xdg"
|
|
26
71
|
export CLAUDE_CONFIG_DIR="$HOME/.claude"
|
|
27
72
|
mkdir -p "$CLAUDE_CONFIG_DIR"
|
|
28
|
-
|
|
73
|
+
# Canonical-suffix asm: production install ALWAYS records a path ending in
|
|
74
|
+
# …/entwurf/meta-bridge/.assembled, and state.py check now shape-validates that
|
|
75
|
+
# suffix — so the survival harness must use a realistic path, not a bare stub.
|
|
76
|
+
ASM="$TMP/asm-xdg/entwurf/meta-bridge/.assembled"
|
|
29
77
|
export ASM
|
|
30
78
|
mkdir -p "$ASM"
|
|
31
79
|
|
|
@@ -213,8 +261,10 @@ then ok "uninstall restores scalars/maps and removes only managed array addition
|
|
|
213
261
|
|
|
214
262
|
if py uninstall >/dev/null 2>&1; then bad "state manager uninstall without state should not guess"; else ok "state manager uninstall without state fails instead of guessing"; fi
|
|
215
263
|
|
|
216
|
-
#
|
|
217
|
-
#
|
|
264
|
+
# ⓪ 봉쇄: wrapper-uninstall을 실제 $REPO 스크립트로 직접 돌린다. 이제 uninstall이
|
|
265
|
+
# 지우는 live artifact는 ${XDG_DATA_HOME}/entwurf/meta-bridge 하나뿐이고, 위에서
|
|
266
|
+
# HOME + XDG_DATA_HOME를 샌드박스로 고정했으므로 실제 개발자 배선/체크아웃에는
|
|
267
|
+
# 닿을 수 없다(2026-07-03 `?` 사건의 구조적 소멸 — 사본 repo 우회가 더는 불필요).
|
|
218
268
|
FAKE_BIN="$TMP/fake-bin"; mkdir -p "$FAKE_BIN"
|
|
219
269
|
cat > "$FAKE_BIN/claude" <<'SH'
|
|
220
270
|
#!/usr/bin/env bash
|
|
@@ -222,6 +272,9 @@ printf '%s\n' "$*" >> "$FAKE_CLAUDE_LOG"
|
|
|
222
272
|
exit 0
|
|
223
273
|
SH
|
|
224
274
|
chmod +x "$FAKE_BIN/claude"
|
|
275
|
+
|
|
276
|
+
# The wrapper must refuse before side effects. A fake claude records whether
|
|
277
|
+
# plugin/MCP removals were attempted; no state means the log must stay empty.
|
|
225
278
|
FAKE_CLAUDE_LOG="$TMP/fake-claude-nostate.log"
|
|
226
279
|
if PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$FAKE_CLAUDE_LOG" bash "$REPO/scripts/meta-bridge-uninstall.sh" >/dev/null 2>&1; then
|
|
227
280
|
bad "wrapper uninstall without state should fail"
|
|
@@ -229,10 +282,14 @@ else
|
|
|
229
282
|
if [ ! -s "$FAKE_CLAUDE_LOG" ]; then ok "wrapper uninstall without state has zero Claude side effects"; else bad "wrapper uninstall without state touched Claude: $(cat "$FAKE_CLAUDE_LOG")"; fi
|
|
230
283
|
fi
|
|
231
284
|
|
|
232
|
-
# With valid state, the wrapper
|
|
233
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
285
|
+
# With valid state, the wrapper removes Claude registrations, restores JSON state,
|
|
286
|
+
# AND removes the XDG live artifact RECORDED in state — never the checkout, never a
|
|
287
|
+
# path recomputed from the env. Record an XDG-structured assembled path and seed the
|
|
288
|
+
# sentinel artifact so we can prove uninstall drops exactly the recorded tree.
|
|
289
|
+
WASM="$XDG_DATA_HOME/entwurf/meta-bridge/.assembled"
|
|
290
|
+
python3 "$STATE" prepare --repo "$REPO" --asm "$WASM" >/dev/null
|
|
291
|
+
python3 "$STATE" apply --repo "$REPO" --asm "$WASM" >/dev/null
|
|
292
|
+
mkdir -p "$WASM"; : > "$WASM/.sentinel"
|
|
236
293
|
FAKE_CLAUDE_LOG="$TMP/fake-claude-state.log"
|
|
237
294
|
if PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$FAKE_CLAUDE_LOG" bash "$REPO/scripts/meta-bridge-uninstall.sh" >/dev/null 2>&1; then
|
|
238
295
|
if grep -q 'plugin uninstall entwurf-meta-receive@meta-bridge-local' "$FAKE_CLAUDE_LOG" && grep -q 'mcp remove entwurf-bridge -s user' "$FAKE_CLAUDE_LOG" && [ ! -f "$STATE_FILE" ]; then
|
|
@@ -240,10 +297,97 @@ if PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$FAKE_CLAUDE_LOG" bash "$REPO/scripts
|
|
|
240
297
|
else
|
|
241
298
|
bad "wrapper uninstall with state missed expected side effects/state removal"
|
|
242
299
|
fi
|
|
300
|
+
# 봉쇄가 회피가 아니라 구조임을 증명: uninstall이 지우는 건 recorded XDG artifact뿐.
|
|
301
|
+
if [ ! -e "$XDG_DATA_HOME/entwurf/meta-bridge" ]; then ok "wrapper removes the recorded XDG live artifact tree, not the checkout"; else bad "wrapper did not remove the recorded XDG live artifact"; fi
|
|
243
302
|
else
|
|
244
303
|
bad "wrapper uninstall with valid state failed"
|
|
245
304
|
fi
|
|
246
305
|
|
|
306
|
+
# Honest inverse under a CHANGED XDG_DATA_HOME (GPT hardening): uninstall must remove
|
|
307
|
+
# the RECORDED artifact path, never one recomputed from the current env. Record A,
|
|
308
|
+
# run uninstall with the env pointing at B, and prove A is gone while B is untouched.
|
|
309
|
+
XDGA="$TMP/xdgA"; XDGB="$TMP/xdgB"
|
|
310
|
+
ASM_A="$XDGA/entwurf/meta-bridge/.assembled"
|
|
311
|
+
python3 "$STATE" prepare --repo "$REPO" --asm "$ASM_A" >/dev/null
|
|
312
|
+
python3 "$STATE" apply --repo "$REPO" --asm "$ASM_A" >/dev/null
|
|
313
|
+
mkdir -p "$ASM_A"; : > "$ASM_A/.sentinel"
|
|
314
|
+
mkdir -p "$XDGB/entwurf/meta-bridge/.assembled"; : > "$XDGB/entwurf/meta-bridge/.assembled/.sentinel"
|
|
315
|
+
if XDG_DATA_HOME="$XDGB" PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$TMP/fake-claude-mismatch.log" bash "$REPO/scripts/meta-bridge-uninstall.sh" >/dev/null 2>&1; then
|
|
316
|
+
if [ ! -e "$XDGA/entwurf/meta-bridge" ]; then ok "uninstall removes the RECORDED artifact (XDG A) even when env XDG_DATA_HOME differs"; else bad "uninstall did not remove the recorded artifact A — recomputed from env instead"; fi
|
|
317
|
+
if [ -e "$XDGB/entwurf/meta-bridge/.assembled/.sentinel" ]; then ok "uninstall leaves the current-env XDG (B) untouched (no env recompute)"; else bad "uninstall wrongly removed the current-env XDG B"; fi
|
|
318
|
+
else
|
|
319
|
+
bad "wrapper uninstall (recorded-path mismatch case) failed"
|
|
320
|
+
fi
|
|
321
|
+
|
|
322
|
+
# GPT hardening: a corrupt recorded assembledMarketplacePath must fail the wrapper
|
|
323
|
+
# uninstall LOUD and BEFORE any side effect — no Claude removal, state file intact
|
|
324
|
+
# (honest inverse never guesses / never partially uninstalls / never WARN-then-DONE).
|
|
325
|
+
# Two corrupt shapes: a trivially-bad "/" AND the basename-corrupt case that a
|
|
326
|
+
# PARENT-ONLY guard would silently pass (…/entwurf/meta-bridge/not-assembled) — the
|
|
327
|
+
# rm targets the parent meta-bridge dir, so that shape must be refused by the FULL
|
|
328
|
+
# suffix guard or it nukes the real artifact + Claude registrations (2026-07-03).
|
|
329
|
+
for CORRUPT in "/" "$XDG_DATA_HOME/entwurf/meta-bridge/not-assembled"; do
|
|
330
|
+
python3 "$STATE" prepare --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
|
|
331
|
+
python3 "$STATE" apply --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
|
|
332
|
+
# seed the real artifact so an over-broad rm would be observable as its removal
|
|
333
|
+
mkdir -p "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled"; : > "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled/.sentinel"
|
|
334
|
+
CORRUPT="$CORRUPT" python3 - <<'PY'
|
|
335
|
+
import json, os
|
|
336
|
+
p = os.environ['CLAUDE_CONFIG_DIR'] + '/entwurf.install-state.json'
|
|
337
|
+
s = json.load(open(p)); s['assembledMarketplacePath'] = os.environ['CORRUPT']
|
|
338
|
+
json.dump(s, open(p, 'w'), indent=2)
|
|
339
|
+
PY
|
|
340
|
+
FAKE_CLAUDE_LOG="$TMP/fake-claude-corrupt.log"; : > "$FAKE_CLAUDE_LOG"
|
|
341
|
+
if PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$FAKE_CLAUDE_LOG" bash "$REPO/scripts/meta-bridge-uninstall.sh" >/dev/null 2>&1; then
|
|
342
|
+
bad "wrapper uninstall with corrupt recorded path '$CORRUPT' should fail loud"
|
|
343
|
+
elif [ -s "$FAKE_CLAUDE_LOG" ] || [ ! -f "$STATE_FILE" ] || [ ! -e "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled/.sentinel" ]; then
|
|
344
|
+
bad "corrupt-path uninstall '$CORRUPT' leaked side effects/removed artifact: claude_log=[$(cat "$FAKE_CLAUDE_LOG")] state_exists=$([ -f "$STATE_FILE" ] && echo yes || echo no) artifact=$([ -e "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled/.sentinel" ] && echo intact || echo REMOVED)"
|
|
345
|
+
else
|
|
346
|
+
ok "corrupt recorded path '$CORRUPT' → uninstall fails BEFORE side effects (no Claude removal, state + artifact intact)"
|
|
347
|
+
fi
|
|
348
|
+
rm -f "$STATE_FILE"; rm -rf "$XDG_DATA_HOME/entwurf/meta-bridge"
|
|
349
|
+
done
|
|
350
|
+
|
|
351
|
+
# GPT hardening: state.py check must compare the RECORDED marketplace path, not the
|
|
352
|
+
# --asm handed in. Install/apply with A, then check with a DIFFERENT --asm B: it must
|
|
353
|
+
# still PASS (Claude settings + state both hold A; only the caller's XDG differs).
|
|
354
|
+
CKA="$TMP/ck-xdgA/entwurf/meta-bridge/.assembled"
|
|
355
|
+
CKB="$TMP/ck-xdgB/entwurf/meta-bridge/.assembled"
|
|
356
|
+
python3 "$STATE" prepare --repo "$REPO" --asm "$CKA" >/dev/null
|
|
357
|
+
python3 "$STATE" apply --repo "$REPO" --asm "$CKA" >/dev/null
|
|
358
|
+
if python3 "$STATE" check --repo "$REPO" --asm "$CKB" >/dev/null 2>&1; then ok "state.py check passes when --asm differs from the recorded path (compares recorded, no XDG false-fail)"; else bad "state.py check false-FAILed on a recorded≠--asm mismatch"; fi
|
|
359
|
+
rm -f "$STATE_FILE"
|
|
360
|
+
|
|
361
|
+
# GPT hardening: check must shape-validate the recorded path, not just compare it to
|
|
362
|
+
# settings. Corrupt BOTH state + settings to the same basename-bad value: a pure
|
|
363
|
+
# consistency compare would PASS (both agree) and greenlight a bogus marketplace
|
|
364
|
+
# source. The suffix guard must FAIL it instead.
|
|
365
|
+
python3 "$STATE" prepare --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
|
|
366
|
+
python3 "$STATE" apply --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
|
|
367
|
+
python3 - <<'PY'
|
|
368
|
+
import json, os
|
|
369
|
+
xdg = os.environ['XDG_DATA_HOME']; corrupt = xdg + '/entwurf/meta-bridge/not-assembled'
|
|
370
|
+
sp = os.environ['CLAUDE_CONFIG_DIR'] + '/entwurf.install-state.json'
|
|
371
|
+
s = json.load(open(sp)); s['assembledMarketplacePath'] = corrupt; json.dump(s, open(sp, 'w'), indent=2)
|
|
372
|
+
cp = os.environ['CLAUDE_CONFIG_DIR'] + '/settings.json'
|
|
373
|
+
c = json.load(open(cp)); c['extraKnownMarketplaces']['meta-bridge-local'] = {'source': {'source': 'directory', 'path': corrupt}}; json.dump(c, open(cp, 'w'), indent=2)
|
|
374
|
+
PY
|
|
375
|
+
if python3 "$STATE" check --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null 2>&1; then bad "state.py check greenlit a both-corrupt (state+settings) malformed marketplace path"; else ok "state.py check fails a malformed recorded path even when settings agree (shape guard, not just consistency)"; fi
|
|
376
|
+
rm -f "$STATE_FILE"
|
|
377
|
+
|
|
378
|
+
# GPT hardening: a MISSING/empty recorded path is corruption too — every state our
|
|
379
|
+
# code writes carries the field, so check must NOT fall back to --asm and PASS.
|
|
380
|
+
# Delete the field with settings still valid; the shape guard must FAIL, not greenlight.
|
|
381
|
+
python3 "$STATE" prepare --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
|
|
382
|
+
python3 "$STATE" apply --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
|
|
383
|
+
python3 - <<'PY'
|
|
384
|
+
import json, os
|
|
385
|
+
sp = os.environ['CLAUDE_CONFIG_DIR'] + '/entwurf.install-state.json'
|
|
386
|
+
s = json.load(open(sp)); s.pop('assembledMarketplacePath', None); json.dump(s, open(sp, 'w'), indent=2)
|
|
387
|
+
PY
|
|
388
|
+
if python3 "$STATE" check --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null 2>&1; then bad "state.py check PASSed with a MISSING recorded assembledMarketplacePath (fell back to --asm)"; else ok "state.py check fails a missing/empty recorded path (no --asm fallback greenlight)"; fi
|
|
389
|
+
rm -f "$STATE_FILE"
|
|
390
|
+
|
|
247
391
|
# Doctor hook-log recovery predicate: only a later `INFO armed watch` clears an
|
|
248
392
|
# ERROR. A UserPromptSubmit `INFO attach record` is degraded backfill, not wake
|
|
249
393
|
# recovery.
|
|
@@ -365,7 +509,7 @@ if node --experimental-strip-types "$STORE_DOCTOR" "$STORE" >/dev/null 2>&1; the
|
|
|
365
509
|
# chain to its final summary line — i.e. no early set -e death anywhere.
|
|
366
510
|
DOC_HOME="$TMP/doctor-home"; DOC_CFG="$DOC_HOME/.claude"
|
|
367
511
|
DOC_AGENT="$TMP/doctor-agent"; DOC_STORE="$DOC_AGENT/meta-sessions"
|
|
368
|
-
DOC_BIN="$TMP/doctor-bin"; DOC_ASM="$
|
|
512
|
+
DOC_BIN="$TMP/doctor-bin"; DOC_ASM="$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" # SAME asm the doctor computes (XDG), so only the MCP drifts
|
|
369
513
|
mkdir -p "$DOC_CFG" "$DOC_STORE" "$DOC_BIN"
|
|
370
514
|
echo '{}' > "$DOC_CFG/settings.json"
|
|
371
515
|
echo '{}' > "$DOC_HOME/.claude.json"
|
|
@@ -405,5 +549,11 @@ if printf '%s\n' "$DOC_OUT" | grep -q 'user MCP entwurf-bridge'; then ok "doctor
|
|
|
405
549
|
if printf '%s\n' "$DOC_OUT" | grep -q '\[plugin install'; then ok "doctor continues to a later section after the drift (no early set -e death)"; else bad "doctor did not reach a later section header after the drift:"$'\n'"$DOC_OUT"; fi
|
|
406
550
|
if printf '%s\n' "$DOC_OUT" | grep -q 'meta-bridge doctor: FAIL'; then ok "doctor runs the whole chain to its final summary line"; else bad "doctor did not reach its final summary line (mid-run death):"$'\n'"$DOC_OUT"; fi
|
|
407
551
|
|
|
552
|
+
# ⓪ 경계 종료 단언: 이 smoke 전체(install 조립 + state + wrapper-uninstall + doctor)가
|
|
553
|
+
# 끝난 뒤에도 checkout 안에는 live marketplace source가 생기지 않았다 — source
|
|
554
|
+
# origin(repo)과 live artifact(XDG)가 구조적으로 분리됐다는 최종 증명. 미래 회귀가
|
|
555
|
+
# repo 내부 ASM을 되살리거나 덮어쓰면(내용 해시 변화 포함) 여기서 잡힌다.
|
|
556
|
+
if [ "$(asm_fingerprint)" = "$REPO_ASM_FP_START" ]; then ok "checkout-internal marketplace source is byte-identical before/after — no op created or mutated \$REPO/.assembled (source origin ≠ live artifact)"; else bad "a meta-bridge operation created/mutated \$REPO/pi/meta-bridge/.assembled — repo boundary breached"; fi
|
|
557
|
+
|
|
408
558
|
echo
|
|
409
559
|
if [ "$fail" -eq 0 ]; then echo "smoke-meta-install-state: PASS"; else echo "smoke-meta-install-state: FAIL (see above)"; exit 1; fi
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# smoke-user-scope-citizen — deterministic gate for pi packages[] citizen
|
|
3
|
+
# registration/removal (register-pi-package.py). Offline/hermetic: a fake
|
|
4
|
+
# settings file + a fake REPO_DIR under $TMP, no pi, no network, no ~/.pi touched.
|
|
5
|
+
#
|
|
6
|
+
# Guards the wiring that dropped when `pi install` was removed from setup
|
|
7
|
+
# (2026-07-03: `--entwurf-control` / `--emacs-agent-socket` unknown in a foreign
|
|
8
|
+
# cwd because entwurf was absent from ~/.pi/agent/settings.json packages[]).
|
|
9
|
+
# statusline `?` had a tripwire; THIS omission had none and hid until GLG hit it
|
|
10
|
+
# in another repo. This gate is that missing tripwire: if the registration ever
|
|
11
|
+
# regresses, `pnpm check` goes red.
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
|
|
14
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
15
|
+
REPO="$(cd "$HERE/.." && pwd)"
|
|
16
|
+
REG="$REPO/scripts/register-pi-package.py"
|
|
17
|
+
|
|
18
|
+
fail=0
|
|
19
|
+
ok() { echo " ok $*"; }
|
|
20
|
+
bad() { echo " FAIL $*"; fail=1; }
|
|
21
|
+
|
|
22
|
+
command -v python3 >/dev/null || { echo "FAIL: python3 not on PATH"; exit 1; }
|
|
23
|
+
|
|
24
|
+
TMP="$(mktemp -d -t psa-user-citizen.XXXXXX)"
|
|
25
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
26
|
+
S="$TMP/settings.json"
|
|
27
|
+
FAKE_REPO="$TMP/fake/entwurf"; mkdir -p "$FAKE_REPO"
|
|
28
|
+
RESOLVED="$(cd "$FAKE_REPO" && pwd)"
|
|
29
|
+
|
|
30
|
+
has_pkg() { python3 -c "import json,sys;p=json.load(open('$S'))['packages'];sys.exit(0 if '$1' in p else 1)"; }
|
|
31
|
+
count_entwurf() { python3 -c "import json;p=json.load(open('$S'))['packages'];print(sum(1 for x in p if isinstance((x if isinstance(x,str) else x.get('source')),str) and (x if isinstance(x,str) else x.get('source')).rstrip('/').split('/')[-1]=='entwurf' or (x if isinstance(x,str) else x.get('source'))=='$RESOLVED'))"; }
|
|
32
|
+
|
|
33
|
+
# 1. absent settings file → created with entwurf registered
|
|
34
|
+
python3 "$REG" "$S" "$FAKE_REPO" >/dev/null
|
|
35
|
+
if [ -f "$S" ] && has_pkg "$RESOLVED"; then ok "absent settings → entwurf registered (file created)"; else bad "entwurf not registered into a fresh settings file"; fi
|
|
36
|
+
|
|
37
|
+
# 2. idempotent: second run is a no-op AND does not even rewrite the file (mtime stable)
|
|
38
|
+
MT1="$(stat -c %Y "$S")"
|
|
39
|
+
sleep 1
|
|
40
|
+
OUT2="$(python3 "$REG" "$S" "$FAKE_REPO")"
|
|
41
|
+
MT2="$(stat -c %Y "$S")"
|
|
42
|
+
if printf '%s' "$OUT2" | grep -q 'no-op'; then ok "second run is a no-op (idempotent)"; else bad "second run was not a no-op: $OUT2"; fi
|
|
43
|
+
if [ "$MT1" = "$MT2" ]; then ok "no-op does NOT rewrite the file (mtime stable — strongest idempotence)"; else bad "no-op rewrote the file (mtime changed $MT1 -> $MT2)"; fi
|
|
44
|
+
if [ "$(count_entwurf)" = "1" ]; then ok "no duplicate entwurf entry after idempotent re-run"; else bad "duplicate entwurf entries: $(count_entwurf)"; fi
|
|
45
|
+
|
|
46
|
+
# 3. preserves unrelated packages AND unrelated keys — including look-alike repos
|
|
47
|
+
# whose path merely CONTAINS 'entwurf' and remote repos whose last segment is
|
|
48
|
+
# 'entwurf' but are not THIS package.
|
|
49
|
+
cat > "$S" <<'JSON'
|
|
50
|
+
{
|
|
51
|
+
"defaultProvider": "openai-codex",
|
|
52
|
+
"theme": "glg-dark",
|
|
53
|
+
"packages": ["../../repos/gh/andenken", "git:github.com/badlogic/pi-telegram",
|
|
54
|
+
"/home/me/entwurf-notes", "/x/openclaw-entwurf",
|
|
55
|
+
"git:github.com/someone/entwurf", "https://github.com/someone/entwurf"]
|
|
56
|
+
}
|
|
57
|
+
JSON
|
|
58
|
+
python3 "$REG" "$S" "$FAKE_REPO" >/dev/null
|
|
59
|
+
if python3 -c "
|
|
60
|
+
import json
|
|
61
|
+
d=json.load(open('$S')); p=d['packages']
|
|
62
|
+
assert '../../repos/gh/andenken' in p, 'andenken dropped'
|
|
63
|
+
assert 'git:github.com/badlogic/pi-telegram' in p, 'pi-telegram dropped'
|
|
64
|
+
assert '/home/me/entwurf-notes' in p, 'entwurf-notes wrongly filtered (substring false-positive)'
|
|
65
|
+
assert '/x/openclaw-entwurf' in p, 'openclaw-entwurf wrongly filtered (substring false-positive)'
|
|
66
|
+
assert 'git:github.com/someone/entwurf' in p, 'foreign git repo named entwurf wrongly filtered'
|
|
67
|
+
assert 'https://github.com/someone/entwurf' in p, 'foreign URL repo named entwurf wrongly filtered'
|
|
68
|
+
assert '$RESOLVED' in p, 'entwurf missing'
|
|
69
|
+
assert d['defaultProvider']=='openai-codex' and d['theme']=='glg-dark', 'unrelated key dropped'
|
|
70
|
+
" 2>/dev/null; then ok "preserves unrelated packages incl. look-alikes/foreign entwurf repos and keys"; else bad "clobbered unrelated packages/keys or a look-alike repo"; fi
|
|
71
|
+
|
|
72
|
+
# 4. stale entwurf paths (bare local 'entwurf' dir, npm install path, explicit npm
|
|
73
|
+
# package source) normalize to one canonical REPO_DIR.
|
|
74
|
+
cat > "$S" <<'JSON'
|
|
75
|
+
{"packages": ["/old/moved/entwurf", "/some/root/node_modules/@junghanacs/entwurf",
|
|
76
|
+
"npm:@junghanacs/entwurf@0.12.4", "../../repos/gh/andenken"]}
|
|
77
|
+
JSON
|
|
78
|
+
python3 "$REG" "$S" "$FAKE_REPO" >/dev/null
|
|
79
|
+
if python3 -c "
|
|
80
|
+
import json
|
|
81
|
+
p=json.load(open('$S'))['packages']
|
|
82
|
+
assert '/old/moved/entwurf' not in p, 'stale bare-entwurf path not dropped'
|
|
83
|
+
assert '/some/root/node_modules/@junghanacs/entwurf' not in p, 'stale npm path not dropped'
|
|
84
|
+
assert 'npm:@junghanacs/entwurf@0.12.4' not in p, 'stale explicit npm source not dropped'
|
|
85
|
+
assert '$RESOLVED' in p, 'entwurf not normalized to REPO_DIR'
|
|
86
|
+
assert '../../repos/gh/andenken' in p, 'andenken dropped'
|
|
87
|
+
ent=[x for x in p if isinstance((x if isinstance(x,str) else x.get('source')),str) and ((x if isinstance(x,str) else x.get('source')).rstrip('/').split('/')[-1]=='entwurf' or (x if isinstance(x,str) else x.get('source')).endswith('/node_modules/@junghanacs/entwurf') or (x if isinstance(x,str) else x.get('source'))=='$RESOLVED')]
|
|
88
|
+
assert len(ent)==1, f'expected exactly 1 entwurf entry, got {ent}'
|
|
89
|
+
" 2>/dev/null; then ok "stale entwurf paths/sources (bare + npm) normalized to a single REPO_DIR"; else bad "stale entwurf normalization failed"; fi
|
|
90
|
+
|
|
91
|
+
# 5. object-form entwurf entries collapse to one canonical string (no duplicate)
|
|
92
|
+
cat > "$S" <<JSON
|
|
93
|
+
{"packages": [{"source": "$RESOLVED"}, "../../repos/gh/andenken"]}
|
|
94
|
+
JSON
|
|
95
|
+
python3 "$REG" "$S" "$FAKE_REPO" >/dev/null
|
|
96
|
+
python3 "$REG" "$S" "$FAKE_REPO" >/dev/null # twice → still one
|
|
97
|
+
if python3 -c "
|
|
98
|
+
import json
|
|
99
|
+
p=json.load(open('$S'))['packages']
|
|
100
|
+
srcs=[(x if isinstance(x,str) else x.get('source')) for x in p]
|
|
101
|
+
assert srcs.count('$RESOLVED')==1, f'object-form produced duplicate/none: {p}'
|
|
102
|
+
assert all(not isinstance(x,dict) or x.get('source')!='$RESOLVED' for x in p), 'object-form entwurf survived (should collapse to string)'
|
|
103
|
+
assert '../../repos/gh/andenken' in p, 'andenken dropped'
|
|
104
|
+
" 2>/dev/null; then ok "object-form entwurf collapses to a single canonical string (no dup across re-runs)"; else bad "object-form entwurf caused a duplicate or survived as object"; fi
|
|
105
|
+
|
|
106
|
+
# 6. a non-object settings file fails loud (never silently wipes operator config)
|
|
107
|
+
echo '[]' > "$S"
|
|
108
|
+
if python3 "$REG" "$S" "$FAKE_REPO" >/dev/null 2>&1; then bad "non-object settings should fail, not overwrite"; else ok "non-object settings fails loud (no silent clobber)"; fi
|
|
109
|
+
|
|
110
|
+
# 7. a non-array packages value fails loud (never silently coerced to [])
|
|
111
|
+
echo '{"packages": {"broken": true}}' > "$S"
|
|
112
|
+
if python3 "$REG" "$S" "$FAKE_REPO" >/dev/null 2>&1; then bad "non-array packages should fail, not coerce to []"; else ok "non-array packages fails loud (no silent drop of operator packages)"; fi
|
|
113
|
+
|
|
114
|
+
# 8. --remove drops every entwurf entry but preserves unrelated + look-alikes
|
|
115
|
+
# (symmetric with install: same is_entwurf_source predicate, no over-delete).
|
|
116
|
+
cat > "$S" <<JSON
|
|
117
|
+
{"packages": ["$RESOLVED", "../../repos/gh/andenken", "/home/me/entwurf-notes"]}
|
|
118
|
+
JSON
|
|
119
|
+
python3 "$REG" "$S" "$FAKE_REPO" --remove >/dev/null
|
|
120
|
+
if python3 -c "
|
|
121
|
+
import json
|
|
122
|
+
p=json.load(open('$S'))['packages']
|
|
123
|
+
assert '$RESOLVED' not in p, 'entwurf not removed'
|
|
124
|
+
assert '../../repos/gh/andenken' in p, 'andenken over-removed'
|
|
125
|
+
assert '/home/me/entwurf-notes' in p, 'entwurf-notes over-removed (substring over-delete)'
|
|
126
|
+
" 2>/dev/null; then ok "--remove drops entwurf, preserves unrelated + look-alikes (symmetric with install)"; else bad "--remove over-deleted a look-alike or missed entwurf"; fi
|
|
127
|
+
|
|
128
|
+
echo
|
|
129
|
+
if [ "$fail" -eq 0 ]; then echo "smoke-user-scope-citizen: PASS"; else echo "smoke-user-scope-citizen: FAIL (see above)"; exit 1; fi
|