@joe-broadhead/opencode-granola 0.0.1 → 0.0.2
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/CHANGELOG.md +55 -0
- package/README.md +39 -19
- package/dist/index.js +156 -78
- package/dist/tui.js +54 -28
- package/package.json +1 -1
- package/skills/granola/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,61 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
4
4
|
|
|
5
|
+
## [0.0.2] - 2026-11-04
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- OpenCode 2.2.5 is a tested release, next to 2.2.3 and 2.2.4.
|
|
10
|
+
- The status RPC (`granola.meetings` `status`) has `version`, the plugin's version, and `host.range` and
|
|
11
|
+
`host.tested`. A status it cannot read is now the RPC error `unavailable` with `STATUS_UNAVAILABLE` and its next
|
|
12
|
+
step, and the `/granola` dialog says so.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- The status RPC uses the field names every plugin of this family shares. Scripts that read it need these changes:
|
|
17
|
+
- `next` is now `nextStep`;
|
|
18
|
+
- `pluginVersion` is now `version`;
|
|
19
|
+
- `hostVersion` is now `host.version`, which is `null` when OpenCode reports no version;
|
|
20
|
+
- `state` uses the shared states: `pending` is now `connecting`, `needs_auth` is now `sign_in_required`, `absent`
|
|
21
|
+
is now `not_configured`, and the user's own `granola` server for another URL is now `conflict` (it was that
|
|
22
|
+
server's connection state, next to `conflict`);
|
|
23
|
+
- `problem` no longer has `next`: the one next step is `nextStep`;
|
|
24
|
+
- `tools.visible` counts every Granola tool the model can call, unknown ones included.
|
|
25
|
+
- On a release inside `>=2.2.3 <2.3.0` other than 2.2.3, 2.2.4 and 2.2.5, Granola still sets everything up, and only
|
|
26
|
+
the `OpenCode` row calls the version untested. The row reads, for example,
|
|
27
|
+
`2.2.5 (tested; supported range >=2.2.3 <2.3.0), plugin <version>`. On a host outside the range that runs because
|
|
28
|
+
of `allowUntestedHost`, the next step, when nothing else is needed, says to use a tested release if Granola
|
|
29
|
+
misbehaves.
|
|
30
|
+
- Status always shows the `Agents without default rules` row, with `none` when no agent needs the fix. It lists an
|
|
31
|
+
agent only when its own rules would let a Granola tool this plugin does not know run without asking; an agent
|
|
32
|
+
whose rules already ask or deny for every Granola tool is not listed.
|
|
33
|
+
- The sign-in next step says to press `o` or Enter to open the sign-in page. A disabled server's next step offers
|
|
34
|
+
`/mcps` first. A missing server's next step points at an organisation policy (`mcp.use`) or another plugin that
|
|
35
|
+
removes it, not at the plugins list.
|
|
36
|
+
- The tools RPC's `describe` takes no input: anything but `{}` or nothing now gets `describe takes no input`. An
|
|
37
|
+
envelope that is not an object gets `the envelope must be an object, such as {…}`.
|
|
38
|
+
- A refused permission check now names the action that was refused, when it is not the method's own.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- A failed connection's reason no longer ends in doubled punctuation, as in `…access the url?.`.
|
|
43
|
+
- The README's sign-in check quoted a status line that does not exist. It now says the `Connection` row shows
|
|
44
|
+
`connected` and the `Tools` row how many Granola tools are available.
|
|
45
|
+
- The README said Explore is left unchanged. Explore and Title also get the block; their own later deny rules still
|
|
46
|
+
keep Granola's tools out.
|
|
47
|
+
- The README's Uninstall now says why to sign out first: once the plugin is removed, `opencode mcp logout granola`
|
|
48
|
+
answers "MCP server not found" and the sign-in stays in OpenCode's credential store. It signs out, checks with
|
|
49
|
+
`opencode auth list`, then removes the plugin, reloads, and checks with `opencode mcp list`.
|
|
50
|
+
- The README now says what to do after `opencode plugin add` (run `opencode reload`, then `/granola-status`), how to
|
|
51
|
+
update the plugin, and, under Troubleshooting, that Granola's free plan returns only the last 30 days of meetings.
|
|
52
|
+
- `docs/scripting.md` said `opencode api` runs in the directory you call it from. It runs in the OpenCode service's
|
|
53
|
+
default location, your home directory; the doc now says how to target a project. It also says that `opencode api`
|
|
54
|
+
exits with status 1 for an RPC error, and that a result with only text has that text as its `output`.
|
|
55
|
+
- The `granola` skill now names the `folder_id` filter of `search_meetings` and `list_meetings`.
|
|
56
|
+
- A script's call that found Granola disconnected, while Granola reads connected again by the time the plugin looks,
|
|
57
|
+
now gets `CONNECTING` and "Retry in a few seconds.", as the model does. It got `CONNECTION_FAILED` and a reconnect
|
|
58
|
+
step before.
|
|
59
|
+
|
|
5
60
|
## [0.0.1] - 2026-11-03
|
|
6
61
|
|
|
7
62
|
### Added
|
package/README.md
CHANGED
|
@@ -7,7 +7,8 @@ Unofficial; not affiliated with Granola.
|
|
|
7
7
|
|
|
8
8
|
## Requirements
|
|
9
9
|
|
|
10
|
-
- OpenCode `>=2.2.3 <2.3.0`, tested on 2.2.3 and 2.2.
|
|
10
|
+
- OpenCode `>=2.2.3 <2.3.0`, tested on 2.2.3, 2.2.4 and 2.2.5. Another release inside that range runs, and status
|
|
11
|
+
calls it untested. Outside the range the plugin does not start, unless you set `allowUntestedHost`; see
|
|
11
12
|
[Troubleshooting](#troubleshooting).
|
|
12
13
|
- macOS or Linux.
|
|
13
14
|
- A Granola account. Granola's free plan includes the last 30 days of meeting history; transcripts and folders are
|
|
@@ -19,13 +20,19 @@ Unofficial; not affiliated with Granola.
|
|
|
19
20
|
opencode plugin add @joe-broadhead/opencode-granola
|
|
20
21
|
```
|
|
21
22
|
|
|
22
|
-
This adds the plugin to your global OpenCode config.
|
|
23
|
+
This adds the plugin to your global OpenCode config. Run `opencode reload`, or restart OpenCode. Then run
|
|
24
|
+
`/granola-status`: it shows `sign-in required` until you sign in.
|
|
25
|
+
|
|
26
|
+
**Update** with `opencode plugin update @joe-broadhead/opencode-granola`, then run `opencode reload`. An entry pinned to
|
|
27
|
+
a version, such as `@joe-broadhead/opencode-granola@0.0.1`, does not update: remove the `@version` from its `plugins`
|
|
28
|
+
entry first.
|
|
23
29
|
|
|
24
30
|
## Sign in
|
|
25
31
|
|
|
26
32
|
1. Run `/connect` and choose **Granola**, listed under MCP.
|
|
27
33
|
2. Press `o` or Enter to open Granola's sign-in page in your browser, then approve there.
|
|
28
|
-
3. Run `/granola-status`.
|
|
34
|
+
3. Run `/granola-status`. Its `Connection` row shows `connected`, and its `Tools` row shows how many Granola tools
|
|
35
|
+
are available.
|
|
29
36
|
|
|
30
37
|
On the CLI, `opencode mcp auth granola` opens the sign-in page automatically. `/mcps` works too. There is
|
|
31
38
|
nothing to configure first: Granola registers OpenCode's sign-in itself.
|
|
@@ -84,8 +91,9 @@ See [Scripting](https://github.com/joe-broadhead/opencode-granola/blob/HEAD/docs
|
|
|
84
91
|
| Hidden | `sign_out` | removed; use `opencode mcp logout granola` |
|
|
85
92
|
|
|
86
93
|
The plugin inserts these rules into each agent, directly after the agent's base "allow everything" rule. Your own
|
|
87
|
-
`permissions` and your saved "Always allow" answers override them, and a `deny` you configure always wins.
|
|
88
|
-
|
|
94
|
+
`permissions` and your saved "Always allow" answers override them, and a `deny` you configure always wins. Explore
|
|
95
|
+
and Title also get the block, but their own later deny rules still keep Granola tools out, and Plan keeps its
|
|
96
|
+
restrictions. An agent without a base "allow everything" rule gets no rules.
|
|
89
97
|
|
|
90
98
|
For example, to let one write run without asking:
|
|
91
99
|
|
|
@@ -99,7 +107,8 @@ For example, to let one write run without asking:
|
|
|
99
107
|
|
|
100
108
|
Agents defined only in your own config are created after plugins start, so they miss the default rules, and Granola's
|
|
101
109
|
writes would run there without asking. Status names each such agent and gives the block to paste first in that agent's
|
|
102
|
-
own `permissions`.
|
|
110
|
+
own `permissions`. An agent whose own rules already ask or deny for every Granola tool needs nothing and is not named.
|
|
111
|
+
Do not put the block in the top-level `permissions`, which would loosen Explore.
|
|
103
112
|
|
|
104
113
|
In `opencode run` and other runs where nobody can answer, a call that would ask is refused; reads still work.
|
|
105
114
|
|
|
@@ -132,26 +141,33 @@ Each member then signs in once with `/connect` → Granola. A managed `mcp.serve
|
|
|
132
141
|
|
|
133
142
|
## Troubleshooting
|
|
134
143
|
|
|
135
|
-
|
|
144
|
+
Run `/granola-status` (or `/granola` in the terminal UI) first. It shows the rows `Connection`, `Account`, `Access`,
|
|
145
|
+
`Export folder`, `Tools`, `Agents without default rules` (`none` when no agent needs the fix), `OpenCode` and exactly
|
|
146
|
+
one `Next step`. Each connection state:
|
|
136
147
|
|
|
137
148
|
| Status | Code | What to do |
|
|
138
149
|
| --- | --- | --- |
|
|
139
|
-
| not configured | `NOT_REGISTERED` | Retry in a few seconds; if it persists, check that
|
|
150
|
+
| not configured | `NOT_REGISTERED` | Retry in a few seconds; if it persists, check that no organisation policy (`mcp.use`) or other plugin removes the `granola` server, then restart OpenCode. |
|
|
140
151
|
| connecting | `CONNECTING` | Retry in a few seconds. |
|
|
141
|
-
| sign-in required | `AUTH_REQUIRED` | Run `/connect` and choose Granola, or
|
|
152
|
+
| sign-in required | `AUTH_REQUIRED` | Run `/connect` and choose Granola, then press `o` or Enter to open the sign-in page. In a terminal, `opencode mcp auth granola` opens it automatically. |
|
|
142
153
|
| failed | `CONNECTION_FAILED` | Reconnect Granola from `/mcps`, and if it keeps failing, run `/connect` and choose Granola to sign in again. |
|
|
143
|
-
| disabled | `DISABLED` |
|
|
154
|
+
| disabled | `DISABLED` | Enable `granola` in `/mcps`, or remove `"disabled": true` from `mcp.servers.granola` in your OpenCode config. |
|
|
144
155
|
| conflict | none | Remove `mcp.servers.granola` from your OpenCode config to use Granola's official server. |
|
|
156
|
+
| connected | none | Ready. The next step reads `Nothing to do.`, or names agents without default rules. |
|
|
145
157
|
|
|
146
158
|
**`MCP server not found: granola` from `opencode mcp auth granola`, right after OpenCode starts.** Plugins are still
|
|
147
159
|
loading for a few seconds. Run it again, or use `/connect`, which updates live.
|
|
148
160
|
|
|
161
|
+
**Older meetings are missing.** On Granola's free plan, Granola's server returns only the meetings of the last 30 days.
|
|
162
|
+
|
|
149
163
|
**A tool says your Granola plan does not include it, or `TOOL_UNAVAILABLE`.** Granola's free plan has no
|
|
150
|
-
transcripts or folders
|
|
164
|
+
transcripts or folders.
|
|
151
165
|
|
|
152
|
-
**`HOST_UNTESTED`.** The plugin
|
|
153
|
-
development builds. Use
|
|
154
|
-
|
|
166
|
+
**`HOST_UNTESTED`.** The plugin does not start on an OpenCode version outside `>=2.2.3 <2.3.0`, including
|
|
167
|
+
development builds. Use OpenCode `>=2.2.3 <2.3.0`, or set the plugin option `allowUntestedHost: true` to run anyway;
|
|
168
|
+
the `OpenCode` row then says `host untested`, and when nothing else is needed the next step says to use a tested
|
|
169
|
+
release if Granola misbehaves. A release inside the range other than 2.2.3, 2.2.4 and 2.2.5 runs normally: only the
|
|
170
|
+
`OpenCode` row calls it untested.
|
|
155
171
|
|
|
156
172
|
**`OPTIONS_INVALID`.** A plugin option has an unknown name or a wrong type. The message names the option.
|
|
157
173
|
|
|
@@ -168,12 +184,16 @@ supports one Granola account.
|
|
|
168
184
|
|
|
169
185
|
## Uninstall
|
|
170
186
|
|
|
171
|
-
|
|
172
|
-
opencode
|
|
173
|
-
|
|
174
|
-
|
|
187
|
+
Sign out while the plugin is still installed: once it is removed, OpenCode no longer knows the `granola` server, so
|
|
188
|
+
it cannot remove its sign-in, and `opencode auth list` stops showing it.
|
|
189
|
+
|
|
190
|
+
1. Sign out of Granola: `opencode mcp logout granola`.
|
|
191
|
+
2. Check: `opencode auth list` no longer lists **Granola**.
|
|
192
|
+
3. Remove the plugin: `opencode plugin remove @joe-broadhead/opencode-granola`, then `opencode reload`.
|
|
193
|
+
`opencode mcp list` no longer lists `granola`.
|
|
194
|
+
4. Remove any Granola rules you copied into your agents' own `permissions`.
|
|
175
195
|
|
|
176
|
-
|
|
196
|
+
Files you exported stay in your export folder.
|
|
177
197
|
|
|
178
198
|
## Links
|
|
179
199
|
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { Rpc as Rpc2 } from "@opencode/plugin/rpc";
|
|
|
7
7
|
|
|
8
8
|
// src/errors.ts
|
|
9
9
|
import { Error as ToolError } from "@opencode/plugin/promise/tool";
|
|
10
|
-
var SIGN_IN_STEPS = "Run /connect and choose Granola, or
|
|
10
|
+
var SIGN_IN_STEPS = "Run /connect and choose Granola, then press o or Enter to open the sign-in page (in a terminal, opencode mcp auth granola opens it automatically).";
|
|
11
11
|
function describe(problem) {
|
|
12
12
|
return `${problem.code}: ${problem.message} Next step: ${problem.next}`;
|
|
13
13
|
}
|
|
@@ -29,6 +29,7 @@ function shortReason(text3) {
|
|
|
29
29
|
if (!reason) return "no reason given";
|
|
30
30
|
return reason.length > 160 ? `${reason.slice(0, 157)}...` : reason;
|
|
31
31
|
}
|
|
32
|
+
var sentence = (reason) => /[.?!]$/.test(reason) ? reason : `${reason}.`;
|
|
32
33
|
function connectionProblem(state, error) {
|
|
33
34
|
switch (state) {
|
|
34
35
|
case "connected":
|
|
@@ -37,7 +38,7 @@ function connectionProblem(state, error) {
|
|
|
37
38
|
return {
|
|
38
39
|
code: "NOT_REGISTERED",
|
|
39
40
|
message: "The Granola connection isn't registered yet.",
|
|
40
|
-
next: "Retry in a few seconds; if it persists, check that
|
|
41
|
+
next: "Retry in a few seconds; if it persists, check that no organisation policy (mcp.use) or other plugin removes the granola server, then restart OpenCode."
|
|
41
42
|
};
|
|
42
43
|
case "pending":
|
|
43
44
|
return { code: "CONNECTING", message: "Granola is still connecting.", next: "Retry in a few seconds." };
|
|
@@ -46,14 +47,14 @@ function connectionProblem(state, error) {
|
|
|
46
47
|
case "failed":
|
|
47
48
|
return {
|
|
48
49
|
code: "CONNECTION_FAILED",
|
|
49
|
-
message: `Granola connection failed: ${shortReason(error)}
|
|
50
|
+
message: `Granola connection failed: ${sentence(shortReason(error))}`,
|
|
50
51
|
next: "Reconnect Granola from /mcps, and if it keeps failing, run /connect and choose Granola to sign in again."
|
|
51
52
|
};
|
|
52
53
|
case "disabled":
|
|
53
54
|
return {
|
|
54
55
|
code: "DISABLED",
|
|
55
|
-
message: "Granola is disabled in
|
|
56
|
-
next: "
|
|
56
|
+
message: "Granola is disabled in OpenCode.",
|
|
57
|
+
next: 'Enable granola in /mcps, or remove "disabled": true from mcp.servers.granola in your OpenCode config.'
|
|
57
58
|
};
|
|
58
59
|
}
|
|
59
60
|
}
|
|
@@ -76,11 +77,11 @@ function invalidOption(key, problem) {
|
|
|
76
77
|
next: "Fix the Granola plugin options in your OpenCode config, then reload OpenCode."
|
|
77
78
|
};
|
|
78
79
|
}
|
|
79
|
-
function hostUntested(
|
|
80
|
+
function hostUntested(range, found) {
|
|
80
81
|
return {
|
|
81
82
|
code: "HOST_UNTESTED",
|
|
82
|
-
message: `The Granola plugin
|
|
83
|
-
next:
|
|
83
|
+
message: `The Granola plugin supports OpenCode ${range}, found ${found || "no version"}.`,
|
|
84
|
+
next: `Use OpenCode ${range}, or set the plugin option allowUntestedHost to true to run anyway.`
|
|
84
85
|
};
|
|
85
86
|
}
|
|
86
87
|
function skillsInvalid(path3) {
|
|
@@ -142,7 +143,7 @@ function readPackageInfo(url = new URL("../package.json", import.meta.url)) {
|
|
|
142
143
|
if (!record4(raw) || typeof raw.version !== "string" || !record4(raw.opencode) || typeof raw.opencode.host !== "string") {
|
|
143
144
|
throw new GranolaError(packageInvalid("needs a version and an opencode.host range"));
|
|
144
145
|
}
|
|
145
|
-
return { version: raw.version,
|
|
146
|
+
return { version: raw.version, hostRange: raw.opencode.host };
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
// src/permissions.ts
|
|
@@ -169,15 +170,12 @@ function matches(input, pattern) {
|
|
|
169
170
|
const source = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
170
171
|
return new RegExp(`^${source}$`, "s").test(input);
|
|
171
172
|
}
|
|
172
|
-
function
|
|
173
|
-
return rules.findLast((rule) => matches(action, rule.action) && matches("*", rule.resource));
|
|
173
|
+
function evaluate(rules, action) {
|
|
174
|
+
return rules.findLast((rule) => matches(action, rule.action) && matches("*", rule.resource))?.effect ?? "ask";
|
|
174
175
|
}
|
|
175
|
-
var
|
|
176
|
+
var UNKNOWN_TOOL = toolID("unknown_tool");
|
|
176
177
|
function unguardedAgents(agents) {
|
|
177
|
-
return agents.filter((agent) => !hasDefaults(agent.permissions)
|
|
178
|
-
const rule = winner(action, agent.permissions);
|
|
179
|
-
return rule?.effect === "allow" && !rule.action.startsWith("granola");
|
|
180
|
-
})).map((agent) => agent.id);
|
|
178
|
+
return agents.filter((agent) => agent.permissions.some(isBaseAllow) && !hasDefaults(agent.permissions) && evaluate(agent.permissions, UNKNOWN_TOOL) === "allow").map((agent) => agent.id);
|
|
181
179
|
}
|
|
182
180
|
async function registerPermissions(ctx) {
|
|
183
181
|
await ctx.agent.transform((editor) => {
|
|
@@ -187,18 +185,34 @@ async function registerPermissions(ctx) {
|
|
|
187
185
|
|
|
188
186
|
// src/rpc/contract.ts
|
|
189
187
|
import { Rpc } from "@opencode/plugin/rpc";
|
|
188
|
+
var STATES = ["connected", "sign_in_required", "connecting", "disabled", "failed", "not_configured", "conflict"];
|
|
189
|
+
var STATE_OF = {
|
|
190
|
+
connected: "connected",
|
|
191
|
+
pending: "connecting",
|
|
192
|
+
needs_auth: "sign_in_required",
|
|
193
|
+
failed: "failed",
|
|
194
|
+
disabled: "disabled",
|
|
195
|
+
absent: "not_configured"
|
|
196
|
+
};
|
|
190
197
|
var STATUS_SCHEMA = {
|
|
191
198
|
type: "object",
|
|
192
199
|
additionalProperties: false,
|
|
193
|
-
required: ["
|
|
200
|
+
required: ["version", "host", "state", "tools", "unguardedAgents", "exportDirectory", "nextStep"],
|
|
194
201
|
properties: {
|
|
195
|
-
|
|
202
|
+
version: { type: "string" },
|
|
203
|
+
host: {
|
|
204
|
+
type: "object",
|
|
205
|
+
additionalProperties: false,
|
|
206
|
+
required: ["version", "range", "tested"],
|
|
207
|
+
properties: { version: { type: ["string", "null"] }, range: { type: "string" }, tested: { type: "boolean" } }
|
|
208
|
+
},
|
|
209
|
+
state: { type: "string", enum: STATES },
|
|
196
210
|
title: { type: "string" },
|
|
197
211
|
problem: {
|
|
198
212
|
type: "object",
|
|
199
213
|
additionalProperties: false,
|
|
200
|
-
required: ["code", "message"
|
|
201
|
-
properties: { code: { type: "string" }, message: { type: "string" }
|
|
214
|
+
required: ["code", "message"],
|
|
215
|
+
properties: { code: { type: "string" }, message: { type: "string" } }
|
|
202
216
|
},
|
|
203
217
|
conflict: { type: "string" },
|
|
204
218
|
tools: {
|
|
@@ -228,31 +242,43 @@ var STATUS_SCHEMA = {
|
|
|
228
242
|
}
|
|
229
243
|
},
|
|
230
244
|
exportDirectory: { type: "string" },
|
|
231
|
-
pluginVersion: { type: "string" },
|
|
232
|
-
hostVersion: { type: "string" },
|
|
233
245
|
hostWarning: { type: "string" },
|
|
234
|
-
|
|
246
|
+
nextStep: { type: "string" }
|
|
235
247
|
}
|
|
236
248
|
};
|
|
249
|
+
var STATUS_ERROR_SCHEMA = {
|
|
250
|
+
type: "object",
|
|
251
|
+
additionalProperties: false,
|
|
252
|
+
required: ["code", "next"],
|
|
253
|
+
properties: { code: { type: "string" }, next: { type: "string" } }
|
|
254
|
+
};
|
|
237
255
|
var STATE_LABELS = {
|
|
238
256
|
connected: "connected",
|
|
239
|
-
|
|
240
|
-
|
|
257
|
+
connecting: "connecting",
|
|
258
|
+
sign_in_required: "sign-in required",
|
|
241
259
|
failed: "failed",
|
|
242
260
|
disabled: "disabled",
|
|
243
|
-
|
|
261
|
+
not_configured: "not configured",
|
|
262
|
+
conflict: "conflict"
|
|
244
263
|
};
|
|
245
264
|
var TONES = {
|
|
246
265
|
connected: "ok",
|
|
247
|
-
|
|
248
|
-
|
|
266
|
+
connecting: "info",
|
|
267
|
+
sign_in_required: "warning",
|
|
249
268
|
failed: "error",
|
|
250
269
|
disabled: "info",
|
|
251
|
-
|
|
270
|
+
not_configured: "warning",
|
|
271
|
+
conflict: "warning"
|
|
252
272
|
};
|
|
273
|
+
function hostRow(status) {
|
|
274
|
+
const { version, range, tested } = status.host;
|
|
275
|
+
const found = version ?? "unknown";
|
|
276
|
+
if (status.hostWarning) return { label: "OpenCode", text: `${found} (host untested: ${status.hostWarning}), plugin ${status.version}`, tone: "warning" };
|
|
277
|
+
return { label: "OpenCode", text: `${found} (${tested ? "tested" : "untested"}; supported range ${range}), plugin ${status.version}`, ...tested ? {} : { tone: "info" } };
|
|
278
|
+
}
|
|
253
279
|
function statusRows(status) {
|
|
254
280
|
const connected = status.state === "connected";
|
|
255
|
-
const connection2 = status.conflict ? `conflict: ${status.conflict}` : `${STATE_LABELS[status.state]}${status.title ? ` (${status.title})` : ""}${status.problem ? `: ${status.problem.message} (${status.problem.code})` : ""}`;
|
|
281
|
+
const connection2 = status.state === "conflict" && status.conflict ? `conflict: ${status.conflict}` : `${STATE_LABELS[status.state]}${status.title ? ` (${status.title})` : ""}${status.problem ? `: ${status.problem.message} (${status.problem.code})` : ""}`;
|
|
256
282
|
const tools = [`${status.tools.visible} available`];
|
|
257
283
|
if (status.tools.hidden) tools.push(`${status.tools.hidden} hidden`);
|
|
258
284
|
if (status.tools.unknown) {
|
|
@@ -261,18 +287,18 @@ function statusRows(status) {
|
|
|
261
287
|
}
|
|
262
288
|
const agents = status.unguardedAgents;
|
|
263
289
|
return [
|
|
264
|
-
{ label: "Connection", text: connection2, tone:
|
|
290
|
+
{ label: "Connection", text: connection2, tone: TONES[status.state] },
|
|
265
291
|
{ label: "Account", text: !connected ? "unknown until Granola connects" : status.workspace ? `workspace ${status.workspace}` : "workspace unknown until the agent reads your Granola account" },
|
|
266
292
|
{ label: "Access", text: "reads run without asking; changes, new tools and exports ask first" },
|
|
267
293
|
{ label: "Export folder", text: status.exportDirectory },
|
|
268
294
|
{ label: "Tools", text: connected ? tools.join(", ") : "none until Granola connects" },
|
|
269
|
-
|
|
295
|
+
agents.length ? {
|
|
270
296
|
label: "Agents without default rules",
|
|
271
|
-
text: `${agents.join(", ")}: Granola
|
|
297
|
+
text: `${agents.join(", ")}: Granola changes and new tools may run there without asking`,
|
|
272
298
|
tone: "warning",
|
|
273
299
|
block: permissionsSnippet(agents, status.permissionsBlock ?? [])
|
|
274
|
-
}
|
|
275
|
-
|
|
300
|
+
} : { label: "Agents without default rules", text: "none" },
|
|
301
|
+
hostRow(status)
|
|
276
302
|
];
|
|
277
303
|
}
|
|
278
304
|
function permissionsSnippet(agents, block2) {
|
|
@@ -294,34 +320,41 @@ var Granola = Rpc.define({
|
|
|
294
320
|
methods: {
|
|
295
321
|
status: {
|
|
296
322
|
input: { type: "object", properties: {}, additionalProperties: false },
|
|
297
|
-
output: STATUS_SCHEMA
|
|
323
|
+
output: STATUS_SCHEMA,
|
|
324
|
+
errors: { unavailable: STATUS_ERROR_SCHEMA }
|
|
298
325
|
}
|
|
299
326
|
},
|
|
300
327
|
events: {}
|
|
301
328
|
});
|
|
302
329
|
|
|
330
|
+
// src/hosts.ts
|
|
331
|
+
var TESTED_HOSTS = ["2.2.3", "2.2.4", "2.2.5"];
|
|
332
|
+
|
|
303
333
|
// src/status.ts
|
|
304
334
|
var LISTED_UNKNOWN = 20;
|
|
305
335
|
var SAFE_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
306
336
|
var CONFLICT_NEXT = "Remove mcp.servers.granola from your OpenCode config to use Granola's official server.";
|
|
307
337
|
var NOTHING_TO_DO = "Nothing to do.";
|
|
338
|
+
var UNTESTED_HOST_NEXT = `If Granola misbehaves, use OpenCode ${TESTED_HOSTS.slice(0, -1).join(", ")} or ${TESTED_HOSTS.at(-1)}.`;
|
|
308
339
|
function agentFix(agents) {
|
|
309
340
|
const where = agents.map((agent) => `agents.${agent}.permissions`).join(", ");
|
|
310
341
|
return `Put the Granola block first in that agent's own permissions (${where}), followed by your top-level rules for granola_ actions.`;
|
|
311
342
|
}
|
|
312
343
|
function buildStatus(inputs) {
|
|
313
344
|
const server = inputs.servers.find((entry) => entry.name === SERVER);
|
|
314
|
-
const
|
|
315
|
-
const problem = connectionProblem(
|
|
345
|
+
const connection2 = server?.status.status ?? "absent";
|
|
346
|
+
const problem = inputs.conflict ? void 0 : connectionProblem(connection2, server?.status.error);
|
|
316
347
|
const classes = inputs.tools.map((tool) => ({ id: tool.id, kind: classify(tool.id) }));
|
|
317
348
|
const unknown = classes.filter((tool) => tool.kind === "unknown").map((tool) => tool.id.slice(SERVER.length + 1));
|
|
318
|
-
const visible = classes.filter((tool) => tool.kind === "read" || tool.kind === "write").length;
|
|
349
|
+
const visible = classes.filter((tool) => tool.kind === "read" || tool.kind === "write" || tool.kind === "unknown").length;
|
|
319
350
|
const unguarded = unguardedAgents(inputs.agents);
|
|
320
|
-
const
|
|
351
|
+
const nextStep = (inputs.conflict ? CONFLICT_NEXT : void 0) ?? problem?.next ?? (unguarded.length ? agentFix(unguarded) : void 0) ?? (inputs.host.warning ? UNTESTED_HOST_NEXT : void 0) ?? NOTHING_TO_DO;
|
|
321
352
|
return {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
353
|
+
version: inputs.version,
|
|
354
|
+
host: { version: inputs.host.version || null, range: inputs.host.range, tested: inputs.host.tested },
|
|
355
|
+
state: inputs.conflict ? "conflict" : STATE_OF[connection2],
|
|
356
|
+
...connection2 === "connected" && !inputs.conflict && server?.title ? { title: server.title } : {},
|
|
357
|
+
...problem ? { problem: { code: problem.code, message: problem.message } } : {},
|
|
325
358
|
...inputs.conflict ? { conflict: inputs.conflict } : {},
|
|
326
359
|
tools: {
|
|
327
360
|
visible,
|
|
@@ -333,10 +366,8 @@ function buildStatus(inputs) {
|
|
|
333
366
|
unguardedAgents: unguarded,
|
|
334
367
|
...unguarded.length ? { permissionsBlock: DEFAULT_RULES.map((rule) => ({ ...rule })) } : {},
|
|
335
368
|
exportDirectory: inputs.exportDirectory,
|
|
336
|
-
pluginVersion: inputs.pluginVersion,
|
|
337
|
-
hostVersion: inputs.host.version,
|
|
338
369
|
...inputs.host.warning ? { hostWarning: inputs.host.warning } : {},
|
|
339
|
-
|
|
370
|
+
nextStep
|
|
340
371
|
};
|
|
341
372
|
}
|
|
342
373
|
function formatStatus(status) {
|
|
@@ -345,7 +376,7 @@ function formatStatus(status) {
|
|
|
345
376
|
lines.push(`${row.label}: ${row.text}`);
|
|
346
377
|
for (const line of row.block ?? []) lines.push(` ${line}`);
|
|
347
378
|
}
|
|
348
|
-
lines.push(`Next step: ${status.
|
|
379
|
+
lines.push(`Next step: ${status.nextStep}`);
|
|
349
380
|
return lines.join("\n");
|
|
350
381
|
}
|
|
351
382
|
function createStatus(ctx, fixed) {
|
|
@@ -486,14 +517,27 @@ var DESCRIPTION = object({ id: STRING, call: STRING, location: STRING, methods:
|
|
|
486
517
|
"methods"
|
|
487
518
|
]);
|
|
488
519
|
var plainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
520
|
+
var ENVELOPE_MESSAGES = {
|
|
521
|
+
notObject: `the envelope must be an object, such as {"sessionID":"<session>","input":<the tool's input>}`,
|
|
522
|
+
unknownKey: (key) => `"${key}" is not allowed`,
|
|
523
|
+
sessionID: `"sessionID" must be a non-empty string`,
|
|
524
|
+
account: `"account" must be a non-empty string`,
|
|
525
|
+
inputMissing: `"input" is missing`,
|
|
526
|
+
inputNotObject: `"input" must be an object`,
|
|
527
|
+
describe: "describe takes no input"
|
|
528
|
+
};
|
|
529
|
+
var standard = (schema, validate) => Object.defineProperty({ ...schema }, "~standard", { value: { version: 1, vendor: "tools-rpc", validate }, enumerable: false });
|
|
530
|
+
var DESCRIBE_INPUT = standard(
|
|
531
|
+
NO_INPUT,
|
|
532
|
+
(value) => value === void 0 || value === null || plainObject(value) && Object.keys(value).length === 0 ? { value } : { issues: [{ message: ENVELOPE_MESSAGES.describe }] }
|
|
533
|
+
);
|
|
489
534
|
function strict(schema, issues) {
|
|
490
535
|
const keys = Object.keys(schema["properties"]);
|
|
491
|
-
|
|
492
|
-
if (!plainObject(value)) return { issues: [{ message:
|
|
493
|
-
const found = [...Object.keys(value).filter((key) => !keys.includes(key)).map(
|
|
536
|
+
return standard(schema, (value) => {
|
|
537
|
+
if (!plainObject(value)) return { issues: [{ message: ENVELOPE_MESSAGES.notObject }] };
|
|
538
|
+
const found = [...Object.keys(value).filter((key) => !keys.includes(key)).map(ENVELOPE_MESSAGES.unknownKey), ...issues(value)];
|
|
494
539
|
return found.length === 0 ? { value } : { issues: found.map((message) => ({ message })) };
|
|
495
|
-
};
|
|
496
|
-
return Object.defineProperty({ ...schema }, "~standard", { value: { version: 1, vendor: "tools-rpc", validate }, enumerable: false });
|
|
540
|
+
});
|
|
497
541
|
}
|
|
498
542
|
var ScriptError = class extends Error {
|
|
499
543
|
failure;
|
|
@@ -510,6 +554,10 @@ function notFound(error, tags) {
|
|
|
510
554
|
return typeof tag === "string" && tags.includes(tag) || error["status"] === 404 || error["statusCode"] === 404;
|
|
511
555
|
}
|
|
512
556
|
var toolError2 = (error) => record2(error) && error["_tag"] === "Tool.Error";
|
|
557
|
+
var requestAction = (cause) => {
|
|
558
|
+
const request = record2(cause) ? cause["request"] : void 0;
|
|
559
|
+
return record2(request) && typeof request["action"] === "string" && request["action"] !== "" ? request["action"] : void 0;
|
|
560
|
+
};
|
|
513
561
|
var wildcard = (pattern, value) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`).test(value);
|
|
514
562
|
var hidden = (rules, action) => rules.findLast((rule) => wildcard(rule.action, action) && wildcard(rule.resource, "*"))?.effect === "deny";
|
|
515
563
|
var SECRETS = [
|
|
@@ -565,6 +613,7 @@ async function registerToolsRpc(ctx, options) {
|
|
|
565
613
|
const duplicate = names.find((name, index) => name === "describe" || names.indexOf(name) !== index);
|
|
566
614
|
if (duplicate) throw new Error(`registerToolsRpc: the method name "${duplicate}" is taken`);
|
|
567
615
|
const live = /* @__PURE__ */ new Set();
|
|
616
|
+
const refused = /* @__PURE__ */ new Map();
|
|
568
617
|
const serverOf = (account) => {
|
|
569
618
|
const server = typeof mcp.server === "function" ? mcp.server(account) : mcp.server;
|
|
570
619
|
if (server !== void 0) return server;
|
|
@@ -593,11 +642,17 @@ async function registerToolsRpc(ctx, options) {
|
|
|
593
642
|
const status = (await ctx.mcp.list()).data.find((entry) => entry.name === server)?.status.status;
|
|
594
643
|
const code2 = connection(status) ?? connected;
|
|
595
644
|
if (code2 === "TOOL_UNAVAILABLE") return fail(code2, `OpenCode does not list ${tool} on the connected "${server}" server`);
|
|
645
|
+
if (status === "connected") return fail(code2, `the "${server}" server was reconnecting when ${tool} ran`);
|
|
596
646
|
return fail(code2, `the "${server}" server ${STATUS_TEXT[code2] ?? "is unavailable"}`);
|
|
597
647
|
};
|
|
598
|
-
const denied = (action, error) => {
|
|
599
|
-
const reason = record2(error) && typeof error["message"] === "string" ? error["message"] : String(error);
|
|
600
|
-
return fail("PERMISSION_DENIED", `${action} was refused for this session's agent: ${
|
|
648
|
+
const denied = (action, error, asked = action) => {
|
|
649
|
+
const reason = upstreamText(record2(error) && typeof error["message"] === "string" ? error["message"] : String(error));
|
|
650
|
+
if (asked === action) return fail("PERMISSION_DENIED", `${action} was refused for this session's agent: ${reason}`);
|
|
651
|
+
return new ScriptError({
|
|
652
|
+
code: "PERMISSION_DENIED",
|
|
653
|
+
what: `${action} needs ${asked}, which was refused for this session's agent: ${reason}`,
|
|
654
|
+
next: `Add an allow rule for ${asked} to the session's agent or to the session, or use a session whose agent allows it.`
|
|
655
|
+
});
|
|
601
656
|
};
|
|
602
657
|
const mcpTarget = (name, envelope2) => {
|
|
603
658
|
const server = serverOf(envelope2.account);
|
|
@@ -613,7 +668,7 @@ async function registerToolsRpc(ctx, options) {
|
|
|
613
668
|
try {
|
|
614
669
|
result = await tool.execute(envelope2.input, call);
|
|
615
670
|
} catch (error) {
|
|
616
|
-
if (error instanceof Error && error.message.startsWith(`MCP server "${server}" `)) throw await unavailable(server, action, "
|
|
671
|
+
if (error instanceof Error && error.message.startsWith(`MCP server "${server}" `)) throw await unavailable(server, action, "CONNECTING");
|
|
617
672
|
throw error;
|
|
618
673
|
}
|
|
619
674
|
return shapeResult(name, normalise(result));
|
|
@@ -628,16 +683,18 @@ async function registerToolsRpc(ctx, options) {
|
|
|
628
683
|
const invoke = async (target, raw, context) => {
|
|
629
684
|
const envelope2 = raw;
|
|
630
685
|
let method;
|
|
686
|
+
let id;
|
|
631
687
|
try {
|
|
632
688
|
if (context.signal.aborted) throw fail("CANCELLED", "the call was cancelled");
|
|
633
689
|
method = target(envelope2);
|
|
634
690
|
const { agent, rules } = await session(envelope2.sessionID);
|
|
635
691
|
if (hidden(rules, method.action)) throw fail("PERMISSION_DENIED", `${method.action} is hidden from this session's agent by a deny rule`);
|
|
692
|
+
id = `script:${randomUUID()}`;
|
|
636
693
|
const call = {
|
|
637
694
|
sessionID: envelope2.sessionID,
|
|
638
695
|
agent,
|
|
639
696
|
messageID: MESSAGE_ID,
|
|
640
|
-
id
|
|
697
|
+
id,
|
|
641
698
|
signal: context.signal,
|
|
642
699
|
progress: async () => {
|
|
643
700
|
}
|
|
@@ -649,17 +706,19 @@ async function registerToolsRpc(ctx, options) {
|
|
|
649
706
|
live.delete(call.id);
|
|
650
707
|
}
|
|
651
708
|
} catch (error) {
|
|
652
|
-
const failure = refusal(error, method, context.signal);
|
|
709
|
+
const failure = refusal(error, method, context.signal, id === void 0 ? void 0 : refused.get(id));
|
|
653
710
|
if (!failure) throw error;
|
|
654
711
|
return context.error("failed", new ScriptError(failure).message, { code: failure.code, next: failure.next });
|
|
712
|
+
} finally {
|
|
713
|
+
if (id !== void 0) refused.delete(id);
|
|
655
714
|
}
|
|
656
715
|
};
|
|
657
|
-
const refusal = (error, method, signal) => {
|
|
716
|
+
const refusal = (error, method, signal, asked) => {
|
|
658
717
|
if (signal.aborted) return fail("CANCELLED", "the call was cancelled").failure;
|
|
659
718
|
if (error instanceof ScriptError) return error.failure;
|
|
660
719
|
const own = pluginError(error);
|
|
661
720
|
if (own || !method || !toolError2(error)) return own;
|
|
662
|
-
if (error.error !== void 0) return denied(method.action, error).failure;
|
|
721
|
+
if (error.error !== void 0) return denied(method.action, error, requestAction(error.error) ?? asked).failure;
|
|
663
722
|
if (method.mcp) return fail("UPSTREAM_ERROR", `${method.action} failed: ${upstreamText(error.message)}`).failure;
|
|
664
723
|
return void 0;
|
|
665
724
|
};
|
|
@@ -689,8 +748,20 @@ async function registerToolsRpc(ctx, options) {
|
|
|
689
748
|
});
|
|
690
749
|
}
|
|
691
750
|
}
|
|
692
|
-
for (const spec of owned)
|
|
693
|
-
|
|
751
|
+
for (const spec of owned) {
|
|
752
|
+
const reason = await spec.available?.();
|
|
753
|
+
methods.push({
|
|
754
|
+
name: spec.name,
|
|
755
|
+
tool: spec.name,
|
|
756
|
+
kind: "plugin",
|
|
757
|
+
action: spec.action,
|
|
758
|
+
description: spec.description,
|
|
759
|
+
available: reason === void 0,
|
|
760
|
+
...reason === void 0 ? {} : { reason },
|
|
761
|
+
input: spec.input,
|
|
762
|
+
output: spec.output
|
|
763
|
+
});
|
|
764
|
+
}
|
|
694
765
|
return JSON.parse(
|
|
695
766
|
JSON.stringify({
|
|
696
767
|
id: TOOLS_RPC_ID,
|
|
@@ -703,15 +774,15 @@ async function registerToolsRpc(ctx, options) {
|
|
|
703
774
|
const envelope = (input, objectInput) => strict(
|
|
704
775
|
object({ sessionID: { type: "string", minLength: 1 }, ...accounts ? { account: { type: "string", minLength: 1 } } : {}, input }, ["sessionID", "input"]),
|
|
705
776
|
(value) => [
|
|
706
|
-
...typeof value["sessionID"] === "string" && value["sessionID"] !== "" ? [] : [
|
|
707
|
-
...value["account"] === void 0 || typeof value["account"] === "string" && value["account"] !== "" ? [] : [
|
|
708
|
-
...!("input" in value) ? [
|
|
777
|
+
...typeof value["sessionID"] === "string" && value["sessionID"] !== "" ? [] : [ENVELOPE_MESSAGES.sessionID],
|
|
778
|
+
...value["account"] === void 0 || typeof value["account"] === "string" && value["account"] !== "" ? [] : [ENVELOPE_MESSAGES.account],
|
|
779
|
+
...!("input" in value) ? [ENVELOPE_MESSAGES.inputMissing] : objectInput && !plainObject(value["input"]) ? [ENVELOPE_MESSAGES.inputNotObject] : []
|
|
709
780
|
]
|
|
710
781
|
);
|
|
711
782
|
const contract = Rpc2.define({
|
|
712
783
|
id: TOOLS_RPC_ID,
|
|
713
784
|
methods: {
|
|
714
|
-
describe: { input:
|
|
785
|
+
describe: { input: DESCRIBE_INPUT, output: DESCRIPTION },
|
|
715
786
|
...Object.fromEntries(KNOWN_READS.map((name) => [name, { input: envelope(ANY_OBJECT, true), output: mcpOutput(), errors: { failed: FAILED } }])),
|
|
716
787
|
...Object.fromEntries(owned.map((spec) => [spec.name, { input: envelope(spec.input, false), output: spec.output, errors: { failed: FAILED } }]))
|
|
717
788
|
},
|
|
@@ -724,7 +795,9 @@ async function registerToolsRpc(ctx, options) {
|
|
|
724
795
|
};
|
|
725
796
|
const registration = await ctx.rpc.register(contract, handlers);
|
|
726
797
|
const hook = await ctx.permission.hook("evaluate", (evaluation) => {
|
|
727
|
-
if (evaluation.
|
|
798
|
+
if (evaluation.source === void 0 || !live.has(evaluation.source.id) || evaluation.effect === "allow") return;
|
|
799
|
+
refused.set(evaluation.source.id, evaluation.action);
|
|
800
|
+
if (evaluation.effect !== "ask") return;
|
|
728
801
|
evaluation.effect = "deny";
|
|
729
802
|
evaluation.message = UNATTENDED;
|
|
730
803
|
});
|
|
@@ -1671,14 +1744,11 @@ function satisfies(version, range) {
|
|
|
1671
1744
|
return order === 0;
|
|
1672
1745
|
});
|
|
1673
1746
|
}
|
|
1674
|
-
function checkHost(version,
|
|
1675
|
-
|
|
1676
|
-
if (
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
tested,
|
|
1680
|
-
warning: `outside the tested line ${tested}; it runs because allowUntestedHost is true`
|
|
1681
|
-
};
|
|
1747
|
+
function checkHost(version, range, allowUntested) {
|
|
1748
|
+
const tested = TESTED_HOSTS.includes(version);
|
|
1749
|
+
if (satisfies(version, range)) return { version, range, tested };
|
|
1750
|
+
if (!allowUntested) throw new GranolaError(hostUntested(range, version));
|
|
1751
|
+
return { version, range, tested, warning: `outside the supported range ${range}; it runs because allowUntestedHost is true` };
|
|
1682
1752
|
}
|
|
1683
1753
|
|
|
1684
1754
|
// src/integration.ts
|
|
@@ -1818,7 +1888,15 @@ function parseOptions(raw, home = homedir()) {
|
|
|
1818
1888
|
|
|
1819
1889
|
// src/rpc/register.ts
|
|
1820
1890
|
async function registerRpc(ctx, status) {
|
|
1821
|
-
await ctx.rpc.register(Granola, {
|
|
1891
|
+
await ctx.rpc.register(Granola, {
|
|
1892
|
+
status: async (_input, context) => {
|
|
1893
|
+
try {
|
|
1894
|
+
return await status.read();
|
|
1895
|
+
} catch {
|
|
1896
|
+
return context.error("unavailable", describe(STATUS_UNAVAILABLE), { code: STATUS_UNAVAILABLE.code, next: STATUS_UNAVAILABLE.next });
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
});
|
|
1822
1900
|
}
|
|
1823
1901
|
|
|
1824
1902
|
// src/server.ts
|
|
@@ -1878,7 +1956,7 @@ async function registerSkills(ctx, skills = readSkills()) {
|
|
|
1878
1956
|
async function setup(ctx, toolsRpc) {
|
|
1879
1957
|
const info = readPackageInfo();
|
|
1880
1958
|
const options = parseOptions(ctx.options);
|
|
1881
|
-
const host = checkHost(ctx.app.version, info.
|
|
1959
|
+
const host = checkHost(ctx.app.version, info.hostRange, options.allowUntestedHost);
|
|
1882
1960
|
const server = await registerServer(ctx);
|
|
1883
1961
|
const integration = await registerIntegration(ctx, server.conflict);
|
|
1884
1962
|
await registerPermissions(ctx);
|
|
@@ -1888,7 +1966,7 @@ async function setup(ctx, toolsRpc) {
|
|
|
1888
1966
|
workspace: tools?.workspace(),
|
|
1889
1967
|
conflict: server.conflict(),
|
|
1890
1968
|
exportDirectory: options.exportDirectory,
|
|
1891
|
-
|
|
1969
|
+
version: info.version,
|
|
1892
1970
|
host
|
|
1893
1971
|
}));
|
|
1894
1972
|
tools = await registerTools(ctx);
|
package/dist/tui.js
CHANGED
|
@@ -4,18 +4,26 @@ import { createSignal } from "solid-js";
|
|
|
4
4
|
|
|
5
5
|
// src/rpc/contract.ts
|
|
6
6
|
import { Rpc } from "@opencode/plugin/rpc";
|
|
7
|
+
var STATES = ["connected", "sign_in_required", "connecting", "disabled", "failed", "not_configured", "conflict"];
|
|
7
8
|
var STATUS_SCHEMA = {
|
|
8
9
|
type: "object",
|
|
9
10
|
additionalProperties: false,
|
|
10
|
-
required: ["
|
|
11
|
+
required: ["version", "host", "state", "tools", "unguardedAgents", "exportDirectory", "nextStep"],
|
|
11
12
|
properties: {
|
|
12
|
-
|
|
13
|
+
version: { type: "string" },
|
|
14
|
+
host: {
|
|
15
|
+
type: "object",
|
|
16
|
+
additionalProperties: false,
|
|
17
|
+
required: ["version", "range", "tested"],
|
|
18
|
+
properties: { version: { type: ["string", "null"] }, range: { type: "string" }, tested: { type: "boolean" } }
|
|
19
|
+
},
|
|
20
|
+
state: { type: "string", enum: STATES },
|
|
13
21
|
title: { type: "string" },
|
|
14
22
|
problem: {
|
|
15
23
|
type: "object",
|
|
16
24
|
additionalProperties: false,
|
|
17
|
-
required: ["code", "message"
|
|
18
|
-
properties: { code: { type: "string" }, message: { type: "string" }
|
|
25
|
+
required: ["code", "message"],
|
|
26
|
+
properties: { code: { type: "string" }, message: { type: "string" } }
|
|
19
27
|
},
|
|
20
28
|
conflict: { type: "string" },
|
|
21
29
|
tools: {
|
|
@@ -45,31 +53,43 @@ var STATUS_SCHEMA = {
|
|
|
45
53
|
}
|
|
46
54
|
},
|
|
47
55
|
exportDirectory: { type: "string" },
|
|
48
|
-
pluginVersion: { type: "string" },
|
|
49
|
-
hostVersion: { type: "string" },
|
|
50
56
|
hostWarning: { type: "string" },
|
|
51
|
-
|
|
57
|
+
nextStep: { type: "string" }
|
|
52
58
|
}
|
|
53
59
|
};
|
|
60
|
+
var STATUS_ERROR_SCHEMA = {
|
|
61
|
+
type: "object",
|
|
62
|
+
additionalProperties: false,
|
|
63
|
+
required: ["code", "next"],
|
|
64
|
+
properties: { code: { type: "string" }, next: { type: "string" } }
|
|
65
|
+
};
|
|
54
66
|
var STATE_LABELS = {
|
|
55
67
|
connected: "connected",
|
|
56
|
-
|
|
57
|
-
|
|
68
|
+
connecting: "connecting",
|
|
69
|
+
sign_in_required: "sign-in required",
|
|
58
70
|
failed: "failed",
|
|
59
71
|
disabled: "disabled",
|
|
60
|
-
|
|
72
|
+
not_configured: "not configured",
|
|
73
|
+
conflict: "conflict"
|
|
61
74
|
};
|
|
62
75
|
var TONES = {
|
|
63
76
|
connected: "ok",
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
connecting: "info",
|
|
78
|
+
sign_in_required: "warning",
|
|
66
79
|
failed: "error",
|
|
67
80
|
disabled: "info",
|
|
68
|
-
|
|
81
|
+
not_configured: "warning",
|
|
82
|
+
conflict: "warning"
|
|
69
83
|
};
|
|
84
|
+
function hostRow(status) {
|
|
85
|
+
const { version, range, tested } = status.host;
|
|
86
|
+
const found = version ?? "unknown";
|
|
87
|
+
if (status.hostWarning) return { label: "OpenCode", text: `${found} (host untested: ${status.hostWarning}), plugin ${status.version}`, tone: "warning" };
|
|
88
|
+
return { label: "OpenCode", text: `${found} (${tested ? "tested" : "untested"}; supported range ${range}), plugin ${status.version}`, ...tested ? {} : { tone: "info" } };
|
|
89
|
+
}
|
|
70
90
|
function statusRows(status) {
|
|
71
91
|
const connected = status.state === "connected";
|
|
72
|
-
const connection = status.conflict ? `conflict: ${status.conflict}` : `${STATE_LABELS[status.state]}${status.title ? ` (${status.title})` : ""}${status.problem ? `: ${status.problem.message} (${status.problem.code})` : ""}`;
|
|
92
|
+
const connection = status.state === "conflict" && status.conflict ? `conflict: ${status.conflict}` : `${STATE_LABELS[status.state]}${status.title ? ` (${status.title})` : ""}${status.problem ? `: ${status.problem.message} (${status.problem.code})` : ""}`;
|
|
73
93
|
const tools = [`${status.tools.visible} available`];
|
|
74
94
|
if (status.tools.hidden) tools.push(`${status.tools.hidden} hidden`);
|
|
75
95
|
if (status.tools.unknown) {
|
|
@@ -78,18 +98,18 @@ function statusRows(status) {
|
|
|
78
98
|
}
|
|
79
99
|
const agents = status.unguardedAgents;
|
|
80
100
|
return [
|
|
81
|
-
{ label: "Connection", text: connection, tone:
|
|
101
|
+
{ label: "Connection", text: connection, tone: TONES[status.state] },
|
|
82
102
|
{ label: "Account", text: !connected ? "unknown until Granola connects" : status.workspace ? `workspace ${status.workspace}` : "workspace unknown until the agent reads your Granola account" },
|
|
83
103
|
{ label: "Access", text: "reads run without asking; changes, new tools and exports ask first" },
|
|
84
104
|
{ label: "Export folder", text: status.exportDirectory },
|
|
85
105
|
{ label: "Tools", text: connected ? tools.join(", ") : "none until Granola connects" },
|
|
86
|
-
|
|
106
|
+
agents.length ? {
|
|
87
107
|
label: "Agents without default rules",
|
|
88
|
-
text: `${agents.join(", ")}: Granola
|
|
108
|
+
text: `${agents.join(", ")}: Granola changes and new tools may run there without asking`,
|
|
89
109
|
tone: "warning",
|
|
90
110
|
block: permissionsSnippet(agents, status.permissionsBlock ?? [])
|
|
91
|
-
}
|
|
92
|
-
|
|
111
|
+
} : { label: "Agents without default rules", text: "none" },
|
|
112
|
+
hostRow(status)
|
|
93
113
|
];
|
|
94
114
|
}
|
|
95
115
|
function permissionsSnippet(agents, block) {
|
|
@@ -111,7 +131,8 @@ var Granola = Rpc.define({
|
|
|
111
131
|
methods: {
|
|
112
132
|
status: {
|
|
113
133
|
input: { type: "object", properties: {}, additionalProperties: false },
|
|
114
|
-
output: STATUS_SCHEMA
|
|
134
|
+
output: STATUS_SCHEMA,
|
|
135
|
+
errors: { unavailable: STATUS_ERROR_SCHEMA }
|
|
115
136
|
}
|
|
116
137
|
},
|
|
117
138
|
events: {}
|
|
@@ -127,10 +148,10 @@ function view(details) {
|
|
|
127
148
|
if (details.type === "unavailable") {
|
|
128
149
|
return {
|
|
129
150
|
rows: [{ label: "Connection", text: `unknown: ${details.reason}`, tone: "warning" }],
|
|
130
|
-
next: "Check that granola.meetings is active in /plugins, then press r to refresh."
|
|
151
|
+
next: details.next ?? "Check that granola.meetings is active in /plugins, then press r to refresh."
|
|
131
152
|
};
|
|
132
153
|
}
|
|
133
|
-
return { rows: statusRows(details.status), next: details.status.
|
|
154
|
+
return { rows: statusRows(details.status), next: details.status.nextStep };
|
|
134
155
|
}
|
|
135
156
|
|
|
136
157
|
// src/tui/view.tsx
|
|
@@ -275,9 +296,17 @@ function StatusDialog(props) {
|
|
|
275
296
|
}
|
|
276
297
|
|
|
277
298
|
// src/tui/index.tsx
|
|
278
|
-
function
|
|
299
|
+
function failure(error) {
|
|
279
300
|
const type = typeof error === "object" && error !== null && "type" in error ? error.type : void 0;
|
|
280
|
-
|
|
301
|
+
if (type === "unavailable") return {
|
|
302
|
+
type: "unavailable",
|
|
303
|
+
reason: "Granola status could not be read (STATUS_UNAVAILABLE)",
|
|
304
|
+
next: "Press r to refresh in a few seconds."
|
|
305
|
+
};
|
|
306
|
+
return {
|
|
307
|
+
type: "unavailable",
|
|
308
|
+
reason: type === "rpc.unavailable" || type === "rpc.method_not_found" ? "the Granola plugin is not running on this OpenCode server" : "the status request failed"
|
|
309
|
+
};
|
|
281
310
|
}
|
|
282
311
|
var plugin = {
|
|
283
312
|
id: "granola.meetings.tui",
|
|
@@ -297,10 +326,7 @@ var plugin = {
|
|
|
297
326
|
} : {}).then((status) => ({
|
|
298
327
|
type: "ready",
|
|
299
328
|
status
|
|
300
|
-
}), (
|
|
301
|
-
type: "unavailable",
|
|
302
|
-
reason: reason(error)
|
|
303
|
-
})).then((next) => {
|
|
329
|
+
}), failure).then((next) => {
|
|
304
330
|
if (request === generation) setDetails(next);
|
|
305
331
|
});
|
|
306
332
|
};
|
package/package.json
CHANGED
package/skills/granola/SKILL.md
CHANGED
|
@@ -22,7 +22,7 @@ For a brief before a meeting, or a catch-up on a person, company or topic, also
|
|
|
22
22
|
- `search_meetings`: by keyword, participant or company, and date. Keywords match titles, summaries and the user's notes, not transcripts, so an empty result does not mean that a topic was never discussed.
|
|
23
23
|
- `list_meetings`: meetings in a date range, newest first (the last 7 days by default), optionally in one folder.
|
|
24
24
|
- `search_transcripts`: by keyword in what was said; it returns matching transcript passages. Not every Granola plan includes transcripts.
|
|
25
|
-
- `list_meeting_folders`: folders and their IDs, for the
|
|
25
|
+
- `list_meeting_folders`: folders and their IDs, for the `folder_id` filter of `search_meetings` and `list_meetings`.
|
|
26
26
|
- Results come in pages. While a response offers a next cursor, continue with it and the same filters before you say that a meeting does not exist.
|
|
27
27
|
|
|
28
28
|
## Read meetings
|