secret-menu 0.1.0 → 0.1.1
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/README.md +95 -176
- package/lib/cli-help.mjs +4 -4
- package/lib/cli.mjs +23 -9
- package/lib/explain.mjs +7 -1
- package/lib/handoff.mjs +1 -1
- package/lib/setup.mjs +116 -50
- package/package.json +4 -2
- package/test/fixtures/churn-b.html +8 -0
- package/test/fixtures/churn.html +35 -0
- package/test/fixtures/empty.html +4 -0
- package/test/fixtures/explain-expected.json +73 -0
- package/test/fixtures/explain-tools.js +25 -0
- package/test/fixtures/explain.html +17 -0
package/README.md
CHANGED
|
@@ -1,84 +1,54 @@
|
|
|
1
1
|
# 🗝️ Secret Menu
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**See the WebMCP tools a website offers, and let your agent use them as you.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/secret-menu)
|
|
6
|
+
[](https://github.com/israelvicars/secret-menu/actions/workflows/ci.yml)
|
|
7
|
+
[](LICENSE)
|
|
6
8
|
|
|
7
9
|
<p align="center">
|
|
8
10
|
<picture>
|
|
9
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/
|
|
10
|
-
<img src="docs/screenshots/
|
|
11
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/popup-dark.png">
|
|
12
|
+
<img src="docs/screenshots/popup-light.png" width="404" alt="Secret Menu's toolbar popup on a test shop: 14 WebMCP tools grouped into six abilities, each tagged safe, needs sign-in, changes data or spends money.">
|
|
11
13
|
</picture>
|
|
12
14
|
</p>
|
|
13
15
|
|
|
14
|
-
<
|
|
15
|
-
<tr>
|
|
16
|
-
<td width="50%"><picture>
|
|
17
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/popup-dark.png">
|
|
18
|
-
<img src="docs/screenshots/popup-light.png" alt="The toolbar popup: the page's tools grouped into abilities labelled safe, needs sign-in, changes data and spends money">
|
|
19
|
-
</picture></td>
|
|
20
|
-
<td width="50%"><picture>
|
|
21
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/popup-expanded-dark.png">
|
|
22
|
-
<img src="docs/screenshots/popup-expanded-light.png" alt="An expanded ability: its tool names, where each label came from, and a ready-to-copy agent prompt">
|
|
23
|
-
</picture></td>
|
|
24
|
-
</tr>
|
|
25
|
-
</table>
|
|
26
|
-
|
|
27
|
-
<sub>The badge and popup on Secret Menu's built-in test page (<code>secret-menu serve</code>), a fake shop with 14 tools. Nothing on it is real.</sub>
|
|
28
|
-
|
|
29
|
-
> [!IMPORTANT]
|
|
30
|
-
> **WebMCP is still experimental, and so is this project.**
|
|
31
|
-
>
|
|
32
|
-
> - **Stock Chrome barely shows these tools yet.** Without a flag, Chrome only exposes WebMCP tools on sites taking part in the WebMCP origin trial (Chrome 149 to 162, ending March 30, 2027). There is no ship date for WebMCP in stable Chrome yet.
|
|
33
|
-
> - **Most testing ran with the WebMCP flag on.** `secret-menu launch` starts its own Chrome with `--enable-features=WebMCP`. In one check without the flag, the test browser (Chrome for Testing 156) still showed tools on three sample stores. Stock Chrome without the flag hasn't been checked.
|
|
34
|
-
> - **macOS and Linux only.** `npx secret-menu setup` works with Google Chrome on macOS and Linux. The dedicated test Chrome (`secret-menu launch`, for development) needs an Apple Silicon Mac. Windows isn't supported yet.
|
|
35
|
-
> - **Safety controls are opt-in.** By default calls run, and your agent's own approval prompt is the check. See [Safety model](#safety-model).
|
|
36
|
-
|
|
37
|
-
## What it does
|
|
16
|
+
<p align="center"><sub>The popup on Secret Menu's built-in test shop (<code>./bin/secret-menu serve</code> in a clone, then open <code>/explain.html</code>). The shop and its 14 tools are fake.</sub></p>
|
|
38
17
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
- **Give an agent the tools.** `secret-menu mcp` is a stdio MCP server, so any MCP client (Codex, Claude, Cursor and others) can find sites, open them, read their tools and call them as the signed-in user.
|
|
42
|
-
- **See them while browsing.** The Chrome extension shows a small badge on pages that have tools and a toolbar popup listing what the page can do.
|
|
43
|
-
- **Learn from use.** Each site you explain is saved to a local index on your machine. There is no crawler and no server.
|
|
18
|
+
> [!NOTE]
|
|
19
|
+
> WebMCP is an experimental protocol. Turn on Chrome's WebMCP flag (or use a site in Google's trial). macOS or Linux. [Details](#requirements)
|
|
44
20
|
|
|
45
|
-
|
|
21
|
+
**Why**
|
|
46
22
|
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
- Sites that publish WebMCP tools. In stable Chrome that's only sites in the WebMCP origin trial, unless WebMCP is turned on (see the note above).
|
|
23
|
+
- **Building a site with WebMCP?** See your tools the way an agent will, grouped and labelled, with each label marked as your hint or our guess.
|
|
24
|
+
- **Using an agent?** A local MCP server lets Claude, Cursor or Codex use the WebMCP tools on sites you have open.
|
|
25
|
+
- **Local only.** No servers, accounts or analytics.
|
|
51
26
|
|
|
52
|
-
##
|
|
27
|
+
## Try it in 5 minutes
|
|
53
28
|
|
|
54
|
-
|
|
29
|
+
1. In Chrome, open `chrome://flags/#enable-webmcp-testing`, set it to **Enabled**, and relaunch.
|
|
30
|
+
2. Install the extension from the [Chrome Web Store](https://chromewebstore.google.com/detail/ldijmoknbnamjlegbeplefpmhhfmjakh). **In review: this link goes live once Chrome Web Store approves it.** Until then, build it and load it unpacked:
|
|
55
31
|
|
|
56
|
-
|
|
32
|
+
```sh
|
|
33
|
+
git clone https://github.com/israelvicars/secret-menu.git
|
|
34
|
+
cd secret-menu
|
|
35
|
+
npm --prefix ext install && npm run ext:build
|
|
36
|
+
```
|
|
57
37
|
|
|
58
|
-
|
|
38
|
+
Open `chrome://extensions`, turn on **Developer mode**, choose **Load unpacked** and pick `ext/.output/chrome-mv3`.
|
|
39
|
+
3. In the same `secret-menu` folder, run `./bin/secret-menu serve` and open `http://127.0.0.1:8765/explain.html`. (Port busy? Add `--port 8766`.)
|
|
40
|
+
<!-- Back to `npx secret-menu serve` once secret-menu@0.1.1 is on npm (0.1.0 has no serve). -->
|
|
41
|
+
4. Click the 🗝️ in your toolbar (pin it from the puzzle-piece menu), or the badge on the page. You should see 14 tools in six abilities.
|
|
59
42
|
|
|
60
|
-
|
|
61
|
-
git clone https://github.com/israelvicars/secret-menu.git
|
|
62
|
-
cd secret-menu
|
|
63
|
-
npm --prefix ext install
|
|
64
|
-
npm run ext:build # written to ext/.output/chrome-mv3
|
|
65
|
-
```
|
|
43
|
+
The extension works on its own. To let an agent use the tools too, connect it:
|
|
66
44
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
### 2. Set up (once)
|
|
45
|
+
## Connect your agent
|
|
70
46
|
|
|
71
47
|
```sh
|
|
72
48
|
npx secret-menu setup
|
|
73
49
|
```
|
|
74
50
|
|
|
75
|
-
This installs
|
|
76
|
-
|
|
77
|
-
Then reload the extension (or restart Chrome).
|
|
78
|
-
|
|
79
|
-
### 3. Connect your agent
|
|
80
|
-
|
|
81
|
-
Add Secret Menu as an MCP server in your agent (for example, the MCP settings in Claude Desktop or Cursor):
|
|
51
|
+
This installs a small helper that connects the extension to your computer (technically a Chrome native messaging host, `com.secretmenu.bridge`). Then add this to your agent's MCP settings (for example Claude Desktop or Cursor):
|
|
82
52
|
|
|
83
53
|
```json
|
|
84
54
|
{
|
|
@@ -88,154 +58,111 @@ Add Secret Menu as an MCP server in your agent (for example, the MCP settings in
|
|
|
88
58
|
}
|
|
89
59
|
```
|
|
90
60
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
### 4. Check it
|
|
94
|
-
|
|
95
|
-
```sh
|
|
96
|
-
npx secret-menu doctor
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
Each line is `ok` or `FAIL` with a fix: Node version, the native host manifest, the launcher, the extension ID, and whether Chrome's bridge is reachable. It exits non-zero when something fails.
|
|
61
|
+
Reload the extension (or restart Chrome), then check it with `npx secret-menu doctor`. Each line says `ok` or `FAIL`, with a fix.
|
|
100
62
|
|
|
101
|
-
|
|
63
|
+
Setup is safe to run again. It finds your Chrome-family browsers and installs into each; pass `--browser chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary` (macOS) or `chromium` to pick one, and `--extension-id <id>` if your extension has a different ID. See [docs/CLI.md](docs/CLI.md#setup).
|
|
102
64
|
|
|
103
|
-
|
|
104
|
-
- **Extension plus the package:** your agent sees the same tools and labels you do, and its calls run as you on sites you're signed in to. The Hand off page names the page's real tools in a prompt for your agent, and Discover lists the sites in your local index.
|
|
105
|
-
- **The MCP server alone** starts without a browser, but then only `find_sites` and `list_recipes` work, from data already on your machine.
|
|
65
|
+
## What it does
|
|
106
66
|
|
|
107
|
-
|
|
67
|
+
- **Explains a page's tools.** It groups them into a few abilities: *safe*, *changes the view*, *needs sign-in*, *changes data*, *spends money*. Each ability lists the real tool names, and each label says whether it came **from a site hint** (the page's own annotations) or is **guessed** (Secret Menu's rules). No model writes any of it, and the popup never calls a tool.
|
|
68
|
+
- **Shows you while you browse.** A small badge appears only on pages that have tools; the toolbar popup lists what the page can do, with a prompt to copy to an agent.
|
|
69
|
+
- **Gives your agent the same tools.** `secret-menu mcp` is a stdio MCP server. Your agent can open a site, read its tools and call them as you, in your signed-in Chrome.
|
|
70
|
+
- **Remembers sites.** Each site you or your agent explains is saved to a local index (Discover in the extension, `find_sites` for agents). There is no crawler.
|
|
108
71
|
|
|
109
|
-
|
|
72
|
+
<p align="center">
|
|
73
|
+
<picture>
|
|
74
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/popup-expanded-dark.png">
|
|
75
|
+
<img src="docs/screenshots/popup-expanded-light.png" width="404" alt="An expanded ability showing its real tool names, whether each label is a site hint or guessed, and a prompt to copy to an agent.">
|
|
76
|
+
</picture>
|
|
77
|
+
</p>
|
|
110
78
|
|
|
111
|
-
|
|
112
|
-
npx @puppeteer/browsers install chrome@156 --path "$HOME/.secret-menu/browsers"
|
|
113
|
-
./bin/secret-menu launch # opens the Secret Menu Chrome with the extension loaded
|
|
114
|
-
./bin/secret-menu stop
|
|
115
|
-
```
|
|
79
|
+
<p align="center"><sub>An expanded ability: the real tool names, where each label came from, and a prompt to copy.</sub></p>
|
|
116
80
|
|
|
117
|
-
|
|
81
|
+
<p align="center">
|
|
82
|
+
<picture>
|
|
83
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/badge-dark.png">
|
|
84
|
+
<img src="docs/screenshots/badge-light.png" width="308" alt="A small badge on the page: What more can I do here? 14 WebMCP tools.">
|
|
85
|
+
</picture>
|
|
86
|
+
</p>
|
|
118
87
|
|
|
119
|
-
|
|
120
|
-
./bin/secret-menu serve # test page on http://127.0.0.1:8765/
|
|
121
|
-
```
|
|
88
|
+
<p align="center"><sub>The in-page badge. It appears only on pages that have tools.</sub></p>
|
|
122
89
|
|
|
123
|
-
|
|
90
|
+
<p align="center">
|
|
91
|
+
<img src="docs/screenshots/agent-demo.gif" width="760" alt="An agent opens the test shop through Secret Menu, reads its tools and looks up a product.">
|
|
92
|
+
</p>
|
|
124
93
|
|
|
125
|
-
|
|
126
|
-
./bin/secret-menu launch http://127.0.0.1:8765/
|
|
127
|
-
./bin/secret-menu tabs # tab ids
|
|
128
|
-
./bin/secret-menu tools # tools on the active tab
|
|
129
|
-
./bin/secret-menu explain --tab <id> # what those tools let you do
|
|
130
|
-
./bin/secret-menu call get_status # call one
|
|
131
|
-
./bin/secret-menu stop
|
|
132
|
-
```
|
|
94
|
+
<p align="center"><sub>The MCP calls an agent makes on the test shop: <code>open_site</code>, <code>explain_page</code>, then <code>call_page_tool</code>. Results shortened; the data is fake.</sub></p>
|
|
133
95
|
|
|
134
96
|
## MCP tools
|
|
135
97
|
|
|
136
|
-
| Tool | Needs
|
|
98
|
+
| Tool | Needs Chrome open | What it does |
|
|
137
99
|
| --- | --- | --- |
|
|
138
|
-
| `find_sites` | No | Search the local index of sites Secret Menu has
|
|
139
|
-
| `list_recipes` | No | List ready-made multi-site recipes, plus sequences learned from earlier
|
|
100
|
+
| `find_sites` | No | Search the local index of sites Secret Menu has explained, for ones that can do a task. |
|
|
101
|
+
| `list_recipes` | No | List ready-made multi-site recipes, plus sequences learned from earlier sessions. |
|
|
140
102
|
| `run_recipe` | Yes | Run a read-only recipe end to end, for example `compare-shopify` to compare products across Shopify stores. |
|
|
141
103
|
| `open_site` | Yes | Open a site (or reuse a tab Secret Menu opened), wait for its WebMCP tools, and explain them. |
|
|
142
|
-
| `list_tabs` | Yes | List open web tabs
|
|
104
|
+
| `list_tabs` | Yes | List open web tabs. |
|
|
143
105
|
| `list_page_tools` | Yes | List a tab's raw WebMCP tools: name, description, input schema, annotations. |
|
|
144
106
|
| `explain_page` | Yes | Explain a tab's tools as labelled abilities, with the exact tool names. |
|
|
145
107
|
| `call_page_tool` | Yes | Call one WebMCP tool on a tab, as the signed-in user. |
|
|
146
108
|
|
|
147
|
-
|
|
109
|
+
`call_page_tool` returns the site's own output untouched (capped at 20,000 characters), including any next steps the site suggests. For a read-only server, run `secret-menu mcp --read-only` or `--allow-tools a,b`; see [docs/SAFETY.md](docs/SAFETY.md#read-only-mode).
|
|
148
110
|
|
|
149
|
-
|
|
111
|
+
## Requirements
|
|
150
112
|
|
|
151
|
-
|
|
113
|
+
- Node.js 22 or newer.
|
|
114
|
+
- Google Chrome (or Chrome Beta, Dev or Canary on macOS, or Chromium) on **macOS or Linux**. Windows isn't supported yet.
|
|
115
|
+
- **WebMCP turned on.** Stable Chrome needs `chrome://flags/#enable-webmcp-testing`. Without it, Chrome only exposes WebMCP tools on sites in Google's WebMCP origin trial (Chrome 149 to 162, ending March 30, 2027). WebMCP has no ship date in stable Chrome yet, and most of our testing ran with the flag on.
|
|
116
|
+
- The Secret Menu extension. [Chrome Web Store](https://chromewebstore.google.com/detail/ldijmoknbnamjlegbeplefpmhhfmjakh) (**In review: this link goes live once Chrome Web Store approves it.**). Until then, load it unpacked (see [Try it](#try-it-in-5-minutes)).
|
|
152
117
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
118
|
+
If the popup can't find WebMCP on a page, it tells you and links to the flag:
|
|
119
|
+
|
|
120
|
+
<p align="center">
|
|
121
|
+
<picture>
|
|
122
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/flag-off-dark.png">
|
|
123
|
+
<img src="docs/screenshots/flag-off-light.png" width="404" alt="The popup when WebMCP is probably off in this Chrome, with a button to open the flag.">
|
|
124
|
+
</picture>
|
|
125
|
+
</p>
|
|
126
|
+
|
|
127
|
+
### Known limits
|
|
128
|
+
|
|
129
|
+
- WebMCP and Chrome's API names are experimental and may change.
|
|
130
|
+
- Labels come from rules, not understanding. Sites that leave out their read-only hints get guessed labels, and sign-in needs aren't always detected.
|
|
131
|
+
- Only top frames and HTTP(S) tabs are inspected.
|
|
132
|
+
- Safety controls are opt-in (see below).
|
|
133
|
+
|
|
134
|
+
See [KNOWN_ISSUES.md](KNOWN_ISSUES.md) for the full list.
|
|
159
135
|
|
|
160
|
-
##
|
|
136
|
+
## Safety and privacy
|
|
161
137
|
|
|
162
|
-
|
|
138
|
+
- **Your agent's approval prompt is the check by default.** Calls run as you, on sites you're signed in to.
|
|
139
|
+
- **`secret-menu safety on`** adds an approval step for tools the page doesn't mark read-only, plus a hash-chained audit log. Per-site allowlists and a read-only MCP mode are also available.
|
|
140
|
+
- **No servers, accounts, analytics or telemetry.** What Secret Menu saves stays on your computer, and page tools only reach an agent you connect yourself.
|
|
163
141
|
|
|
164
|
-
|
|
165
|
-
- **Popup:** each ability shows a title and a label. Expand a row to see the full title, its tools, notes and a ready-to-copy agent prompt. **Re-check** reads the tools again. The popup is read-only and never calls page tools.
|
|
166
|
-
- **Before setup:** the extension works on its own; Discover and Hand off show a "Connect your agent" step with `npx secret-menu setup`.
|
|
167
|
-
- **Hand off:** builds an agent prompt from the page's real tools, grouped by what they let you do. Changing data and spending money stay off unless you switch them on. "Run in agent" isn't built yet, so copy the prompt into an agent that has Secret Menu connected.
|
|
168
|
-
- **Discover:** lists the sites in your local index.
|
|
169
|
-
- **Theme:** one button cycles Light, Dark and System.
|
|
142
|
+
Details and limits: [docs/SAFETY.md](docs/SAFETY.md) and [PRIVACY.md](PRIVACY.md).
|
|
170
143
|
|
|
171
144
|
## Command line
|
|
172
145
|
|
|
173
146
|
| Command | What it does |
|
|
174
147
|
| --- | --- |
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
177
|
-
| `
|
|
178
|
-
| `
|
|
179
|
-
| `
|
|
180
|
-
| `
|
|
181
|
-
| `mcp [--read-only] [--allow-tools a,b]` | Run the stdio MCP server |
|
|
182
|
-
| `safety [on\|off]` | Show or change the safety setting |
|
|
183
|
-
| `allow`, `disallow`, `sites` | Edit the per-site allowlist |
|
|
184
|
-
| `approvals`, `approve <id>`, `deny <id>` | Handle pending MCP approvals (when safety is on) |
|
|
185
|
-
| `log [--n 20] [--redact]`, `verify` | Read the audit log and check its hash chain |
|
|
186
|
-
| `setup [--extension-id ID]`, `doctor`, `uninstall` | Install, check and remove the native messaging host for your Chrome |
|
|
187
|
-
| `serve` | Serve the test fixture on `http://127.0.0.1:8765/` (from a clone) |
|
|
188
|
-
|
|
189
|
-
Run `npx secret-menu --help` (or `./bin/secret-menu --help` in a clone) for the full list. Use `--wait-tools` (with `--settle MS` and `--timeout MS`) on pages that register tools a few seconds after loading.
|
|
190
|
-
|
|
191
|
-
Secret Menu only closes or navigates tabs it opened itself: tabs from `open --new`, `explain <url>`, MCP `open_site`, and the tabs `launch <url...>` opens. `close` and `open` refuse other tabs unless you add `--force` (`reload` reloads whichever tab you point it at). `stop` refuses while the Secret Menu Chrome has tabs you opened. It lists them so you can decide, and `stop --force` quits anyway.
|
|
192
|
-
|
|
193
|
-
`stop` sends SIGTERM and waits up to `--timeout` seconds (default 8). It only force-kills Chrome if you pass `--kill`; otherwise a Chrome that is still running is reported and `stop` exits with code 4.
|
|
194
|
-
|
|
195
|
-
## Safety model
|
|
196
|
-
|
|
197
|
-
Secret Menu builds roads; it doesn't police them. The checks below run in the command line (`secret-menu call`) and in the MCP server's `call_page_tool`. The native messaging host and the extension pass on what those clients send and don't check policy themselves.
|
|
198
|
-
|
|
199
|
-
- **Off by default.** Calls run, and your agent's own approval UI decides what needs a yes.
|
|
200
|
-
- **`secret-menu safety on`** turns on the guard and the audit log.
|
|
201
|
-
- **Guard:** tools the page doesn't mark read-only need your approval: a prompt or `--yes` on the CLI, or a one-time `secret-menu approve <id>` for MCP, then a retry within ten minutes. The read-only mark (`readOnlyHint`) comes from the page itself, so a tool the page marks read-only runs without a prompt.
|
|
202
|
-
- **Audit:** the native host writes every request the CLI or MCP server sends through the bridge to `audit.jsonl` when it finishes, as a hash chain that `secret-menu verify` checks. Calls the CLI or MCP server refuse are logged too. The popup and Discover read inside the extension and aren't logged.
|
|
203
|
-
- **Per-site allowlist** (`~/.secret-menu/sites.json`, edited with `secret-menu allow`) applies to `secret-menu call`, MCP `call_page_tool` and `explain --probe` whether or not safety is on, because adding an entry is itself an opt-in. Listed tools run without a prompt. `--strict` refuses every other tool on that site. MCP read-only mode still refuses an allowlisted tool that looks like it changes data. The allowlist trusts your judgement, not the page.
|
|
204
|
-
- **Limits you should know about:**
|
|
205
|
-
- `run_recipe` skips the guard and the allowlist. It only ever calls `search_catalog`, so it runs on a `--strict` site that doesn't list that tool, and with the guard on even if the page doesn't mark it read-only. It's still audited.
|
|
206
|
-
- Any program running as you can talk to the bridge socket (`~/.secret-menu/run/bridge.sock`, readable only by you) directly and skip the guard, the allowlist and read-only mode. The audit log still records those calls when it's on, along with the approval label the program reports.
|
|
207
|
-
- A Chrome started with a remote debugging port (the screenshot harness in [TESTING.md](TESTING.md) uses one) can be driven without any of these checks. `secret-menu launch` doesn't open one.
|
|
208
|
-
- Approvals that expire unused aren't written to the audit log.
|
|
209
|
-
- The audit anchor (`audit.jsonl.head`) sits next to the log, so someone who can edit both can still hide a truncated log.
|
|
210
|
-
- Allowlisted tools are trusted by name. If a site later changes what a tool does, the allowlist still lets it run.
|
|
211
|
-
- The local data folder holds your signed-in Chrome profile, and tool arguments appear in the audit log. Use `secret-menu log --redact` before sharing log lines.
|
|
212
|
-
|
|
213
|
-
## Configuration
|
|
214
|
-
|
|
215
|
-
- Data lives in `~/.secret-menu`. Change it with `SECRET_MENU_HOME`.
|
|
216
|
-
- `SECRET_MENU_CHROME` overrides Chrome discovery.
|
|
217
|
-
- `SECRET_MENU_MCP_READ_ONLY=1` and `SECRET_MENU_MCP_ALLOW_TOOLS=a,b` set MCP read-only mode.
|
|
218
|
-
|
|
219
|
-
## Known issues and limits
|
|
148
|
+
| `setup`, `doctor`, `uninstall` | Install, check and remove the helper for your Chrome-family browsers |
|
|
149
|
+
| `mcp [--read-only] [--allow-tools a,b]` | Run the stdio MCP server for your agent |
|
|
150
|
+
| `serve [--port N]` | Serve the test pages on `http://127.0.0.1:8765/` (the test shop is `/explain.html`) |
|
|
151
|
+
| `explain`, `tools`, `call <tool>` | Explain, list and call a page's tools |
|
|
152
|
+
| `safety [on\|off]`, `allow`, `log` | Safety setting, per-site allowlist and audit log |
|
|
153
|
+
| `launch`, `stop` | A separate test Chrome, for development from a clone |
|
|
220
154
|
|
|
221
|
-
|
|
222
|
-
- Closing the last window of the Secret Menu Chrome stops the bridge. Only `secret-menu launch` recovers, and `watch` goes silent without reconnecting.
|
|
223
|
-
- Explain labels are rules, not understanding. Sites that leave their read-only hints off get guessed labels, and sign-in needs aren't always detected.
|
|
224
|
-
- Store search is the site's own and can be fuzzy. Glossier, for example, answers "bag" with pet-waste bags. The `compare-shopify` recipe uses simple keyword rules.
|
|
225
|
-
- Only top frames and HTTP(S) tabs are inspected. Results are cut at 20,000 characters.
|
|
226
|
-
- Tabs opened before Secret Menu started tracking tabs are treated as yours, not Secret Menu's.
|
|
227
|
-
|
|
228
|
-
See [KNOWN_ISSUES.md](KNOWN_ISSUES.md) for the full list and [TESTING.md](TESTING.md) for tests and UI test hooks.
|
|
155
|
+
Run `npx secret-menu --help` for everything, or see [docs/CLI.md](docs/CLI.md).
|
|
229
156
|
|
|
230
157
|
## Development
|
|
231
158
|
|
|
232
159
|
```sh
|
|
233
160
|
npm --prefix ext install
|
|
234
161
|
npm test # Node 22; unit tests, no browser needed
|
|
235
|
-
npm run ext:build
|
|
162
|
+
npm run ext:build # extension output in ext/.output/chrome-mv3
|
|
236
163
|
```
|
|
237
164
|
|
|
238
|
-
|
|
165
|
+
The extension lives in `ext/` (WXT, React, Tailwind and shadcn/ui on Base UI). See [TESTING.md](TESTING.md) for tests and [docs/CLI.md](docs/CLI.md#the-test-chrome) to run it in its own test Chrome.
|
|
239
166
|
|
|
240
167
|
## Uninstall
|
|
241
168
|
|
|
@@ -243,12 +170,4 @@ Optional, for desktop UI tests only (never needed to run Secret Menu): [agent-de
|
|
|
243
170
|
npx secret-menu uninstall
|
|
244
171
|
```
|
|
245
172
|
|
|
246
|
-
This removes only what setup wrote
|
|
247
|
-
|
|
248
|
-
## Credits
|
|
249
|
-
|
|
250
|
-
The 🗝️ icon is [Twemoji](https://github.com/jdecked/twemoji) "old key" (1f5dd), © Twitter, Inc. and other contributors, used under [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md).
|
|
251
|
-
|
|
252
|
-
## License
|
|
253
|
-
|
|
254
|
-
[MIT](LICENSE) © 2026 Israel Vicars. The 🗝️ icon is licensed separately under CC-BY 4.0 (see Credits).
|
|
173
|
+
This removes only what setup wrote. Your data in `~/.secret-menu` stays; delete that folder to remove it. Then remove the extension from Chrome.
|
package/lib/cli-help.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export const TAGLINE = "🗝️ Secret Menu connects you and your agent to WebMCP tools across sites.";
|
|
2
2
|
|
|
3
3
|
export const COMMANDS = [
|
|
4
|
-
{ name: "setup", group: "Setup", usage: "setup [--extension-id ID]", summary: "Connect the Secret Menu extension in your
|
|
5
|
-
{ name: "doctor", group: "Setup", usage: "doctor [--extension-id ID]", summary: "Check Node,
|
|
6
|
-
{ name: "uninstall", group: "Setup", usage: "uninstall", summary: "Remove what setup wrote (keeps your data)" },
|
|
4
|
+
{ name: "setup", group: "Setup", usage: "setup [--browser NAME ...] [--extension-id ID ...]", summary: "Connect the Secret Menu extension in your Chromium browser(s) to this computer (run once)", details: "Browsers: chrome, chrome-beta, chrome-dev, chrome-canary (macOS), chromium, or all. Repeat --browser or use comma-separated values. Without --browser, existing browser data folders are detected; otherwise Chrome stable is used. Repeat --extension-id for multiple IDs, or use comma-separated values. The pinned ID, and the store ID once set, are always included." },
|
|
5
|
+
{ name: "doctor", group: "Setup", usage: "doctor [--browser NAME ...] [--extension-id ID ...]", summary: "Check Node, browser native host manifests, the extension ID and the bridge", details: "Accepts the same --browser names as setup and reports each selected or detected browser." },
|
|
6
|
+
{ name: "uninstall", group: "Setup", usage: "uninstall [--browser NAME ...]", summary: "Remove what setup wrote (keeps your data)", details: "Removes the manifest from each selected or detected supported browser only when it points at Secret Menu's launcher." },
|
|
7
7
|
{ name: "launch", group: "Browser", usage: "launch [--headless] [url...]", summary: "Open the Secret Menu Chrome (test profile), optionally at URLs" },
|
|
8
8
|
{ name: "stop", group: "Browser", usage: "stop [--force] [--timeout SECONDS] [--kill]", summary: "Stop Secret Menu Chrome after checking for foreign tabs", details: "Sends SIGTERM and waits (default 8s); exits 4 if Chrome is still up. --kill escalates to SIGKILL; --force skips the foreign-tab check." },
|
|
9
9
|
{ name: "status", group: "Browser", usage: "status", summary: "Show Chrome, bridge, extension, and host status" },
|
|
@@ -27,7 +27,7 @@ export const COMMANDS = [
|
|
|
27
27
|
{ name: "log", group: "Safety", usage: "log [--n N] [--redact]", summary: "Print recent audit records" },
|
|
28
28
|
{ name: "verify", group: "Safety", usage: "verify", summary: "Verify the audit log hash chain" },
|
|
29
29
|
{ name: "mcp", group: "Other", usage: "mcp [--read-only] [--allow-tools LIST]", summary: "Run the MCP server so your agent can use page tools" },
|
|
30
|
-
{ name: "serve", group: "Other", usage: "serve", summary: "Host test fixtures on 127.0.0.1:8765" },
|
|
30
|
+
{ name: "serve", group: "Other", usage: "serve [--port N]", summary: "Host test fixtures on 127.0.0.1:N (default 8765)", details: "--port must be an integer from 1 to 65535." },
|
|
31
31
|
];
|
|
32
32
|
|
|
33
33
|
export function commandEntry(name) { return COMMANDS.find((command) => command.name === name); }
|
package/lib/cli.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { addToPolicy, decideTool, loadPolicy, normalizeOrigin, removeFromPolicy,
|
|
|
11
11
|
import { redactText } from "../lib/redact.mjs";
|
|
12
12
|
import { normalizePageContext } from "../lib/page-context.mjs";
|
|
13
13
|
import { classifyTool, explainTools, renderExplain, seedCandidates } from "../lib/explain.mjs";
|
|
14
|
+
import { cacheExplain } from "../lib/learn.mjs";
|
|
14
15
|
import { installNativeHost } from "../host/install-native-host.mjs";
|
|
15
16
|
import { listApprovals, readApproval, writeApproval } from "../lib/approvals.mjs";
|
|
16
17
|
import { parseArgs } from "../lib/args.mjs";
|
|
@@ -24,7 +25,7 @@ import { relay } from "../lib/relay.mjs";
|
|
|
24
25
|
import { waitForTools } from "../lib/wait.mjs";
|
|
25
26
|
import { parseStopTimeout, terminate } from "../lib/stop.mjs";
|
|
26
27
|
import { COMMANDS, TAGLINE, commandHelp, helpText } from "../lib/cli-help.mjs";
|
|
27
|
-
import { doctor, nextStepText,
|
|
28
|
+
import { doctor, nextStepText, resolveExtensionIds, setup, uninstall } from "../lib/setup.mjs";
|
|
28
29
|
|
|
29
30
|
const USAGE = helpText(DATA_HOME);
|
|
30
31
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -33,6 +34,7 @@ const command = commandArgv.shift();
|
|
|
33
34
|
let args = { positionals: [], values: {}, booleans: {}, multi: {} };
|
|
34
35
|
const flag = (name) => args.booleans[name.slice(2)] === true;
|
|
35
36
|
const option = (name, fallback) => args.values[name.slice(2)] ?? fallback;
|
|
37
|
+
const options = (name) => args.multi[name.slice(2)] ?? (option(name) == null ? [] : [option(name)]);
|
|
36
38
|
const tabId = () => option("--tab") == null ? undefined : Number(option("--tab"));
|
|
37
39
|
const request = (method, params = {}, approval = "n/a") => relay(method, params, { caller: "cli", approval });
|
|
38
40
|
const die = (message, code = 1) => { console.error(message); process.exit(code); };
|
|
@@ -241,6 +243,7 @@ async function cmdExplain() {
|
|
|
241
243
|
if (seed.add.length || strict) { const next = addToPolicy(policy, origin, seed.add, { strict }); savePolicy(POLICY_PATH, next); appendAudit(AUDIT_PATH, { ts: new Date().toISOString(), caller: "cli", method: "site_policy", origin, policy: next.sites[origin], via: "explain --seed-allowlist" }); seed.mode = next.sites[origin].mode; }
|
|
242
244
|
else seed.mode = site?.mode ?? null;
|
|
243
245
|
}
|
|
246
|
+
try { cacheExplain(DATA_HOME, report); } catch {}
|
|
244
247
|
if (flag("--json")) return output({ ...report, ...(seed ? { seed } : {}) }, true);
|
|
245
248
|
console.log(renderExplain(report));
|
|
246
249
|
if (seed) {
|
|
@@ -258,26 +261,35 @@ function cmdDisallow() { const [origin, ...tools] = args.positionals; if (!origi
|
|
|
258
261
|
function cmdVerify() { const rows = readAudit(AUDIT_PATH), anchor = readAuditAnchor(AUDIT_PATH), v = verifyAudit(rows, anchor); if (!v.ok && v.truncated) die(`BAD: audit log truncated (expected seq ${v.expectedSeq}, found ${v.foundSeq})`, 6); if (!v.ok) die(`BAD: first invalid audit record seq ${v.seq}`, 6); if (rows.length && !anchor) console.error("warning: audit head anchor is missing; tail truncation cannot be detected"); console.log(`OK: ${v.count} audit records`); }
|
|
259
262
|
function cmdApprovals() { for (const x of listApprovals(APPROVALS_DIR).filter((a) => a.status === "pending")) console.log(`${x.id}\t${x.createdAt}\t${x.origin}\t${x.tool}\t${JSON.stringify(x.args)}`); }
|
|
260
263
|
function decide(status) { const id = args.positionals[0]; if (!id) die(`usage: secret-menu ${status === "approved" ? "approve" : "deny"} <id>`, 2); const rec = readApproval(APPROVALS_DIR, id); if (!rec) die(`Approval not found: ${id}`, 2); if (Date.now() - Date.parse(rec.createdAt) > 600000) die(`Approval expired: ${id}`, 2); if (rec.status !== "pending") die(`Approval is already ${rec.status}`, 2); rec.status = status; rec.decidedAt = new Date().toISOString(); writeApproval(APPROVALS_DIR, rec); appendAudit(AUDIT_PATH, { ts: rec.decidedAt, caller: "cli", method: "approval_decision", approvalId: rec.id, tool: rec.tool, origin: rec.origin, argsDigest: sha256(canonical(rec.args)), decision: status }); console.log(`${status}: ${id}`); }
|
|
261
|
-
function cmdServe() {
|
|
262
|
-
|
|
264
|
+
function cmdServe() {
|
|
265
|
+
const dir = join(root, "test", "fixtures"), rawPort = option("--port", "8765"), port = Number(rawPort);
|
|
266
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) die("--port must be an integer from 1 to 65535", 2);
|
|
267
|
+
if (!existsSync(dir)) die("test fixtures are missing; this package cannot serve its test pages", 1);
|
|
268
|
+
const mime = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8" };
|
|
269
|
+
const server = createServer((req, res) => { const pathname = decodeURIComponent(new URL(req.url, "http://localhost").pathname); const path = resolve(dir, `.${pathname === "/" ? "/churn.html" : pathname}`), rel = relative(dir, path); if (rel.startsWith("..") || rel.startsWith("/") || !existsSync(path)) { res.writeHead(404).end("not found"); return; } res.setHeader("content-type", mime[extname(path)] ?? "application/octet-stream"); res.end(readFileSync(path)); });
|
|
270
|
+
server.on("error", (error) => { console.error(error.code === "EADDRINUSE" ? `Port ${port} is already in use. Stop the other server or run: secret-menu serve --port <n>` : `Could not serve fixtures: ${error.message}`); process.exit(1); });
|
|
271
|
+
server.listen(port, "127.0.0.1", () => console.log(`Serving http://127.0.0.1:${port}/`));
|
|
272
|
+
}
|
|
273
|
+
function extensionIdOrDie() { try { return resolveExtensionIds({ flags: options("--extension-id") }); } catch (e) { die(e.message, 2); } }
|
|
263
274
|
function cmdSetup() {
|
|
264
275
|
const ext = extensionIdOrDie();
|
|
265
|
-
let res; try { res = setup({ dataHome: DATA_HOME,
|
|
276
|
+
let res; try { res = setup({ dataHome: DATA_HOME, extensionIds: ext.ids, browsers: options("--browser") }); } catch (e) { die(e.message); }
|
|
266
277
|
console.log("Secret Menu setup: connecting the extension in your Chrome to this computer.");
|
|
278
|
+
for (const browser of res.browsers) console.log(` browser ${browser.browser}: ${browser.hostsDir}`);
|
|
267
279
|
for (const [what, path] of res.actions) console.log(` ${what.padEnd(9)} ${path}`);
|
|
268
280
|
if (res.replaced) console.log(` note: the manifest used to point at ${res.replaced}; it now points at the launcher above.`);
|
|
269
|
-
console.log(`
|
|
281
|
+
console.log(` extensions ${ext.ids.join(", ")} (${ext.source})\n\n${nextStepText()}`);
|
|
270
282
|
}
|
|
271
283
|
async function cmdDoctor() {
|
|
272
284
|
const ext = extensionIdOrDie();
|
|
273
|
-
|
|
285
|
+
let checks; try { checks = await doctor({ dataHome: DATA_HOME, socketPath: SOCKET_PATH, extensionIds: ext.ids, browsers: options("--browser") }); } catch (e) { die(e.message); }
|
|
274
286
|
for (const c of checks) { console.log(`${c.ok ? "ok " : "FAIL"} ${c.label}`); if (!c.ok) console.log(` fix: ${c.fix}`); }
|
|
275
287
|
const failed = checks.filter((c) => !c.ok).length;
|
|
276
288
|
console.log(failed ? `${failed} check${failed === 1 ? "" : "s"} failed.` : "All checks passed.");
|
|
277
289
|
if (failed) process.exitCode = 1;
|
|
278
290
|
}
|
|
279
291
|
function cmdUninstall() {
|
|
280
|
-
let res; try { res = uninstall({ dataHome: DATA_HOME }); } catch (e) { die(e.message); }
|
|
292
|
+
let res; try { res = uninstall({ dataHome: DATA_HOME, browsers: options("--browser") }); } catch (e) { die(e.message); }
|
|
281
293
|
for (const [what, path] of res.actions) console.log(` ${what.padEnd(9)} ${path}`);
|
|
282
294
|
console.log(`Left in place: your data in ${DATA_HOME} and anything secret-menu launch wrote.`);
|
|
283
295
|
}
|
|
@@ -297,8 +309,10 @@ async function main() {
|
|
|
297
309
|
sites: { booleans: ["--json"] },
|
|
298
310
|
explain: { values: ["--tab", "--timeout", "--settle"], booleans: ["--json", "--seed-allowlist", "--strict", "--probe"] },
|
|
299
311
|
allow: { booleans: ["--strict", "--no-strict"] },
|
|
300
|
-
setup: { values: ["--extension-id"] },
|
|
301
|
-
doctor: { values: ["--extension-id"] },
|
|
312
|
+
setup: { values: ["--extension-id", "--browser"], multi: ["--extension-id", "--browser"] },
|
|
313
|
+
doctor: { values: ["--extension-id", "--browser"], multi: ["--extension-id", "--browser"] },
|
|
314
|
+
uninstall: { values: ["--browser"], multi: ["--browser"] },
|
|
315
|
+
serve: { values: ["--port"] },
|
|
302
316
|
};
|
|
303
317
|
if (command == null || command === "--help" || command === "-h") { console.log(USAGE); return; }
|
|
304
318
|
if (command === "help") { const target = commandArgv[0]; if (!target) { console.log(USAGE); return; } const detail = commandHelp(target); if (!detail) die(USAGE, 2); console.log(`${TAGLINE}\n${detail}`); return; }
|
package/lib/explain.mjs
CHANGED
|
@@ -217,9 +217,15 @@ function exampleForSpec(spec = {}, key = "value") {
|
|
|
217
217
|
}
|
|
218
218
|
return type === "string" || !type ? `<${key}>` : exampleValue(spec);
|
|
219
219
|
}
|
|
220
|
+
function exampleCli(cli) {
|
|
221
|
+
const path = String(cli);
|
|
222
|
+
if (/_npx[\\/]/.test(path)) return "npx secret-menu";
|
|
223
|
+
if (/node_modules[\\/]/.test(path)) return "secret-menu";
|
|
224
|
+
return path;
|
|
225
|
+
}
|
|
220
226
|
export function exampleCommand(c, tool, { cli = "secret-menu", tabId } = {}) {
|
|
221
227
|
const schema = parseSchema(tool?.inputSchema);
|
|
222
|
-
const parts = [cli, "call", c.name];
|
|
228
|
+
const parts = [exampleCli(cli), "call", c.name];
|
|
223
229
|
if (tabId != null) parts.push("--tab", String(tabId));
|
|
224
230
|
for (const key of c.required) {
|
|
225
231
|
const spec = schema.properties?.[key] ?? {};
|
package/lib/handoff.mjs
CHANGED
|
@@ -44,7 +44,7 @@ export function handoffPrompt({ host, task, abilities, allowChanges = false, all
|
|
|
44
44
|
} else parts.push("This site has no WebMCP tools you have allowed; tell me what you would need.");
|
|
45
45
|
if (asks.length) parts.push(`Before calling ${list(asks)}, show me exactly what will ${plan.ask.some((t) => t.label === "spends money") ? "change or be charged" : "change"} and wait for my yes.`);
|
|
46
46
|
if (never.length) parts.push(`Never call ${list(never)}.`);
|
|
47
|
-
if (signIn) parts.push("If a tool needs sign-in, ask me to sign in to the site in
|
|
47
|
+
if (signIn) parts.push("If a tool needs sign-in, ask me to sign in to the site in your Chrome; never ask for my password.");
|
|
48
48
|
parts.push("Stop before confirming.");
|
|
49
49
|
return parts.join(" ");
|
|
50
50
|
}
|
package/lib/setup.mjs
CHANGED
|
@@ -10,30 +10,73 @@ import { fileURLToPath } from "node:url";
|
|
|
10
10
|
|
|
11
11
|
export const HOST_NAME = "com.secretmenu.bridge";
|
|
12
12
|
export const DEFAULT_EXTENSION_ID = "jfeobmnppdjonobichecmfkabddogeim";
|
|
13
|
+
export const STORE_EXTENSION_ID = "ldijmoknbnamjlegbeplefpmhhfmjakh"; // Chrome Web Store listing ID (dev ID stays DEFAULT_EXTENSION_ID).
|
|
14
|
+
export const KNOWN_EXTENSION_IDS = [DEFAULT_EXTENSION_ID, STORE_EXTENSION_ID].filter(Boolean);
|
|
13
15
|
export const EXTENSION_ID_RE = /^[a-p]{32}$/;
|
|
14
16
|
export const MIN_NODE_MAJOR = 22;
|
|
15
17
|
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
16
18
|
// What the stable copy needs to run host/native-host.mjs without the npx cache (host imports ../lib/*).
|
|
17
19
|
const APP_PARTS = ["host", "lib", "bin", "package.json", join("ext", "EXTENSION_ID")];
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
const BROWSER_FOLDERS = {
|
|
22
|
+
chrome: { mac: "Google/Chrome", linux: "google-chrome" },
|
|
23
|
+
"chrome-beta": { mac: "Google/Chrome Beta", linux: "google-chrome-beta" },
|
|
24
|
+
"chrome-dev": { mac: "Google/Chrome Dev", linux: "google-chrome-unstable" },
|
|
25
|
+
"chrome-canary": { mac: "Google/Chrome Canary", linux: null },
|
|
26
|
+
chromium: { mac: "Chromium", linux: "chromium" },
|
|
27
|
+
};
|
|
28
|
+
export const SUPPORTED_BROWSERS = Object.keys(BROWSER_FOLDERS);
|
|
29
|
+
|
|
30
|
+
function browserPlatform(platform) {
|
|
31
|
+
if (platform === "darwin") return "mac";
|
|
32
|
+
if (platform === "linux") return "linux";
|
|
23
33
|
if (platform === "win32") throw new Error("secret-menu setup does not support Windows yet (Chrome there needs a registry key, and the bridge needs a named pipe). macOS and Linux are supported.");
|
|
24
34
|
throw new Error(`secret-menu setup does not support ${platform} yet. macOS and Linux are supported.`);
|
|
25
35
|
}
|
|
26
36
|
|
|
27
|
-
export function
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
if (!
|
|
31
|
-
return
|
|
37
|
+
export function browserDataDir({ browser = "chrome", platform = process.platform, home = homedir(), env = process.env } = {}) {
|
|
38
|
+
if (!BROWSER_FOLDERS[browser]) throw new Error(`Unknown browser ${JSON.stringify(browser)}. Valid names: ${SUPPORTED_BROWSERS.join(", ")}, all.`);
|
|
39
|
+
const folder = BROWSER_FOLDERS[browser][browserPlatform(platform)];
|
|
40
|
+
if (!folder) throw new Error(`${browser} is not supported on Linux. Valid Linux browsers: chrome, chrome-beta, chrome-dev, chromium.`);
|
|
41
|
+
if (platform === "darwin") return join(home, "Library", "Application Support", folder);
|
|
42
|
+
return join(env.XDG_CONFIG_HOME || join(home, ".config"), folder);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Where a Chromium browser looks for per-user native messaging host manifests.
|
|
46
|
+
export function chromeHostsDir({ platform = process.platform, home = homedir(), env = process.env, browser = "chrome" } = {}) {
|
|
47
|
+
return join(browserDataDir({ browser, platform, home, env }), "NativeMessagingHosts");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolveBrowsers({ browsers = [], platform = process.platform, home = homedir(), env = process.env, detect = true } = {}) {
|
|
51
|
+
const requested = browsers.flatMap((value) => String(value).split(",")).map((value) => value.trim()).filter(Boolean);
|
|
52
|
+
for (const browser of requested) {
|
|
53
|
+
if (browser !== "all" && !SUPPORTED_BROWSERS.includes(browser)) throw new Error(`Unknown browser ${JSON.stringify(browser)}. Valid names: ${SUPPORTED_BROWSERS.join(", ")}, all.`);
|
|
54
|
+
if (browser !== "all") browserDataDir({ browser, platform, home, env });
|
|
55
|
+
}
|
|
56
|
+
let names;
|
|
57
|
+
if (requested.includes("all")) names = SUPPORTED_BROWSERS.filter((browser) => BROWSER_FOLDERS[browser][browserPlatform(platform)]);
|
|
58
|
+
else if (requested.length) names = requested;
|
|
59
|
+
else if (detect) names = SUPPORTED_BROWSERS.filter((browser) => BROWSER_FOLDERS[browser][browserPlatform(platform)] && existsSync(browserDataDir({ browser, platform, home, env })));
|
|
60
|
+
else names = [];
|
|
61
|
+
return [...new Set(names.length ? names : ["chrome"])];
|
|
32
62
|
}
|
|
33
63
|
|
|
34
|
-
export function
|
|
64
|
+
export function resolveExtensionIds({ flags = [], env = process.env } = {}) {
|
|
65
|
+
const hasFlags = flags.length > 0;
|
|
66
|
+
const source = hasFlags ? "--extension-id" : env.SECRET_MENU_EXTENSION_ID ? "SECRET_MENU_EXTENSION_ID" : "default (the extension's pinned ID)";
|
|
67
|
+
const extra = (hasFlags ? flags : env.SECRET_MENU_EXTENSION_ID ? String(env.SECRET_MENU_EXTENSION_ID).split(",") : []).flatMap((value) => String(value).split(","));
|
|
68
|
+
const ids = [];
|
|
69
|
+
for (const value of [...extra, ...KNOWN_EXTENSION_IDS]) {
|
|
70
|
+
const trimmed = String(value).trim();
|
|
71
|
+
if (!EXTENSION_ID_RE.test(trimmed)) throw new Error(`Not a Chrome extension ID (${source}): ${JSON.stringify(trimmed)}. IDs are 32 letters a-p, shown on chrome://extensions with Developer mode on.`);
|
|
72
|
+
if (!ids.includes(trimmed)) ids.push(trimmed);
|
|
73
|
+
}
|
|
74
|
+
return { ids, source };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function setupPaths({ dataHome, platform, home, env, browser = "chrome" } = {}) {
|
|
35
78
|
const dir = join(dataHome, "native-host", "setup");
|
|
36
|
-
return { dir, app: join(dir, "app"), launcher: join(dir, "secret-menu-host"), receipt: join(dir, "setup.json"), hostsDir: chromeHostsDir({ platform, home, env }), get manifest() { return join(this.hostsDir, `${HOST_NAME}.json`); } };
|
|
79
|
+
return { dir, app: join(dir, "app"), launcher: join(dir, "secret-menu-host"), receipt: join(dir, "setup.json"), browser, hostsDir: chromeHostsDir({ platform, home, env, browser }), get manifest() { return join(this.hostsDir, `${HOST_NAME}.json`); } };
|
|
37
80
|
}
|
|
38
81
|
|
|
39
82
|
const shQuote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
@@ -62,12 +105,13 @@ function treeHash(root) {
|
|
|
62
105
|
return hash.digest("hex");
|
|
63
106
|
}
|
|
64
107
|
|
|
65
|
-
export function manifestFor({ launcher,
|
|
66
|
-
return { name: HOST_NAME, description: "Secret Menu local bridge", path: launcher, type: "stdio", allowed_origins:
|
|
108
|
+
export function manifestFor({ launcher, extensionIds }) {
|
|
109
|
+
return { name: HOST_NAME, description: "Secret Menu local bridge", path: launcher, type: "stdio", allowed_origins: extensionIds.map((id) => `chrome-extension://${id}/`) };
|
|
67
110
|
}
|
|
68
111
|
|
|
69
|
-
export function setup({ dataHome,
|
|
70
|
-
const
|
|
112
|
+
export function setup({ dataHome, extensionIds, browsers = [], browser, platform = process.platform, home = homedir(), env = process.env, nodePath = process.execPath, pkgRoot = PKG_ROOT } = {}) {
|
|
113
|
+
const browserNames = resolveBrowsers({ browsers: browser ? [browser] : browsers, platform, home, env });
|
|
114
|
+
const p = setupPaths({ dataHome, platform, home, env, browser: browserNames[0] });
|
|
71
115
|
const actions = [];
|
|
72
116
|
// 1. A stable copy of the host code, outside the npx cache (which npm may clear at any time).
|
|
73
117
|
if (existsSync(p.app) && treeHash(p.app) === treeHash(pkgRoot)) actions.push(["unchanged", p.app]);
|
|
@@ -85,26 +129,43 @@ export function setup({ dataHome, extensionId, platform = process.platform, home
|
|
|
85
129
|
const launcherText = `#!/bin/sh\n# Written by secret-menu setup. Remove with: secret-menu uninstall\nSECRET_MENU_HOME=${shQuote(dataHome)}\nexport SECRET_MENU_HOME\nexec ${shQuote(nodePath)} ${shQuote(script)} "$@"\n`;
|
|
86
130
|
actions.push([writeIfChanged(p.launcher, launcherText, 0o700), p.launcher]);
|
|
87
131
|
// 3. The Chrome manifest. Note when it replaces one that points somewhere else (e.g. a dev checkout).
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
132
|
+
const manifests = [];
|
|
133
|
+
for (const name of browserNames) {
|
|
134
|
+
const browserPath = setupPaths({ dataHome, platform, home, env, browser: name });
|
|
135
|
+
const existing = readJson(browserPath.manifest);
|
|
136
|
+
const replaced = existing && existing.path && existing.path !== p.launcher ? existing.path : null;
|
|
137
|
+
const manifestText = `${JSON.stringify(manifestFor({ launcher: p.launcher, extensionIds }), null, 2)}\n`;
|
|
138
|
+
actions.push([writeIfChanged(browserPath.manifest, manifestText), browserPath.manifest]);
|
|
139
|
+
manifests.push({ browser: name, hostsDir: browserPath.hostsDir, manifest: browserPath.manifest, replaced });
|
|
140
|
+
}
|
|
141
|
+
const replaced = manifests.find((item) => item.replaced)?.replaced ?? null;
|
|
92
142
|
// 4. A receipt, so uninstall removes only what setup wrote.
|
|
93
|
-
const receipt = { manifest:
|
|
143
|
+
const receipt = { manifests: manifests.map((item) => item.manifest), manifest: manifests[0].manifest, launcher: p.launcher, app: p.app, extensionIds, node: nodePath };
|
|
94
144
|
actions.push([writeIfChanged(p.receipt, `${JSON.stringify(receipt, null, 2)}\n`), p.receipt]);
|
|
95
|
-
return { ...p, manifest: p.manifest, actions, replaced,
|
|
145
|
+
return { ...p, manifest: p.manifest, actions, replaced, extensionIds, browsers: manifests };
|
|
96
146
|
}
|
|
97
147
|
|
|
98
|
-
export function uninstall({ dataHome, platform = process.platform, home = homedir(), env = process.env } = {}) {
|
|
99
|
-
const
|
|
148
|
+
export function uninstall({ dataHome, browsers = [], browser, platform = process.platform, home = homedir(), env = process.env } = {}) {
|
|
149
|
+
const names = resolveBrowsers({ browsers: browser ? [browser] : browsers, platform, home, env });
|
|
150
|
+
const p = setupPaths({ dataHome, platform, home, env, browser: names[0] });
|
|
100
151
|
const receipt = readJson(p.receipt);
|
|
101
|
-
const
|
|
152
|
+
const receiptManifests = receipt?.manifests ?? (receipt?.manifest ? [receipt.manifest] : []);
|
|
102
153
|
const actions = [];
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
154
|
+
// Without --browser, also clean every manifest the last setup recorded, even if its browser folder is gone.
|
|
155
|
+
const explicit = (browser ? [browser] : browsers).length > 0;
|
|
156
|
+
const paths = [...new Set([...names.map((name) => setupPaths({ dataHome, platform, home, env, browser: name }).manifest), ...(explicit ? [] : receiptManifests)])];
|
|
157
|
+
for (const manifestPath of paths) {
|
|
158
|
+
const m = readJson(manifestPath);
|
|
159
|
+
if (m && m.path === p.launcher) { rmSync(manifestPath, { force: true }); actions.push(["removed", manifestPath]); }
|
|
160
|
+
else if (m) actions.push(["kept", `${manifestPath} (points at ${m.path}, not written by setup)`]);
|
|
161
|
+
else actions.push(["absent", manifestPath]);
|
|
162
|
+
}
|
|
163
|
+
// Keep the launcher while another browser's manifest (from the last setup) still points at it.
|
|
164
|
+
const remaining = receiptManifests.filter((path) => !paths.includes(path) && readJson(path)?.path === p.launcher);
|
|
165
|
+
if (remaining.length) {
|
|
166
|
+
writeIfChanged(p.receipt, `${JSON.stringify({ ...receipt, manifests: remaining, manifest: remaining[0] }, null, 2)}\n`);
|
|
167
|
+
actions.push(["kept", `${p.dir} (still used by ${remaining.join(", ")})`]);
|
|
168
|
+
} else if (existsSync(p.dir)) { rmSync(p.dir, { recursive: true, force: true }); actions.push(["removed", p.dir]); }
|
|
108
169
|
else actions.push(["absent", p.dir]);
|
|
109
170
|
return { actions };
|
|
110
171
|
}
|
|
@@ -122,34 +183,39 @@ function socketReachable(path, timeoutMs = 1500) {
|
|
|
122
183
|
|
|
123
184
|
// Each check: { ok, label, fix? }. Bridge reachability is informational when nothing else failed:
|
|
124
185
|
// the bridge only runs while Chrome has the extension loaded.
|
|
125
|
-
export async function doctor({ dataHome, socketPath,
|
|
186
|
+
export async function doctor({ dataHome, socketPath, extensionIds, browsers = [], browser, platform = process.platform, home = homedir(), env = process.env, nodeVersion = process.versions.node } = {}) {
|
|
126
187
|
const checks = [];
|
|
127
188
|
const add = (ok, label, fix) => checks.push({ ok, label, ...(ok ? {} : { fix }) });
|
|
128
189
|
const major = Number(String(nodeVersion).split(".")[0]);
|
|
129
190
|
add(major >= MIN_NODE_MAJOR, `Node ${nodeVersion} (needs ${MIN_NODE_MAJOR}+)`, `Install Node ${MIN_NODE_MAJOR} or newer (https://nodejs.org).`);
|
|
130
|
-
let p;
|
|
131
|
-
try { p = setupPaths({ dataHome, platform, home, env }); }
|
|
191
|
+
let p, browserNames;
|
|
192
|
+
try { browserNames = resolveBrowsers({ browsers: browser ? [browser] : browsers, platform, home, env }); p = setupPaths({ dataHome, platform, home, env, browser: browserNames[0] }); }
|
|
132
193
|
catch (e) { add(false, "Platform supported", e.message); return checks; }
|
|
133
194
|
const setupFix = "Run: npx -y secret-menu setup";
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
195
|
+
for (const name of browserNames) {
|
|
196
|
+
const browserPath = setupPaths({ dataHome, platform, home, env, browser: name });
|
|
197
|
+
const manifest = readJson(browserPath.manifest);
|
|
198
|
+
if (!existsSync(browserPath.manifest)) { add(false, `Native host manifest at ${browserPath.manifest}${browserNames.length > 1 ? ` (${name})` : ""}`, setupFix); continue; }
|
|
199
|
+
if (!manifest) { add(false, `Native host manifest parses (${browserPath.manifest})`, `The file is not valid JSON. ${setupFix}`); continue; }
|
|
200
|
+
add(true, `Native host manifest at ${browserPath.manifest}${browserNames.length > 1 ? ` (${name})` : ""}`);
|
|
201
|
+
add(manifest.name === HOST_NAME, `Manifest name is ${HOST_NAME}${browserNames.length > 1 ? ` (${name})` : ""}`, setupFix);
|
|
202
|
+
let exec = false;
|
|
203
|
+
try { exec = statSync(manifest.path).isFile(); accessSync(manifest.path, constants.X_OK); } catch { exec = false; }
|
|
204
|
+
add(exec, `Manifest points at an executable (${manifest.path})${browserNames.length > 1 ? ` (${name})` : ""}`, setupFix);
|
|
205
|
+
if (exec && manifest.path === browserPath.launcher) {
|
|
206
|
+
const text = readText(browserPath.launcher) ?? "";
|
|
207
|
+
const quoted = [...text.matchAll(/^exec '((?:[^']|'\\'')*)' '((?:[^']|'\\'')*)'/gm)][0];
|
|
208
|
+
const unq = (s) => s.replace(/'\\''/g, "'");
|
|
209
|
+
const node = quoted && unq(quoted[1]), script = quoted && unq(quoted[2]);
|
|
210
|
+
add(!!node && existsSync(node), `Launcher's Node exists (${node ?? "unknown"})`, `Node moved or was removed (nvm upgrade?). ${setupFix}`);
|
|
211
|
+
add(!!script && existsSync(script), `Launcher's host script exists (${script ?? "unknown"})`, setupFix);
|
|
212
|
+
}
|
|
213
|
+
const origins = Array.isArray(manifest.allowed_origins) ? manifest.allowed_origins : [];
|
|
214
|
+
const missing = extensionIds.filter((id) => !origins.includes(`chrome-extension://${id}/`));
|
|
215
|
+
const label = extensionIds.length === 1 ? `Manifest allows extension ${extensionIds[0]}` : `Manifest allows extensions ${extensionIds.join(", ")}`;
|
|
216
|
+
const suggested = missing.filter((id) => !KNOWN_EXTENSION_IDS.includes(id)).map((id) => `--extension-id ${id}`).join(" ");
|
|
217
|
+
add(missing.length === 0, `${label}${browserNames.length > 1 ? ` (${name})` : ""}`, `It allows ${origins.join(", ") || "nothing"}. Run: npx -y secret-menu setup${suggested ? ` ${suggested}` : ""}`);
|
|
149
218
|
}
|
|
150
|
-
const origins = Array.isArray(manifest.allowed_origins) ? manifest.allowed_origins : [];
|
|
151
|
-
const want = `chrome-extension://${extensionId}/`;
|
|
152
|
-
add(origins.includes(want), `Manifest allows extension ${extensionId}`, `It allows ${origins.join(", ") || "nothing"}. Run: npx -y secret-menu setup --extension-id ${extensionId}`);
|
|
153
219
|
const sock = await socketReachable(socketPath);
|
|
154
220
|
add(sock.ok, `Bridge reachable at ${socketPath}${sock.ok ? "" : ` (${sock.why})`}`, "Open Chrome with the Secret Menu extension enabled; Chrome starts the bridge. If it still fails, check chrome://extensions for errors.");
|
|
155
221
|
return checks;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "secret-menu",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Let your AI agent use the WebMCP tools sites expose in your Chrome, through the Secret Menu extension.",
|
|
6
6
|
"type": "module",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"host/",
|
|
14
14
|
"ext/EXTENSION_ID",
|
|
15
15
|
"README.md",
|
|
16
|
-
"LICENSE"
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"test/fixtures/"
|
|
17
18
|
],
|
|
18
19
|
"engines": {
|
|
19
20
|
"node": ">=22"
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
"scripts": {
|
|
38
39
|
"test": "node --test test/*.test.mjs",
|
|
39
40
|
"ext:build": "npm --prefix ext run build",
|
|
41
|
+
"ext:zip:store": "npm --prefix ext run zip:store",
|
|
40
42
|
"ext:dev": "npm --prefix ext run dev"
|
|
41
43
|
},
|
|
42
44
|
"mcpName": "io.github.israelvicars/secret-menu"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8"><title>Secret Menu churn B</title>
|
|
3
|
+
<h1>Full-navigation fixture B</h1><a href="churn.html">Back to churn fixture</a><pre id="log"></pre>
|
|
4
|
+
<script type="module">
|
|
5
|
+
const log = document.querySelector("#log");
|
|
6
|
+
if (!document.modelContext) log.textContent = "WebMCP unavailable";
|
|
7
|
+
else { await document.modelContext.registerTool({ name: "get_b_info", description: "Identify fixture B.", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, execute: async () => ({ fixture: "b", url: location.href }) }); log.textContent = "registered get_b_info"; }
|
|
8
|
+
</script>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>Secret Menu churn fixture</title>
|
|
4
|
+
<h1>Secret Menu churn fixture</h1>
|
|
5
|
+
<button id="signin">Sign in (fake)</button> <button id="signout">Sign out</button>
|
|
6
|
+
<a href="#/checkout">Checkout route</a> · <a href="#/home">Home route</a> · <a href="churn-b.html">Full navigation</a>
|
|
7
|
+
<pre id="log"></pre>
|
|
8
|
+
<script type="module">
|
|
9
|
+
const loaded = new Date().toISOString(), log = (s) => document.querySelector("#log").textContent += `${new Date().toISOString()} ${s}\n`;
|
|
10
|
+
const mc = document.modelContext; if (!mc) { log("WebMCP unavailable"); throw new Error("document.modelContext missing"); }
|
|
11
|
+
const active = new Map(); let cartCount = 0;
|
|
12
|
+
const session = () => ({ loggedIn: localStorage.getItem("secret_menu_user") === "demo", user: localStorage.getItem("secret_menu_user"), cartCount, sessionCookie: document.cookie.includes("secret_menu_session="), localStorageSession: Boolean(localStorage.getItem("secret_menu_user")), pageLoadedAt: loaded });
|
|
13
|
+
async function add(tool) {
|
|
14
|
+
if (active.has(tool.name)) return;
|
|
15
|
+
const controller = new AbortController(); let method = "signal";
|
|
16
|
+
try { await mc.registerTool(tool, { signal: controller.signal }); active.set(tool.name, { controller, method }); }
|
|
17
|
+
catch { await mc.registerTool(tool); method = typeof mc.unregisterTool === "function" ? "unregisterTool" : "cannot unregister"; active.set(tool.name, { method }); }
|
|
18
|
+
log(`registered ${tool.name}; removal: ${method}`);
|
|
19
|
+
}
|
|
20
|
+
async function remove(name) { const item = active.get(name); if (!item) return; try { if (item.method === "signal") item.controller.abort(); else if (item.method === "unregisterTool") await mc.unregisterTool(name); else { log(`cannot unregister ${name}`); return; } active.delete(name); log(`removed ${name} via ${item.method}`); } catch (e) { log(`remove ${name} failed: ${e.message}`); } }
|
|
21
|
+
const object = (properties = {}, required = []) => ({ type: "object", properties, required });
|
|
22
|
+
await add({ name: "get_status", description: "Report the fake session and cart state.", inputSchema: object(), annotations: { readOnlyHint: true }, execute: async () => session() });
|
|
23
|
+
await add({ name: "fake_sign_in", description: "Create a fake cookie and localStorage session.", inputSchema: object(), annotations: { readOnlyHint: false }, execute: async () => { document.cookie = `secret_menu_session=demo-${crypto.randomUUID()}; path=/; max-age=86400`; localStorage.setItem("secret_menu_user", "demo"); await sync(); return session(); } });
|
|
24
|
+
async function sync() {
|
|
25
|
+
if (session().loggedIn) {
|
|
26
|
+
await add({ name: "get_account", description: "Read the fake signed-in account.", inputSchema: object(), annotations: { readOnlyHint: true }, execute: async () => ({ user: "demo" }) });
|
|
27
|
+
await add({ name: "add_to_cart", description: "Add an item to the fake cart.", inputSchema: object({ item: { type: "string", enum: ["tea", "coffee", "scone"], description: "Item to add" }, qty: { type: "integer", default: 1, description: "Quantity" } }, ["item"]), annotations: { readOnlyHint: false, consequentialHint: true }, execute: async ({ item, qty = 1 }) => ({ item, qty, cartCount: cartCount += qty }) });
|
|
28
|
+
} else { await remove("get_account"); await remove("add_to_cart"); }
|
|
29
|
+
if (location.hash === "#/checkout") await add({ name: "checkout", description: "Complete fake checkout.", inputSchema: object({ confirm: { type: "boolean", description: "Confirm checkout" } }, ["confirm"]), annotations: { readOnlyHint: false, consequentialHint: true }, execute: async ({ confirm }) => ({ checkedOut: confirm, cartCount }) }); else await remove("checkout");
|
|
30
|
+
}
|
|
31
|
+
document.querySelector("#signin").onclick = async () => { document.cookie = `secret_menu_session=demo-${crypto.randomUUID()}; path=/; max-age=86400`; localStorage.setItem("secret_menu_user", "demo"); await sync(); };
|
|
32
|
+
document.querySelector("#signout").onclick = async () => { document.cookie = "secret_menu_session=; path=/; max-age=0"; localStorage.removeItem("secret_menu_user"); await sync(); };
|
|
33
|
+
addEventListener("hashchange", sync); addEventListener("storage", sync); await sync();
|
|
34
|
+
setTimeout(() => add({ name: "late_tool", description: "Appears three seconds after page load.", inputSchema: object(), annotations: { readOnlyHint: true }, execute: async () => ({ late: true }) }), 3000);
|
|
35
|
+
</script>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "secret-menu.fixture-expected/v1",
|
|
3
|
+
"fixture": "test/fixtures/explain.html",
|
|
4
|
+
"url": "http://127.0.0.1:8765/explain.html",
|
|
5
|
+
"title": "Secret Menu explain fixture",
|
|
6
|
+
"note": "Rows the popup shows for this fixture, in order. Generated from explain-tools.js with lib/explain.mjs; checked by test/fixture-expected.test.mjs.",
|
|
7
|
+
"counts": {
|
|
8
|
+
"tools": 14,
|
|
9
|
+
"safe": 6,
|
|
10
|
+
"view": 0,
|
|
11
|
+
"signIn": 1,
|
|
12
|
+
"changes": 4,
|
|
13
|
+
"spends": 3
|
|
14
|
+
},
|
|
15
|
+
"abilities": [
|
|
16
|
+
{
|
|
17
|
+
"title": "Search products, see product and see store hours (+2 more)",
|
|
18
|
+
"label": "safe",
|
|
19
|
+
"labelFrom": "mixed: 3 from site hint, 2 guessed",
|
|
20
|
+
"toolNames": [
|
|
21
|
+
"fetch_reviews",
|
|
22
|
+
"get_product",
|
|
23
|
+
"get_store_hours",
|
|
24
|
+
"get_wishlist",
|
|
25
|
+
"search_products"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"title": "Open help",
|
|
30
|
+
"label": "safe",
|
|
31
|
+
"labelFrom": "guessed",
|
|
32
|
+
"toolNames": [
|
|
33
|
+
"open_help"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"title": "See your orders",
|
|
38
|
+
"label": "needs sign-in",
|
|
39
|
+
"labelFrom": "guessed",
|
|
40
|
+
"toolNames": [
|
|
41
|
+
"list_my_orders"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"title": "Delete account, frobnicate and update profile",
|
|
46
|
+
"label": "changes data",
|
|
47
|
+
"labelFrom": "mixed: 1 from site hint, 2 guessed",
|
|
48
|
+
"toolNames": [
|
|
49
|
+
"delete_account",
|
|
50
|
+
"frobnicate",
|
|
51
|
+
"update_profile"
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"title": "Buy now, get gift card and place order",
|
|
56
|
+
"label": "spends money",
|
|
57
|
+
"labelFrom": "mixed: 1 from site hint, 2 guessed",
|
|
58
|
+
"toolNames": [
|
|
59
|
+
"buy_now",
|
|
60
|
+
"get_gift_card",
|
|
61
|
+
"place_order"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"title": "Sign-in demo",
|
|
66
|
+
"label": "changes data",
|
|
67
|
+
"labelFrom": "guessed",
|
|
68
|
+
"toolNames": [
|
|
69
|
+
"sign_in_demo"
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Pinned tool list for explain.html (the deterministic test fixture). Imported by the page and by
|
|
2
|
+
// test/fixture-expected.test.mjs, which checks it against explain-expected.json. Change both together.
|
|
3
|
+
const signedIn = () => localStorage.getItem("explain_user") === "demo";
|
|
4
|
+
const object = (properties = {}, required = []) => ({ type: "object", properties, required });
|
|
5
|
+
const ok = (x) => async () => x;
|
|
6
|
+
export const tools = [
|
|
7
|
+
// labeled read-only
|
|
8
|
+
{ name: "get_product", description: "Read one product by id.", inputSchema: object({ id: { type: "string", description: "Product id" } }, ["id"]), annotations: { readOnlyHint: true }, execute: ok({ id: "p1", name: "Tea" }) },
|
|
9
|
+
{ name: "fetch_reviews", description: "Customer reviews for a product (user-written text).", inputSchema: object({ id: { type: "string" } }, ["id"]), annotations: { readOnlyHint: true, untrustedContentHint: true }, execute: ok({ reviews: [] }) },
|
|
10
|
+
{ name: "get_wishlist", description: "Your saved items.", inputSchema: object(), annotations: { readOnlyHint: true }, execute: async () => signedIn() ? { items: ["tea"] } : { ok: false, error: "login_required" } },
|
|
11
|
+
// unlabeled reads
|
|
12
|
+
{ name: "search_products", description: "Search the catalog by keyword. Public, no login.", inputSchema: object({ q: { type: "string", description: "Keyword" } }, ["q"]), execute: ok({ results: [] }) },
|
|
13
|
+
{ name: "get_store_hours", description: "Hours.", inputSchema: object(), execute: ok({ open: "9-5" }) },
|
|
14
|
+
{ name: "list_my_orders", description: "Requires sign-in. Past orders with status.", inputSchema: object(), execute: ok({ orders: [] }) },
|
|
15
|
+
// unlabeled changes / spends
|
|
16
|
+
{ name: "update_profile", description: "Requires sign-in. Update the display name.", inputSchema: object({ display_name: { type: "string" } }, ["display_name"]), execute: ok({ ok: true }) },
|
|
17
|
+
{ name: "buy_now", description: "SENSITIVE: charges the card on file. Requires login and confirm true.", inputSchema: object({ product_id: { type: "string" }, confirm: { type: "boolean" } }, ["product_id", "confirm"]), execute: ok({ ok: false, error: "fixture never charges" }) },
|
|
18
|
+
{ name: "get_gift_card", description: "Issue a gift card.", inputSchema: object({ amount: { type: "integer" }, confirm: { type: "boolean" } }, ["amount", "confirm"]), execute: ok({ ok: false }) },
|
|
19
|
+
{ name: "open_help", description: "Navigate to the help page.", inputSchema: object(), execute: ok({ ok: true }) },
|
|
20
|
+
{ name: "frobnicate", description: "Does the thing.", inputSchema: object(), execute: ok({ ok: true }) },
|
|
21
|
+
{ name: "sign_in_demo", description: "Fake sign in for the demo shop.", inputSchema: object(), execute: async () => { localStorage.setItem("explain_user", "demo"); return { ok: true }; } },
|
|
22
|
+
// labeled changes
|
|
23
|
+
{ name: "place_order", description: "Place the order in the cart.", inputSchema: object({ confirm: { type: "boolean" } }, ["confirm"]), annotations: { readOnlyHint: false, consequentialHint: true }, execute: ok({ ok: false }) },
|
|
24
|
+
{ name: "delete_account", description: "Permanently delete the account.", inputSchema: object({ confirm: { type: "boolean" } }, ["confirm"]), annotations: { readOnlyHint: false, destructiveHint: true }, execute: ok({ ok: false }) },
|
|
25
|
+
];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>Secret Menu explain fixture</title>
|
|
4
|
+
<meta name="description" content="A pretend shop for testing secret-menu explain: search products, order, and manage an account.">
|
|
5
|
+
<meta property="og:site_name" content="Secret Menu Test Shop">
|
|
6
|
+
<meta property="og:type" content="website">
|
|
7
|
+
<h1>Secret Menu explain fixture</h1>
|
|
8
|
+
<p>Fake shop with labeled, unlabeled and spending tools. Nothing here is real.</p>
|
|
9
|
+
<button id="signin">Sign in (fake)</button>
|
|
10
|
+
<pre id="log"></pre>
|
|
11
|
+
<script type="module">
|
|
12
|
+
const mc = document.modelContext; const log = (s) => document.querySelector("#log").textContent += `${s}\n`;
|
|
13
|
+
if (!mc) { log("WebMCP unavailable"); throw new Error("document.modelContext missing"); }
|
|
14
|
+
const { tools } = await import("./explain-tools.js");
|
|
15
|
+
for (const t of tools) { try { await mc.registerTool(t); log(`registered ${t.name}`); } catch (e) { log(`FAILED ${t.name}: ${e.message}`); } }
|
|
16
|
+
document.querySelector("#signin").onclick = () => localStorage.setItem("explain_user", "demo");
|
|
17
|
+
</script>
|