on master help sign in
Ask for Apple's fm terms during install; end with one clear line
4e43454Claude
README.md+9−7
@@ -28,12 +28,14 @@ The model sees a tab's title, address and `<meta name="description">`, and nothi
28brew install iko-soy/tap/diagonal
28brew install iko-soy/tap/diagonal
29```
29```
30
30
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.
32
32
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.
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+
34−2. Open `brave://extensions` and turn on **Developer mode**.
37+1. Open `brave://extensions` and turn on **Developer mode**.
35−3. Click **Load unpacked**, press **Cmd+Shift+G** and paste `~/Library/Application Support/Diagonal/extension`.
38+2. 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**.
37
39
38To 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.
40To 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.
39
41
@@ -49,7 +51,7 @@ Brave only installs extensions on its own from the Chrome Web Store, so the one
49 bash ~/Applications/Diagonal/install-host.command
51 bash ~/Applications/Diagonal/install-host.command
50 ```
52 ```
51
53
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.
534. Open Diagonal's settings and click **Run self-test**. Brave does not need a restart.
554. Open Diagonal's settings and click **Run self-test**. Brave does not need a restart.
54
56
55The 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.
57The 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
129
131
130Everything was tested against a fake `fm`. These are open until someone runs it on macOS 27 (section 16 of the spec):
132Everything was tested against a fake `fm`. These are open until someone runs it on macOS 27 (section 16 of the spec):
131
133
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.
133- 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`.
135- 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`.
134- `fm` stderr wording. `classify()` matches keywords, so an unexpected message shows as `FM_ERROR` with the raw text on the settings page.
136- `fm` stderr wording. `classify()` matches keywords, so an unexpected message shows as `FM_ERROR` with the raw text on the settings page.
135- Whether `fm respond` can read the prompt from stdin. Until then, the prompt is an argument and is briefly visible in `ps`.
137- 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):
247 license_required, msg = True, LICENSE_MESSAGE
247 license_required, msg = True, LICENSE_MESSAGE
248 except subprocess.TimeoutExpired:
248 except subprocess.TimeoutExpired:
249 ok, msg = False, "fm available timed out"
249 ok, msg = False, "fm available timed out"
250− if ok and not schemas_ok():
250+ if ok:
251− # The installer could not write them (typically: terms not yet accepted). Now fm works, so do it here;
251+ ensure_schemas()
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()
255 mode = organize_mode()
252 mode = organize_mode()
256 return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": ok, "fmMessage": msg,
253 return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": ok, "fmMessage": msg,
257 "licenseRequired": license_required, "schemasOk": schemas_ok(), "organizeMode": mode}
254 "licenseRequired": license_required, "schemasOk": schemas_ok(), "organizeMode": mode}
@@ -261,7 +258,19 @@ def schemas_ok():
261 return bool(schema_path("name.json")) and organize_mode() != "missing"
258 return bool(schema_path("name.json")) and organize_mode() != "missing"
262
259
263
260
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+
264def op_name(payload, opts):
272def op_name(payload, opts):
273+ ensure_schemas()
265 schema = schema_path("name.json")
274 schema = schema_path("name.json")
266 if not schema:
275 if not schema:
267 raise Fail("SCHEMA_MISSING", os.path.join(SCHEMAS, "name.json"))
276 raise Fail("SCHEMA_MISSING", os.path.join(SCHEMAS, "name.json"))
@@ -276,6 +285,7 @@ def op_name(payload, opts):
276
285
277def op_organize(payload, opts):
286def op_organize(payload, opts):
278 strict = opts.get("strict", False)
287 strict = opts.get("strict", False)
288+ ensure_schemas()
279 mode = organize_mode()
289 mode = organize_mode()
280 try:
290 try:
281 if mode == "nested":
291 if mode == "nested":
host/tests/fake_fm.py+14−2
@@ -5,6 +5,7 @@ control.json keys:
5 respond_queue: list of the same, consumed one per call before `respond` is used
5 respond_queue: list of the same, consumed one per call before `respond` is used
6 available: {"stdout": str, "stderr": str, "exit": int} for `fm available`
6 available: {"stdout": str, "stderr": str, "exit": int} for `fm available`
7 schema_nested: false → `fm schema` with --object fails (simulates no nested-array support)
7 schema_nested: false → `fm schema` with --object fails (simulates no nested-array support)
8+ schema_fail: true → every `fm schema` fails
8 license: true → every command exits 69 with the terms notice, as fm does before `sudo fm license`
9 license: true → every command exits 69 with the terms notice, as fm does before `sudo fm license`
9Every invocation's argv is appended to argv.log as one JSON line.
10Every invocation's argv is appended to argv.log as one JSON line.
10"""
11"""
@@ -27,12 +28,23 @@ def main():
27 path = os.path.join(HERE, "control.json")
28 path = os.path.join(HERE, "control.json")
28 control = json.load(open(path, encoding="utf-8")) if os.path.exists(path) else {}
29 control = json.load(open(path, encoding="utf-8")) if os.path.exists(path) else {}
29 cmd = argv[0] if argv else ""
30 cmd = argv[0] if argv else ""
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}
30− if control.get("license"):
40+ elif control.get("license"):
31 spec = {"stderr": LICENSE_NOTICE, "exit": 69}
41 spec = {"stderr": LICENSE_NOTICE, "exit": 69}
32 elif cmd == "available":
42 elif cmd == "available":
33 spec = control.get("available", {"stdout": "available", "exit": 0})
43 spec = control.get("available", {"stdout": "available", "exit": 0})
34 elif cmd == "schema":
44 elif cmd == "schema":
45+ if control.get("schema_fail"):
46+ spec = {"stderr": "error: schema generation failed", "exit": 1}
35− if "--object" in argv and control.get("schema_nested", True) is False:
47+ elif "--object" in argv and control.get("schema_nested", True) is False:
36 spec = {"stderr": "error: unknown option --object", "exit": 64}
48 spec = {"stderr": "error: unknown option --object", "exit": 64}
37 else:
49 else:
38 spec = {"stdout": json.dumps({"schema": argv[3] if len(argv) > 3 else "x", "args": argv[1:]}), "exit": 0}
50 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):
188
188
189 def test_schema_missing(self):
189 def test_schema_missing(self):
190 os.remove(os.path.join(self.support, "schemas", "name.json"))
190 os.remove(os.path.join(self.support, "schemas", "name.json"))
191+ self.control({"schema_fail": True}) # first-use repair fails too
191 r = self.call("name", {"items": ITEMS3})
192 r = self.call("name", {"items": ITEMS3})
192 self.assertEqual(r["error"]["code"], "SCHEMA_MISSING")
193 self.assertEqual(r["error"]["code"], "SCHEMA_MISSING")
193
194
@@ -258,6 +259,21 @@ class InstallSchemas(HostCase):
258 self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize-assign.json", "organize-labels.json"])
259 self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize-assign.json", "organize-labels.json"])
259
260
260
261
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+
261 def test_license_stops_early_without_claiming_nesting_is_unsupported(self):
277 def test_license_stops_early_without_claiming_nesting_is_unsupported(self):
262 for f in os.listdir(os.path.join(self.support, "schemas")):
278 for f in os.listdir(os.path.join(self.support, "schemas")):
263 os.remove(os.path.join(self.support, "schemas", f))
279 os.remove(os.path.join(self.support, "schemas", f))
scripts/install-manifest.sh+39−14
@@ -52,18 +52,43 @@ for ch in "${CHANNELS[@]}"; do
52 echo "manifest: $DIR/io.diagonal.host.json"
52 echo "manifest: $DIR/io.diagonal.host.json"
53done
53done
54
54
55−echo "schemas:"
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() {
56−rc=0
59+ local out rc=0
57−"$BIN" --install-schemas || rc=$?
60+ out=$("$FM" available --model system 2>&1) || rc=$?
61+ [[ $rc -eq 69 ]] || grep -qiE "legal notice|fm license" <<<"$out"
62+}
63+
64+echo
58−if [[ $rc -eq 3 ]]; then
65+if [[ ! -x "$FM" ]]; then
59− echo
66+ echo "Diagonal's host is installed, but $FM is missing: Diagonal needs macOS 27 with Apple Intelligence."
60− echo "One more step: accept Apple's terms for the fm tool once (asks for your password):"
67+ exit 0
61− echo " sudo fm license"
68+fi
62− echo "Diagonal finishes setting itself up the next time Brave talks to it."
69+
63− echo
70+if needs_license; then
64−elif [[ $rc -ne 0 ]]; then
71+ echo "Diagonal names tabs with Apple's on-device model through the fm tool, which asks you to accept"
65− echo " (schema install failed: is Apple Intelligence on? Details above.)"
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"
66fi
94fi
67−echo "self-test:"
68−"$BIN" --selftest || true
69−echo "extension ID: $ID"