@junghanacs/entwurf 0.12.6 → 0.12.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/AGENTS.md +31 -17
  2. package/BASELINE.md +42 -8
  3. package/CHANGELOG.md +22 -0
  4. package/DELIVERY.md +71 -14
  5. package/README.md +77 -45
  6. package/VERIFY.md +18 -13
  7. package/docs/setup-clean-host.md +80 -22
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
  30. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
  31. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
  32. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
  33. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
  34. package/mcp/entwurf-bridge/src/index.ts +101 -67
  35. package/mcp/entwurf-bridge/test.sh +1 -1
  36. package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
  37. package/package.json +10 -5
  38. package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
  39. package/pi-extensions/lib/entwurf-self-address.ts +58 -15
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
  41. package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
  42. package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
  43. package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
  44. package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
  45. package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
  46. package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
  48. package/pi-extensions/lib/meta-sender-identity.ts +154 -0
  49. package/pi-extensions/lib/native-push/adapter.ts +255 -0
  50. package/pi-extensions/lib/native-push/register.ts +99 -0
  51. package/run.sh +756 -214
  52. package/scripts/agy-bridge-config.py +446 -0
  53. package/scripts/agy-bridge.sh +359 -0
  54. package/scripts/agy-hooks-bridge.sh +193 -0
  55. package/scripts/agy-hooks-config.py +257 -0
  56. package/scripts/agy-imprint.sh +28 -0
  57. package/scripts/agy-imprint.ts +193 -0
  58. package/scripts/agy-statusline-bridge.sh +176 -0
  59. package/scripts/agy-statusline-config.py +213 -0
  60. package/scripts/agy-statusline.sh +256 -0
  61. package/scripts/build-bridge.sh +20 -0
  62. package/scripts/check-agy-sender-identity.ts +364 -0
  63. package/scripts/check-entwurf-bridge-boot.ts +8 -2
  64. package/scripts/check-entwurf-deliverability.ts +34 -0
  65. package/scripts/check-entwurf-self-address.ts +78 -11
  66. package/scripts/check-entwurf-v2-contract.ts +136 -1
  67. package/scripts/check-entwurf-v2-decider.ts +95 -1
  68. package/scripts/check-entwurf-v2-matrix.ts +14 -3
  69. package/scripts/check-entwurf-v2-native-push.ts +193 -0
  70. package/scripts/check-entwurf-v2-production.ts +68 -1
  71. package/scripts/check-entwurf-v2-runner.ts +58 -0
  72. package/scripts/check-entwurf-v2-surface.ts +35 -0
  73. package/scripts/check-install-surface.ts +357 -0
  74. package/scripts/check-native-push-adapter.ts +319 -0
  75. package/scripts/check-native-push-register.ts +130 -0
  76. package/scripts/dev-bin.sh +195 -0
  77. package/scripts/doctor-pi-provider.ts +140 -0
  78. package/scripts/meta-bridge-doctor.sh +36 -2
  79. package/scripts/register-pi-package.py +37 -3
  80. package/scripts/register-pi-provider.py +287 -0
  81. package/scripts/smoke-agy-hooks-state.sh +172 -0
  82. package/scripts/smoke-agy-install-state.sh +660 -0
  83. package/scripts/smoke-agy-native-push-live.ts +243 -0
  84. package/scripts/smoke-agy-statusline-state.sh +300 -0
  85. package/scripts/smoke-meta-async-drift.sh +9 -2
  86. package/scripts/smoke-meta-install-state.sh +20 -0
  87. package/scripts/smoke-pi-provider-state.sh +182 -0
  88. package/scripts/smoke-user-scope-citizen.sh +62 -0
  89. package/scripts/with-dist-lock.sh +81 -0
  90. package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
  91. package/scripts/__pycache__/meta-bridge-state.cpython-313.pyc +0 -0
  92. package/scripts/__pycache__/register-pi-package.cpython-313.pyc +0 -0
@@ -0,0 +1,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
- if claude plugin list 2>/dev/null | grep -q "$PLUGIN@$MKT_NAME"; then
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
- args = [a for a in argv[1:] if a != "--remove"]
124
- do_remove = "--remove" in argv[1:]
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)