@junghanacs/entwurf 0.12.5 → 0.12.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +31 -17
- package/BASELINE.md +42 -8
- package/CHANGELOG.md +44 -0
- package/DELIVERY.md +71 -14
- package/README.md +77 -45
- package/VERIFY.md +33 -38
- package/docs/setup-clean-host.md +80 -22
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
- package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
- package/mcp/entwurf-bridge/src/index.ts +101 -67
- package/mcp/entwurf-bridge/test.sh +1 -1
- package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
- package/package.json +10 -6
- package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
- package/pi-extensions/lib/entwurf-self-address.ts +58 -15
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
- package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
- package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
- package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
- package/pi-extensions/lib/meta-sender-identity.ts +154 -0
- package/pi-extensions/lib/native-push/adapter.ts +255 -0
- package/pi-extensions/lib/native-push/register.ts +99 -0
- package/run.sh +974 -248
- package/scripts/agy-bridge-config.py +446 -0
- package/scripts/agy-bridge.sh +359 -0
- package/scripts/agy-hooks-bridge.sh +193 -0
- package/scripts/agy-hooks-config.py +257 -0
- package/scripts/agy-imprint.sh +28 -0
- package/scripts/agy-imprint.ts +193 -0
- package/scripts/agy-statusline-bridge.sh +176 -0
- package/scripts/agy-statusline-config.py +213 -0
- package/scripts/agy-statusline.sh +256 -0
- package/scripts/build-bridge.sh +20 -0
- package/scripts/check-agy-sender-identity.ts +364 -0
- package/scripts/check-entwurf-bridge-boot.ts +8 -2
- package/scripts/check-entwurf-deliverability.ts +34 -0
- package/scripts/check-entwurf-self-address.ts +78 -11
- package/scripts/check-entwurf-v2-contract.ts +136 -1
- package/scripts/check-entwurf-v2-decider.ts +95 -1
- package/scripts/check-entwurf-v2-matrix.ts +14 -3
- package/scripts/check-entwurf-v2-native-push.ts +193 -0
- package/scripts/check-entwurf-v2-production.ts +68 -1
- package/scripts/check-entwurf-v2-runner.ts +58 -0
- package/scripts/check-entwurf-v2-surface.ts +35 -0
- package/scripts/check-install-surface.ts +357 -0
- package/scripts/check-native-push-adapter.ts +319 -0
- package/scripts/check-native-push-register.ts +130 -0
- package/scripts/dev-bin.sh +195 -0
- package/scripts/doctor-pi-provider.ts +140 -0
- package/scripts/meta-bridge-doctor.sh +47 -9
- 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 +183 -0
- package/scripts/register-pi-provider.py +287 -0
- package/scripts/smoke-agy-hooks-state.sh +172 -0
- package/scripts/smoke-agy-install-state.sh +660 -0
- package/scripts/smoke-agy-native-push-live.ts +243 -0
- package/scripts/smoke-agy-statusline-state.sh +300 -0
- package/scripts/smoke-meta-async-drift.sh +9 -2
- package/scripts/smoke-meta-install-state.sh +178 -8
- package/scripts/smoke-pi-provider-state.sh +182 -0
- package/scripts/smoke-user-scope-citizen.sh +191 -0
- package/scripts/with-dist-lock.sh +81 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// doctor-pi-provider — fail-loud doctor for the pi provider (entwurfProvider.mcpServers.
|
|
3
|
+
// entwurf-bridge) ownership (#46 Task 2). Side-effect FREE (read-only). Uses the config.ts SSOT
|
|
4
|
+
// `readProviderSettingsFile` so the effective (shadow-resolved) view matches what pi actually
|
|
5
|
+
// loads — NOT a re-implemented merge (GPT D: a python re-impl drifts into "doctor green, runtime
|
|
6
|
+
// red"). Reports user / project / EFFECTIVE command (project shadows user per-name, the
|
|
7
|
+
// resolveProviderConfig rule), plus install-state ownership, and gates on stable-bin resolvability.
|
|
8
|
+
//
|
|
9
|
+
// Env overrides (for the hermetic smoke):
|
|
10
|
+
// PI_PROVIDER_GLOBAL_SETTINGS default: $PI_CODING_AGENT_DIR/settings.json or ~/.pi/agent/settings.json
|
|
11
|
+
// PI_PROVIDER_PROJECT_SETTINGS default: <cwd>/.pi/settings.json
|
|
12
|
+
// PI_PROVIDER_STATE default: $XDG_DATA_HOME/entwurf/pi-provider/install-state.json
|
|
13
|
+
//
|
|
14
|
+
// Exit: 0 ok (incl. honest "never installed / unowned" notes) · 1 hard fail (malformed settings /
|
|
15
|
+
// state-owned-but-drifted / stable bin dangling).
|
|
16
|
+
import { execSync } from "node:child_process";
|
|
17
|
+
import { existsSync, constants as FS, readFileSync, statSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { readProviderSettingsFile } from "../pi-extensions/lib/acp/config.ts";
|
|
21
|
+
|
|
22
|
+
const BARE = "entwurf-bridge";
|
|
23
|
+
const KEY = "entwurf-bridge";
|
|
24
|
+
|
|
25
|
+
const home = homedir();
|
|
26
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR || join(home, ".pi", "agent");
|
|
27
|
+
const globalPath = process.env.PI_PROVIDER_GLOBAL_SETTINGS || join(agentDir, "settings.json");
|
|
28
|
+
const projectPath = process.env.PI_PROVIDER_PROJECT_SETTINGS || join(process.cwd(), ".pi", "settings.json");
|
|
29
|
+
const xdg = process.env.XDG_DATA_HOME || join(home, ".local", "share");
|
|
30
|
+
const statePath = process.env.PI_PROVIDER_STATE || join(xdg, "entwurf", "pi-provider", "install-state.json");
|
|
31
|
+
|
|
32
|
+
let hardFail = 0;
|
|
33
|
+
const log = (s: string) => process.stdout.write(s + "\n");
|
|
34
|
+
|
|
35
|
+
function commandOf(settings: { mcpServers?: Record<string, unknown> }): string | undefined {
|
|
36
|
+
const entry = settings.mcpServers?.[KEY];
|
|
37
|
+
if (entry && typeof entry === "object" && typeof (entry as { command?: unknown }).command === "string") {
|
|
38
|
+
return (entry as { command: string }).command;
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Does the command resolve in the environment (best local proxy for "where pi/agy runs")?
|
|
44
|
+
// A bare name is looked up on PATH; a path must be an executable file.
|
|
45
|
+
function resolvable(cmd: string): boolean {
|
|
46
|
+
if (cmd.includes("/")) {
|
|
47
|
+
try {
|
|
48
|
+
statSync(cmd);
|
|
49
|
+
// eslint-disable-next-line no-bitwise
|
|
50
|
+
return (statSync(cmd).mode & FS.S_IXUSR) !== 0;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
// `command -v` is a POSIX sh builtin; use the default /bin/sh (NixOS has no /bin/bash).
|
|
57
|
+
execSync(`command -v ${cmd}`, { stdio: "ignore" });
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
log("[pi-provider doctor]");
|
|
65
|
+
|
|
66
|
+
// Read via the SSOT — a malformed settings file THROWS here (fail-loud, named file).
|
|
67
|
+
let userCmd: string | undefined;
|
|
68
|
+
let projCmd: string | undefined;
|
|
69
|
+
try {
|
|
70
|
+
userCmd = commandOf(readProviderSettingsFile(globalPath).settings);
|
|
71
|
+
projCmd = commandOf(readProviderSettingsFile(projectPath).settings);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
log(` FAIL: ${err instanceof Error ? err.message : String(err)}`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// EFFECTIVE = project shadows user per-name (the resolveProviderConfig merge rule).
|
|
78
|
+
const effectiveCmd = projCmd ?? userCmd;
|
|
79
|
+
const effectiveScope = projCmd !== undefined ? "project" : userCmd !== undefined ? "user(global)" : "none";
|
|
80
|
+
|
|
81
|
+
log("── scopes (project shadows user per-name)");
|
|
82
|
+
log(` user(global) ${globalPath}: ${userCmd ? `'${userCmd}'` : "entwurf-bridge NOT configured"}`);
|
|
83
|
+
log(` project ${projectPath}: ${projCmd ? `'${projCmd}'` : "entwurf-bridge NOT configured"}`);
|
|
84
|
+
log(` EFFECTIVE (${effectiveScope}): ${effectiveCmd ? `'${effectiveCmd}'` : "none"}`);
|
|
85
|
+
|
|
86
|
+
// install-state ownership (user scope). absent state on a configured effective is either a
|
|
87
|
+
// pre-Task-2 install or a user-override we deliberately did not own.
|
|
88
|
+
let ownership: string | undefined;
|
|
89
|
+
if (existsSync(statePath)) {
|
|
90
|
+
try {
|
|
91
|
+
const st = JSON.parse(readFileSync(statePath, "utf8")) as { ownership?: string };
|
|
92
|
+
ownership = typeof st.ownership === "string" ? st.ownership : undefined;
|
|
93
|
+
log(` state: install-state present (ownership=${ownership}).`);
|
|
94
|
+
} catch {
|
|
95
|
+
log(` state: FAIL — install-state ${statePath} is unreadable/corrupt.`);
|
|
96
|
+
hardFail = 1;
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
log(" state: no user-scope install-state.");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
log("── verdict");
|
|
103
|
+
if (effectiveCmd === undefined) {
|
|
104
|
+
log(
|
|
105
|
+
" note: no entwurfProvider.mcpServers.entwurf-bridge in any scope (never installed — this is the '?'; run ./run.sh setup).",
|
|
106
|
+
);
|
|
107
|
+
} else if (effectiveCmd === BARE) {
|
|
108
|
+
if (resolvable(effectiveCmd)) {
|
|
109
|
+
log(` ok: effective command is the bare stable bin '${BARE}' and it RESOLVES.`);
|
|
110
|
+
} else {
|
|
111
|
+
log(` FAIL: effective command is '${BARE}' but it does NOT resolve (run ./run.sh expose-dev-bin / npm bin-link).`);
|
|
112
|
+
hardFail = 1;
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
// effective is NOT the bare bin. If state says we own it → drift (FAIL). Otherwise classify
|
|
116
|
+
// the effective command honestly: our OWN legacy repo start.sh (not yet adopted) is NOT a
|
|
117
|
+
// user override — say so distinctly so "run setup" is the clear next step. A truly foreign
|
|
118
|
+
// command is an unowned override left as the operator's choice. Neither is a hard fail.
|
|
119
|
+
const isLegacyManaged = effectiveCmd.endsWith("/entwurf/mcp/entwurf-bridge/start.sh");
|
|
120
|
+
if (ownership && ownership !== "user-override") {
|
|
121
|
+
log(
|
|
122
|
+
` FAIL: state owns entwurf-bridge (ownership=${ownership}) but the effective command drifted to '${effectiveCmd}'.`,
|
|
123
|
+
);
|
|
124
|
+
hardFail = 1;
|
|
125
|
+
} else if (isLegacyManaged) {
|
|
126
|
+
log(
|
|
127
|
+
` note: effective is our LEGACY managed repo path ('${effectiveCmd}'), not yet adopted to the bare stable bin. Run ./run.sh setup to normalize (this is the pre-Task-2 '?').`,
|
|
128
|
+
);
|
|
129
|
+
} else {
|
|
130
|
+
log(
|
|
131
|
+
` note: entwurf-bridge is an UNOWNED override ('${effectiveCmd}') — effective is not the stable bin. Left as the operator's choice (run ./run.sh setup to adopt the bare bin).`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (hardFail) {
|
|
137
|
+
log("pi-provider doctor: FAIL.");
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
log("pi-provider doctor: ok.");
|
|
@@ -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"
|
|
@@ -89,9 +93,43 @@ else
|
|
|
89
93
|
fi
|
|
90
94
|
|
|
91
95
|
echo "[plugin install (global / --scope user)]"
|
|
92
|
-
|
|
96
|
+
PLUGIN_LIST_JSON="$(claude plugin list --json 2>/dev/null || true)"
|
|
97
|
+
PLUGIN_FACT="$(printf '%s' "$PLUGIN_LIST_JSON" | python3 -c '
|
|
98
|
+
import json, sys
|
|
99
|
+
try:
|
|
100
|
+
rows = json.load(sys.stdin)
|
|
101
|
+
except Exception:
|
|
102
|
+
raise SystemExit(1)
|
|
103
|
+
target = sys.argv[1]
|
|
104
|
+
row = next((x for x in rows if isinstance(x, dict) and x.get("id") == target), None)
|
|
105
|
+
if row is None:
|
|
106
|
+
print("absent")
|
|
107
|
+
else:
|
|
108
|
+
print("present")
|
|
109
|
+
print("true" if row.get("enabled") is True else "false")
|
|
110
|
+
errors = row.get("errors")
|
|
111
|
+
print("; ".join(str(x) for x in errors) if isinstance(errors, list) else "")
|
|
112
|
+
' "$PLUGIN@$MKT_NAME" 2>/dev/null || true)"
|
|
113
|
+
if [ -n "$PLUGIN_FACT" ]; then
|
|
114
|
+
PLUGIN_PRESENT="$(printf '%s\n' "$PLUGIN_FACT" | sed -n '1p')"
|
|
115
|
+
PLUGIN_ENABLED="$(printf '%s\n' "$PLUGIN_FACT" | sed -n '2p')"
|
|
116
|
+
PLUGIN_ERRORS="$(printf '%s\n' "$PLUGIN_FACT" | sed -n '3p')"
|
|
117
|
+
if [ "$PLUGIN_PRESENT" = "absent" ]; then
|
|
118
|
+
bad "$PLUGIN@$MKT_NAME not installed — run ./run.sh install-meta-bridge"
|
|
119
|
+
else
|
|
120
|
+
ok "$PLUGIN@$MKT_NAME present"
|
|
121
|
+
if [ -n "$PLUGIN_ERRORS" ]; then
|
|
122
|
+
bad "enabled=$PLUGIN_ENABLED but FAILED TO LOAD: $PLUGIN_ERRORS — the SessionStart hook is not running; re-run ./run.sh install-meta-bridge"
|
|
123
|
+
elif [ "$PLUGIN_ENABLED" = "true" ]; then
|
|
124
|
+
ok "enabled and loadable"
|
|
125
|
+
else
|
|
126
|
+
bad "installed but NOT enabled"
|
|
127
|
+
fi
|
|
128
|
+
fi
|
|
129
|
+
# Compatibility fallback for a Claude floor whose plugin list has no --json surface.
|
|
130
|
+
elif claude plugin list 2>/dev/null | grep -q "$PLUGIN@$MKT_NAME"; then
|
|
93
131
|
ok "$PLUGIN@$MKT_NAME present"
|
|
94
|
-
claude plugin list 2>/dev/null | grep -A3 "$PLUGIN@$MKT_NAME" | grep -qi "enabled" && ok "enabled" || bad "installed but NOT enabled"
|
|
132
|
+
claude plugin list 2>/dev/null | grep -A3 "$PLUGIN@$MKT_NAME" | grep -qi "enabled" && ok "enabled (text fallback; load errors unavailable)" || bad "installed but NOT enabled"
|
|
95
133
|
else
|
|
96
134
|
bad "$PLUGIN@$MKT_NAME not installed — run ./run.sh install-meta-bridge"
|
|
97
135
|
fi
|
|
@@ -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,183 @@
|
|
|
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 would_remove(settings_path: Path, repo_dir_arg: str) -> int:
|
|
123
|
+
"""Count the entwurf entries a --remove WOULD drop, writing NOTHING.
|
|
124
|
+
|
|
125
|
+
Read-only companion to remove() for --dry-run — lets a caller (e.g. run.sh's
|
|
126
|
+
project `remove` pointer note) decide whether the global user-scope inverse is
|
|
127
|
+
worth suggesting without mutating the operator's settings.
|
|
128
|
+
"""
|
|
129
|
+
repo_dir = str(Path(repo_dir_arg).resolve())
|
|
130
|
+
if not settings_path.exists():
|
|
131
|
+
return 0
|
|
132
|
+
data = _load(settings_path)
|
|
133
|
+
packages = _packages(settings_path, data)
|
|
134
|
+
return len(_entwurf_matches(packages, repo_dir))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main(argv: list[str]) -> int:
|
|
138
|
+
flags = {a for a in argv[1:] if a.startswith("--")}
|
|
139
|
+
args = [a for a in argv[1:] if not a.startswith("--")]
|
|
140
|
+
do_remove = "--remove" in flags
|
|
141
|
+
dry_run = "--dry-run" in flags
|
|
142
|
+
known = {"--remove", "--dry-run"}
|
|
143
|
+
unknown = flags - known
|
|
144
|
+
if unknown:
|
|
145
|
+
raise SystemExit(f"unknown flag(s): {', '.join(sorted(unknown))}")
|
|
146
|
+
# --dry-run is a REMOVE-only preview. Without --remove it would otherwise fall
|
|
147
|
+
# through to the register path and WRITE — a flag literally named "dry-run"
|
|
148
|
+
# mutating settings is an install-hygiene footgun, so reject it loud instead of
|
|
149
|
+
# silently registering.
|
|
150
|
+
if dry_run and not do_remove:
|
|
151
|
+
raise SystemExit("--dry-run is only supported with --remove")
|
|
152
|
+
if len(args) != 2:
|
|
153
|
+
raise SystemExit("usage: register-pi-package.py <settings.json> <repo_dir> [--remove] [--dry-run]")
|
|
154
|
+
settings_path = Path(args[0])
|
|
155
|
+
repo_dir_arg = args[1]
|
|
156
|
+
resolved = str(Path(repo_dir_arg).resolve())
|
|
157
|
+
|
|
158
|
+
if do_remove:
|
|
159
|
+
if dry_run:
|
|
160
|
+
n = would_remove(settings_path, repo_dir_arg)
|
|
161
|
+
if n:
|
|
162
|
+
print(f"remove: would remove {n} entwurf packages[] entr{'y' if n == 1 else 'ies'} from {settings_path}")
|
|
163
|
+
else:
|
|
164
|
+
print(f"remove: no entwurf packages[] entry to remove ({settings_path})")
|
|
165
|
+
return 0
|
|
166
|
+
n = remove(settings_path, repo_dir_arg)
|
|
167
|
+
if n:
|
|
168
|
+
print(f"remove: removed {n} entwurf packages[] entr{'y' if n == 1 else 'ies'} from {settings_path}")
|
|
169
|
+
else:
|
|
170
|
+
print(f"remove: no entwurf packages[] entry to remove ({settings_path})")
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
result = register(settings_path, repo_dir_arg)
|
|
174
|
+
if result == "noop":
|
|
175
|
+
print(f"install: entwurf package already registered (no-op) -> {resolved}")
|
|
176
|
+
else:
|
|
177
|
+
print(f"install: registered entwurf package -> {settings_path}")
|
|
178
|
+
print(f"install: package source -> {resolved}")
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
raise SystemExit(main(sys.argv))
|