@junghanacs/entwurf 0.12.6 → 0.12.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +31 -17
- package/BASELINE.md +42 -8
- package/CHANGELOG.md +22 -0
- package/DELIVERY.md +71 -14
- package/README.md +77 -45
- package/VERIFY.md +18 -13
- 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 -5
- 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 +756 -214
- 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 +36 -2
- package/scripts/register-pi-package.py +37 -3
- 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 +20 -0
- package/scripts/smoke-pi-provider-state.sh +182 -0
- package/scripts/smoke-user-scope-citizen.sh +62 -0
- package/scripts/with-dist-lock.sh +81 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-313.pyc +0 -0
- package/scripts/__pycache__/register-pi-package.cpython-313.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.");
|
|
@@ -93,9 +93,43 @@ else
|
|
|
93
93
|
fi
|
|
94
94
|
|
|
95
95
|
echo "[plugin install (global / --scope user)]"
|
|
96
|
-
|
|
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
|
|
97
131
|
ok "$PLUGIN@$MKT_NAME present"
|
|
98
|
-
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"
|
|
99
133
|
else
|
|
100
134
|
bad "$PLUGIN@$MKT_NAME not installed — run ./run.sh install-meta-bridge"
|
|
101
135
|
fi
|
|
@@ -119,16 +119,50 @@ def remove(settings_path: Path, repo_dir_arg: str) -> int:
|
|
|
119
119
|
return len(entwurf_entries)
|
|
120
120
|
|
|
121
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
|
+
|
|
122
137
|
def main(argv: list[str]) -> int:
|
|
123
|
-
|
|
124
|
-
|
|
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")
|
|
125
152
|
if len(args) != 2:
|
|
126
|
-
raise SystemExit("usage: register-pi-package.py <settings.json> <repo_dir> [--remove]")
|
|
153
|
+
raise SystemExit("usage: register-pi-package.py <settings.json> <repo_dir> [--remove] [--dry-run]")
|
|
127
154
|
settings_path = Path(args[0])
|
|
128
155
|
repo_dir_arg = args[1]
|
|
129
156
|
resolved = str(Path(repo_dir_arg).resolve())
|
|
130
157
|
|
|
131
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
|
|
132
166
|
n = remove(settings_path, repo_dir_arg)
|
|
133
167
|
if n:
|
|
134
168
|
print(f"remove: removed {n} entwurf packages[] entr{'y' if n == 1 else 'ies'} from {settings_path}")
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""register-pi-provider — own entwurfProvider.mcpServers.entwurf-bridge in a pi settings.json
|
|
3
|
+
(#46 Task 2). The provider analog of register-pi-package.py: user + project scope, same
|
|
4
|
+
predicate/idempotency, ONE shared SSOT. The owned keyset is the SINGLE key
|
|
5
|
+
`entwurfProvider.mcpServers.entwurf-bridge` (봉인계약 4); every sibling
|
|
6
|
+
(skillPlugins/appendSystemPrompt/showToolNotifications/other mcpServers) is preserved untouched.
|
|
7
|
+
|
|
8
|
+
The command written is ALWAYS the bare stable bin `entwurf-bridge` (dev AND installed — the repo
|
|
9
|
+
start.sh path lives only in the dev-bin symlink state, never in a settings file; #46 tripwire).
|
|
10
|
+
|
|
11
|
+
ownership classification of the EXISTING command (GPT caveat: an old managed repo path is NOT a
|
|
12
|
+
user value, so its inverse is key-removal, never a restore):
|
|
13
|
+
absent key not present → we create it
|
|
14
|
+
managed-current command == entwurf-bridge → already ours (idempotent)
|
|
15
|
+
managed-legacy command is our old repo start.sh
|
|
16
|
+
(== <repo>/mcp/entwurf-bridge/start.sh, or endswith
|
|
17
|
+
/entwurf/mcp/entwurf-bridge/start.sh) → normalize to the bare bin
|
|
18
|
+
user-override anything else → DO NOT overwrite, DO NOT own
|
|
19
|
+
(no state; doctor: unowned)
|
|
20
|
+
|
|
21
|
+
Scope asymmetry (봉인계약 4·6, REASONED — not the unfounded asymmetry dev-bin (B) rejected):
|
|
22
|
+
user ~/.pi/agent/settings.json — GLOBAL, durable,파급s to every cwd → install-state +
|
|
23
|
+
honest inverse. absent/managed-* → remove OUR key on uninstall (a legacy repo path is
|
|
24
|
+
NOT restored); user-override → no state taken, left untouched. A parent object emptied
|
|
25
|
+
of our key is tidied; siblings kept.
|
|
26
|
+
project <repo>/.pi/settings.json — checkout-LOCAL, disposable, re-creatable, and `run.sh
|
|
27
|
+
remove` already covers it → NO state. install normalizes the command; remove strips
|
|
28
|
+
our-managed shapes (the bare bin AND the legacy repo path). project-scope state is a
|
|
29
|
+
NAMED FOLLOW-UP (NEXT), deliberately out of this lane.
|
|
30
|
+
|
|
31
|
+
Subcommands:
|
|
32
|
+
install <settings_path> <repo_dir> --scope <user|project> [--state <state_path>]
|
|
33
|
+
remove <settings_path> <repo_dir> --scope <user|project> [--state <state_path>]
|
|
34
|
+
|
|
35
|
+
Exit codes: 0 ok · 2 no-state · 3 refuse-symlink · 4 invalid-json · 5 usage.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import sys
|
|
41
|
+
|
|
42
|
+
SERVER_KEY = "entwurf-bridge"
|
|
43
|
+
BARE_COMMAND = "entwurf-bridge"
|
|
44
|
+
STATE_SCHEMA_VERSION = 1
|
|
45
|
+
|
|
46
|
+
# Legacy-bundled MCP names prior installers wrote; the current cutover supersedes them. Pruned at
|
|
47
|
+
# install time (and project remove) ONLY when the command matches our bundled start.sh path, so a
|
|
48
|
+
# user-customized command is left alone. Mirrors run.sh's pre-Task-2 inline prune (no regression).
|
|
49
|
+
LEGACY_BUNDLED = {
|
|
50
|
+
"session-bridge": "retracted in 0.4.14, issue #7",
|
|
51
|
+
"pi-tools-bridge": "renamed to entwurf-bridge in 0.11 S2 cutover",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _prune_legacy(servers: dict, repo_dir: str) -> None:
|
|
56
|
+
for name, reason in LEGACY_BUNDLED.items():
|
|
57
|
+
existing = servers.get(name)
|
|
58
|
+
if not isinstance(existing, dict):
|
|
59
|
+
continue
|
|
60
|
+
cmd = existing.get("command")
|
|
61
|
+
if not isinstance(cmd, str):
|
|
62
|
+
continue
|
|
63
|
+
if cmd == f"{repo_dir}/mcp/{name}/start.sh" or cmd.endswith(f"/entwurf/mcp/{name}/start.sh"):
|
|
64
|
+
del servers[name]
|
|
65
|
+
sys.stdout.write(f"pruned legacy entwurfProvider.mcpServers.{name} ({reason})\n")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _die(code: int, msg: str) -> "None":
|
|
69
|
+
sys.stderr.write(msg.rstrip("\n") + "\n")
|
|
70
|
+
sys.exit(code)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _atomic_write(path: str, text: str) -> None:
|
|
74
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
75
|
+
tmp = f"{path}.tmp-{os.getpid()}"
|
|
76
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
77
|
+
fh.write(text)
|
|
78
|
+
os.replace(tmp, path)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _load(path: str) -> dict:
|
|
82
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
83
|
+
raw = fh.read()
|
|
84
|
+
if raw.strip() == "":
|
|
85
|
+
return {}
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(raw)
|
|
88
|
+
except json.JSONDecodeError as err:
|
|
89
|
+
_die(4, f"register-pi-provider: {path} is not valid JSON: {err}")
|
|
90
|
+
if not isinstance(data, dict):
|
|
91
|
+
_die(4, f"register-pi-provider: {path} top-level must be a JSON object")
|
|
92
|
+
return data
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _dump(data: dict) -> str:
|
|
96
|
+
return json.dumps(data, indent=2) + "\n"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _now() -> str:
|
|
100
|
+
import datetime
|
|
101
|
+
|
|
102
|
+
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _classify(existing_cmd, repo_dir: str) -> str:
|
|
106
|
+
if existing_cmd is None:
|
|
107
|
+
return "absent"
|
|
108
|
+
if existing_cmd == BARE_COMMAND:
|
|
109
|
+
return "managed-current"
|
|
110
|
+
if isinstance(existing_cmd, str) and (
|
|
111
|
+
existing_cmd == f"{repo_dir}/mcp/{SERVER_KEY}/start.sh"
|
|
112
|
+
or existing_cmd.endswith(f"/entwurf/mcp/{SERVER_KEY}/start.sh")
|
|
113
|
+
):
|
|
114
|
+
return "managed-legacy"
|
|
115
|
+
return "user-override"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _provider_servers(data: dict, create: bool):
|
|
119
|
+
"""Return (provider, servers) dicts. When create, setdefault them; else may be None."""
|
|
120
|
+
if create:
|
|
121
|
+
provider = data.setdefault("entwurfProvider", {})
|
|
122
|
+
if not isinstance(provider, dict):
|
|
123
|
+
_die(4, "register-pi-provider: entwurfProvider is not an object")
|
|
124
|
+
servers = provider.setdefault("mcpServers", {})
|
|
125
|
+
if not isinstance(servers, dict):
|
|
126
|
+
_die(4, "register-pi-provider: entwurfProvider.mcpServers is not an object")
|
|
127
|
+
return provider, servers
|
|
128
|
+
provider = data.get("entwurfProvider")
|
|
129
|
+
if not isinstance(provider, dict):
|
|
130
|
+
return None, None
|
|
131
|
+
servers = provider.get("mcpServers")
|
|
132
|
+
if not isinstance(servers, dict):
|
|
133
|
+
return provider, None
|
|
134
|
+
return provider, servers
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_install(settings_path: str, repo_dir: str, scope: str, state_path: str) -> None:
|
|
138
|
+
if os.path.islink(settings_path):
|
|
139
|
+
target = os.readlink(settings_path)
|
|
140
|
+
_die(3, f"register-pi-provider: refusing to adopt {settings_path} — it is a symlink to {target} "
|
|
141
|
+
f"(someone else's SSOT). Manage it there, or replace it with a regular file, then retry.")
|
|
142
|
+
|
|
143
|
+
data = _load(settings_path) if os.path.exists(settings_path) else {}
|
|
144
|
+
provider, servers = _provider_servers(data, create=True)
|
|
145
|
+
_prune_legacy(servers, repo_dir) # independent of entwurf-bridge ownership
|
|
146
|
+
existing = servers.get(SERVER_KEY)
|
|
147
|
+
existing_cmd = existing.get("command") if isinstance(existing, dict) else existing
|
|
148
|
+
ownership = _classify(existing_cmd, repo_dir)
|
|
149
|
+
|
|
150
|
+
if ownership == "user-override":
|
|
151
|
+
# DO NOT overwrite our key, DO NOT own it (no state). doctor reports it as unowned. Still
|
|
152
|
+
# persist so any legacy prune above (and a materialized parent) is written.
|
|
153
|
+
sys.stdout.write(
|
|
154
|
+
f"install: preserved entwurfProvider.mcpServers.{SERVER_KEY} (user override, NOT owned: {existing_cmd!r})\n"
|
|
155
|
+
)
|
|
156
|
+
_atomic_write(settings_path, _dump(data))
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
# absent / managed-current / managed-legacy → normalize to the bare stable bin.
|
|
160
|
+
newval = {"command": BARE_COMMAND}
|
|
161
|
+
# preserve non-empty custom args if the operator set them; else default [].
|
|
162
|
+
if isinstance(existing, dict) and existing.get("args") not in (None, []):
|
|
163
|
+
newval["args"] = existing["args"]
|
|
164
|
+
else:
|
|
165
|
+
newval["args"] = []
|
|
166
|
+
servers[SERVER_KEY] = newval
|
|
167
|
+
_atomic_write(settings_path, _dump(data))
|
|
168
|
+
sys.stdout.write(
|
|
169
|
+
f"install: {ownership} → entwurfProvider.mcpServers.{SERVER_KEY} = {BARE_COMMAND} (bare stable bin)\n"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if scope == "user":
|
|
173
|
+
if state_path:
|
|
174
|
+
state = {
|
|
175
|
+
"schemaVersion": STATE_SCHEMA_VERSION,
|
|
176
|
+
"managedSettingsPath": os.path.abspath(settings_path),
|
|
177
|
+
"scope": "user",
|
|
178
|
+
"key": f"entwurfProvider.mcpServers.{SERVER_KEY}",
|
|
179
|
+
"command": BARE_COMMAND,
|
|
180
|
+
"ownership": ownership, # absent | managed-current | managed-legacy
|
|
181
|
+
"preimage": existing, # raw prior value (audit only; NOT restored)
|
|
182
|
+
"installedAt": _now(),
|
|
183
|
+
}
|
|
184
|
+
_atomic_write(state_path, _dump(state))
|
|
185
|
+
# reload-timing honesty (봉인: no implicit reload assumption): a running pi does not
|
|
186
|
+
# re-read settings mid-session — only new sessions pick this up.
|
|
187
|
+
sys.stdout.write(
|
|
188
|
+
"install: existing pi sessions unaffected until restart; new sessions pick up the change\n"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def cmd_remove(settings_path: str, repo_dir: str, scope: str, state_path: str) -> None:
|
|
193
|
+
if scope == "user":
|
|
194
|
+
if not state_path or not os.path.exists(state_path):
|
|
195
|
+
sys.stdout.write("remove: no install-state — nothing to undo (never owned, or already removed).\n")
|
|
196
|
+
return
|
|
197
|
+
state = _load(state_path)
|
|
198
|
+
managed = state.get("managedSettingsPath")
|
|
199
|
+
if not isinstance(managed, str):
|
|
200
|
+
_die(4, f"register-pi-provider: install-state {state_path} has no managedSettingsPath")
|
|
201
|
+
if os.path.islink(managed):
|
|
202
|
+
_die(3, f"register-pi-provider: refusing to uninstall — {managed} became a symlink since install.")
|
|
203
|
+
if os.path.exists(managed):
|
|
204
|
+
data = _load(managed)
|
|
205
|
+
provider, servers = _provider_servers(data, create=False)
|
|
206
|
+
# honest inverse: absent/managed-* → remove OUR key (a legacy repo path is NOT
|
|
207
|
+
# restored — it was our old managed value, not a user value).
|
|
208
|
+
if isinstance(servers, dict):
|
|
209
|
+
servers.pop(SERVER_KEY, None)
|
|
210
|
+
if not servers:
|
|
211
|
+
provider.pop("mcpServers", None)
|
|
212
|
+
if isinstance(provider, dict) and not provider:
|
|
213
|
+
data.pop("entwurfProvider", None)
|
|
214
|
+
_atomic_write(managed, _dump(data))
|
|
215
|
+
os.remove(state_path)
|
|
216
|
+
sys.stdout.write(f"remove: removed our {SERVER_KEY} key (ownership={state.get('ownership')}) from {managed}\n")
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
# project scope: no state — strip our-managed shapes (bare bin OR legacy repo path).
|
|
220
|
+
if not os.path.exists(settings_path):
|
|
221
|
+
sys.stdout.write(f"remove: nothing to do ({settings_path} missing)\n")
|
|
222
|
+
return
|
|
223
|
+
if os.path.islink(settings_path):
|
|
224
|
+
_die(3, f"register-pi-provider: refusing to touch {settings_path} — it is a symlink.")
|
|
225
|
+
data = _load(settings_path)
|
|
226
|
+
provider, servers = _provider_servers(data, create=False)
|
|
227
|
+
if not isinstance(servers, dict):
|
|
228
|
+
sys.stdout.write("remove: no entwurfProvider.mcpServers — nothing to do.\n")
|
|
229
|
+
return
|
|
230
|
+
_prune_legacy(servers, repo_dir)
|
|
231
|
+
existing = servers.get(SERVER_KEY)
|
|
232
|
+
existing_cmd = existing.get("command") if isinstance(existing, dict) else existing
|
|
233
|
+
ownership = _classify(existing_cmd, repo_dir)
|
|
234
|
+
if ownership in ("managed-current", "managed-legacy"):
|
|
235
|
+
del servers[SERVER_KEY]
|
|
236
|
+
sys.stdout.write(f"remove: removed entwurfProvider.mcpServers.{SERVER_KEY} ({ownership})\n")
|
|
237
|
+
if not servers:
|
|
238
|
+
provider.pop("mcpServers", None)
|
|
239
|
+
if isinstance(provider, dict) and not provider:
|
|
240
|
+
data.pop("entwurfProvider", None)
|
|
241
|
+
elif ownership == "user-override":
|
|
242
|
+
sys.stdout.write(f"remove: preserved entwurfProvider.mcpServers.{SERVER_KEY} (user override: {existing_cmd!r})\n")
|
|
243
|
+
else: # absent
|
|
244
|
+
sys.stdout.write(f"remove: entwurfProvider.mcpServers.{SERVER_KEY} already absent.\n")
|
|
245
|
+
_atomic_write(settings_path, _dump(data))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _parse(argv: list):
|
|
249
|
+
# positional: settings_path repo_dir ; flags: --scope <s> [--state <p>]
|
|
250
|
+
pos, scope, state_path = [], None, ""
|
|
251
|
+
i = 0
|
|
252
|
+
while i < len(argv):
|
|
253
|
+
a = argv[i]
|
|
254
|
+
if a == "--scope":
|
|
255
|
+
i += 1
|
|
256
|
+
scope = argv[i] if i < len(argv) else None
|
|
257
|
+
elif a == "--state":
|
|
258
|
+
i += 1
|
|
259
|
+
state_path = argv[i] if i < len(argv) else ""
|
|
260
|
+
else:
|
|
261
|
+
pos.append(a)
|
|
262
|
+
i += 1
|
|
263
|
+
return pos, scope, state_path
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def main(argv: list) -> None:
|
|
267
|
+
if len(argv) < 2:
|
|
268
|
+
_die(5, "usage: register-pi-provider.py <install|remove> <settings_path> <repo_dir> --scope <user|project> [--state <path>]")
|
|
269
|
+
sub = argv[1]
|
|
270
|
+
pos, scope, state_path = _parse(argv[2:])
|
|
271
|
+
if sub not in ("install", "remove"):
|
|
272
|
+
_die(5, f"register-pi-provider.py: unknown subcommand {sub!r}")
|
|
273
|
+
if len(pos) != 2:
|
|
274
|
+
_die(5, f"usage: register-pi-provider.py {sub} <settings_path> <repo_dir> --scope <user|project> [--state <path>]")
|
|
275
|
+
if scope not in ("user", "project"):
|
|
276
|
+
_die(5, "register-pi-provider.py: --scope must be user or project")
|
|
277
|
+
settings_path, repo_dir = pos[0], str(os.path.abspath(pos[1]))
|
|
278
|
+
if scope == "user" and not state_path:
|
|
279
|
+
_die(5, "register-pi-provider.py: --state is required for --scope user")
|
|
280
|
+
if sub == "install":
|
|
281
|
+
cmd_install(settings_path, repo_dir, scope, state_path)
|
|
282
|
+
else:
|
|
283
|
+
cmd_remove(settings_path, repo_dir, scope, state_path)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
main(sys.argv)
|