on master help sign in
Ask for Apple's fm terms during install; end with one clear line

The installer now checks whether fm's terms are accepted and, when a terminal is attached, runs `sudo fm license` so the user reads them and agrees or declines in fm's own prompt. It then ends with either "Diagonal's host is ready." or one line saying the terms are still needed and the command to run, instead of a wall of FAIL lines. The self-test output is shown only when something else is wrong. The host writes its schemas on first use of any op, so install order no longer matters and --install-schemas is never a manual step. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LdKDAYoqmGehyHsV42LhJW

commit4e4345495656d71914cc097733c3c55bd1cf4ca8 authorClaude <noreply@anthropic.com> dateWed 23 Sep 2026, 20:04 (2 weeks ago) parent6ff63e4
5 files changed+93−28
README.md+9−7 host/diagonal-host.py+15−5 host/tests/fake_fm.py+14−2 host/tests/test_host.py+16 scripts/install-manifest.sh+39−14
README.md+9−7
@@ -28,12 +28,14 @@ The model sees a tab's title, address and `<meta name="description">`, and nothi
2828brew install iko-soy/tap/diagonal
2929```
3030
31−This downloads the latest release, puts the extension at `~/Library/Application Support/Diagonal/extension`, and installs the native host and registers it with Brave. Then, once:
31+This downloads the latest release, puts the extension at `~/Library/Application Support/Diagonal/extension`, installs the native host and registers it with Brave.
3232
33−1. Accept Apple's terms for the `fm` tool, which it asks for once per Mac before it runs at all: `sudo fm license`. Diagonal writes its model schemas on its own after that.
34−2. Open `brave://extensions` and turn on **Developer mode**.
35−3. Click **Load unpacked**, press **Cmd+Shift+G** and paste `~/Library/Application Support/Diagonal/extension`.
36−4. Open Diagonal's settings and click **Run self-test**.
33+Apple's `fm` tool refuses to run until its terms are accepted once per Mac. If they aren't yet, the install shows them (through `sudo fm license`, so it asks for your password) and you agree or decline there. If you decline, the install still finishes and tells you to run `sudo fm license` when you're ready; until then Diagonal's popup says the same. The install ends with "Diagonal's host is ready." once everything works.
34+
35+Then load the extension in Brave once:
36+
37+1. Open `brave://extensions` and turn on **Developer mode**.
38+2. Click **Load unpacked**, press **Cmd+Shift+G** and paste `~/Library/Application Support/Diagonal/extension`.
3739
3840To update: `brew upgrade --cask --greedy diagonal`, then click reload on Diagonal at `brave://extensions`. `brew uninstall diagonal` removes the extension folder, the host and its Brave manifest; `brew uninstall --zap diagonal` also removes the fm schemas and logs.
3941
@@ -49,7 +51,7 @@ Brave only installs extensions on its own from the Chrome Web Store, so the one
4951 bash ~/Applications/Diagonal/install-host.command
5052 ```
5153
52− (Type `bash `, then drag `install-host.command` from the folder into the Terminal window.) If it says python3 is not set up, run `xcode-select --install` and try again. If it asks you to, run `sudo fm license` once to accept Apple's terms for `fm`.
54+ (Type `bash `, then drag `install-host.command` from the folder into the Terminal window.) If it says python3 is not set up, run `xcode-select --install` and try again. It shows Apple's terms for `fm` if they haven't been accepted on this Mac yet, and ends with "Diagonal's host is ready." when everything works.
53554. Open Diagonal's settings and click **Run self-test**. Brave does not need a restart.
5456
5557The zip carries the host (`host/`), `extension-id` and `install-host.command`, which is the same script as `scripts/install-manifest.sh`. When you update to a newer release, rerun step 3.
@@ -129,7 +131,7 @@ Every push to `master` runs `.github/workflows/release.yml`: it typechecks, runs
129131
130132Everything was tested against a fake `fm`. These are open until someone runs it on macOS 27 (section 16 of the spec):
131133
132−- **Confirmed on a Mac:** `fm` is at `/usr/bin/fm`, and until an admin runs `sudo fm license` every `fm` command exits 69 with a terms notice. Diagonal reports that as `LICENSE_REQUIRED` with the fix, and the host writes its schemas on the next ping once `fm` works.
134+- **Confirmed on a Mac:** `fm` is at `/usr/bin/fm`, and until an admin runs `sudo fm license` every `fm` command exits 69 with a terms notice. The installer offers the terms in the terminal; if they are declined, Diagonal reports `LICENSE_REQUIRED` with the fix. The host writes its schemas on first use, so install order does not matter.
133135- The `fm schema` flags. `diagonal-host --install-schemas` tries the nested organize schema and falls back to the two flat schemas plus two calls if that fails. The flags are in `SCHEMA_COMMANDS` at the top of `host/diagonal-host.py`.
134136- `fm` stderr wording. `classify()` matches keywords, so an unexpected message shows as `FM_ERROR` with the raw text on the settings page.
135137- Whether `fm respond` can read the prompt from stdin. Until then, the prompt is an argument and is briefly visible in `ps`.
host/diagonal-host.py+15−5
@@ -247,11 +247,8 @@ def op_ping(_payload, _opts):
247247 license_required, msg = True, LICENSE_MESSAGE
248248 except subprocess.TimeoutExpired:
249249 ok, msg = False, "fm available timed out"
250− if ok and not schemas_ok():
251− # The installer could not write them (typically: terms not yet accepted). Now fm works, so do it here;
252− # stdout is the native-messaging channel, so the installer's progress lines are swallowed.
253− with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
254− install_schemas()
250+ if ok:
251+ ensure_schemas()
255252 mode = organize_mode()
256253 return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": ok, "fmMessage": msg,
257254 "licenseRequired": license_required, "schemasOk": schemas_ok(), "organizeMode": mode}
@@ -261,7 +258,19 @@ def schemas_ok():
261258 return bool(schema_path("name.json")) and organize_mode() != "missing"
262259
263260
261+def ensure_schemas():
262+ """Write the schema files on first use, so install order does not matter (e.g. the fm terms were
263+ accepted after the installer ran). stdout is the native-messaging channel: progress lines are swallowed."""
264+ if schemas_ok():
265+ return
266+ with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
267+ rc = install_schemas()
268+ if rc == INSTALL_LICENSE_EXIT:
269+ raise Fail("LICENSE_REQUIRED", LICENSE_MESSAGE)
270+
271+
264272def op_name(payload, opts):
273+ ensure_schemas()
265274 schema = schema_path("name.json")
266275 if not schema:
267276 raise Fail("SCHEMA_MISSING", os.path.join(SCHEMAS, "name.json"))
@@ -276,6 +285,7 @@ def op_name(payload, opts):
276285
277286def op_organize(payload, opts):
278287 strict = opts.get("strict", False)
288+ ensure_schemas()
279289 mode = organize_mode()
280290 try:
281291 if mode == "nested":
host/tests/fake_fm.py+14−2
@@ -5,6 +5,7 @@ control.json keys:
55 respond_queue: list of the same, consumed one per call before `respond` is used
66 available: {"stdout": str, "stderr": str, "exit": int} for `fm available`
77 schema_nested: false → `fm schema` with --object fails (simulates no nested-array support)
8+ schema_fail: true → every `fm schema` fails
89 license: true → every command exits 69 with the terms notice, as fm does before `sudo fm license`
910Every invocation's argv is appended to argv.log as one JSON line.
1011"""
@@ -27,12 +28,23 @@ def main():
2728 path = os.path.join(HERE, "control.json")
2829 control = json.load(open(path, encoding="utf-8")) if os.path.exists(path) else {}
2930 cmd = argv[0] if argv else ""
30− if control.get("license"):
31+ if cmd == "license":
32+ # Like `sudo fm license`: show the terms, then agree or decline on stdin.
33+ sys.stdout.write("APPLE FOUNDATION MODELS CLI LEGAL NOTICE & TERMS\n...\nType 'agree' to accept: ")
34+ sys.stdout.flush()
35+ agreed = sys.stdin.readline().strip().lower() == "agree"
36+ if agreed:
37+ control["license"] = False
38+ json.dump(control, open(path, "w", encoding="utf-8"))
39+ spec = {"exit": 0 if agreed else 1}
40+ elif control.get("license"):
3141 spec = {"stderr": LICENSE_NOTICE, "exit": 69}
3242 elif cmd == "available":
3343 spec = control.get("available", {"stdout": "available", "exit": 0})
3444 elif cmd == "schema":
35− if "--object" in argv and control.get("schema_nested", True) is False:
45+ if control.get("schema_fail"):
46+ spec = {"stderr": "error: schema generation failed", "exit": 1}
47+ elif "--object" in argv and control.get("schema_nested", True) is False:
3648 spec = {"stderr": "error: unknown option --object", "exit": 64}
3749 else:
3850 spec = {"stdout": json.dumps({"schema": argv[3] if len(argv) > 3 else "x", "args": argv[1:]}), "exit": 0}
host/tests/test_host.py+16
@@ -188,6 +188,7 @@ class ErrorCodes(HostCase):
188188
189189 def test_schema_missing(self):
190190 os.remove(os.path.join(self.support, "schemas", "name.json"))
191+ self.control({"schema_fail": True}) # first-use repair fails too
191192 r = self.call("name", {"items": ITEMS3})
192193 self.assertEqual(r["error"]["code"], "SCHEMA_MISSING")
193194
@@ -258,6 +259,21 @@ class InstallSchemas(HostCase):
258259 self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize-assign.json", "organize-labels.json"])
259260
260261
262+ def test_first_use_writes_missing_schemas(self):
263+ for f in os.listdir(os.path.join(self.support, "schemas")):
264+ os.remove(os.path.join(self.support, "schemas", f))
265+ self.respond(json.dumps({"title": "Rust async runtimes", "emoji": "🦀"}))
266+ r = self.call("name", {"items": ITEMS3})
267+ self.assertTrue(r["ok"], r)
268+ self.assertIn("name.json", os.listdir(os.path.join(self.support, "schemas")))
269+
270+ def test_first_use_without_the_terms_says_so(self):
271+ for f in os.listdir(os.path.join(self.support, "schemas")):
272+ os.remove(os.path.join(self.support, "schemas", f))
273+ self.control({"license": True})
274+ r = self.call("name", {"items": ITEMS3})
275+ self.assertEqual(r["error"]["code"], "LICENSE_REQUIRED", r)
276+
261277 def test_license_stops_early_without_claiming_nesting_is_unsupported(self):
262278 for f in os.listdir(os.path.join(self.support, "schemas")):
263279 os.remove(os.path.join(self.support, "schemas", f))
scripts/install-manifest.sh+39−14
@@ -52,18 +52,43 @@ for ch in "${CHANNELS[@]}"; do
5252 echo "manifest: $DIR/io.diagonal.host.json"
5353done
5454
55−echo "schemas:"
56−rc=0
57−"$BIN" --install-schemas || rc=$?
58−if [[ $rc -eq 3 ]]; then
59− echo
60− echo "One more step: accept Apple's terms for the fm tool once (asks for your password):"
61− echo " sudo fm license"
62− echo "Diagonal finishes setting itself up the next time Brave talks to it."
63− echo
64−elif [[ $rc -ne 0 ]]; then
65− echo " (schema install failed: is Apple Intelligence on? Details above.)"
55+FM="${DIAGONAL_FM:-/usr/bin/fm}"
56+
57+# fm refuses to run (exit 69) until someone accepts Apple's terms for it, once per Mac.
58+needs_license() {
59+ local out rc=0
60+ out=$("$FM" available --model system 2>&1) || rc=$?
61+ [[ $rc -eq 69 ]] || grep -qiE "legal notice|fm license" <<<"$out"
62+}
63+
64+echo
65+if [[ ! -x "$FM" ]]; then
66+ echo "Diagonal's host is installed, but $FM is missing: Diagonal needs macOS 27 with Apple Intelligence."
67+ exit 0
68+fi
69+
70+if needs_license; then
71+ echo "Diagonal names tabs with Apple's on-device model through the fm tool, which asks you to accept"
72+ echo "Apple's terms once per Mac. The choice applies to every user here, so sudo asks for your password."
73+ # Only with a terminal to read the terms and answer in; brew and double-click installs have one.
74+ if [[ -z "${DIAGONAL_NO_PROMPT:-}" ]] && { : </dev/tty; } 2>/dev/null; then
75+ ${DIAGONAL_SUDO-sudo} "$FM" license </dev/tty >/dev/tty 2>&1 || true
76+ echo
77+ fi
78+ if needs_license; then
79+ echo
80+ echo "Diagonal is installed, but it can't name tabs until Apple's terms for fm are accepted."
81+ echo "When you're ready, run: sudo fm license"
82+ exit 0
83+ fi
84+fi
85+
86+# Diagonal also writes these on first use; doing it now lets the self-test below cover the model.
87+schemas_out=$("$BIN" --install-schemas 2>&1) || true
88+if selftest_out=$("$BIN" --selftest 2>&1); then
89+ echo "Diagonal's host is ready."
90+else
91+ echo "Diagonal's host is installed, but its self-test found a problem:"
92+ echo "$schemas_out"
93+ echo "$selftest_out"
6694fi
67−echo "self-test:"
68−"$BIN" --selftest || true
69−echo "extension ID: $ID"