diff --git a/CHANGELOG.md b/CHANGELOG.md index b96eb7b..68a58d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project are documented here. The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.2] - 2026-11-13 + +### Added + +- The `jelly` settings declare their choices: `access` is drawn as Allowed, Ask before changes or Read only, with + Jelly's own line for each (needs a jelly that includes app#1301). + ## [0.0.1] - 2026-11-12 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 461a83e..1b5c125 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ Only for development. Install the package into an OpenCode project from the fold { "plugins": ["./opencode-boxd"] } ``` -A published install is `opencode plugin add github:joe-broadhead/opencode-boxd#v0.0.1`. +A published install is `opencode plugin add github:joe-broadhead/opencode-boxd#v0.0.2`. ## Rules @@ -134,5 +134,5 @@ Each release has its own tag, and a Git install does not update by itself. To mo Or add the plugin to `opencode.json` instead, with the same spec: ```json -{ "plugins": [{ "package": "github:joe-broadhead/opencode-boxd#v0.0.1" }] } +{ "plugins": [{ "package": "github:joe-broadhead/opencode-boxd#v0.0.2" }] } ``` diff --git a/README.md b/README.md index 73d9f7e..81ab94e 100644 --- a/README.md +++ b/README.md @@ -18,17 +18,17 @@ Unofficial; not affiliated with boxd. ## Install ```sh -opencode plugin add github:joe-broadhead/opencode-boxd#v0.0.1 +opencode plugin add github:joe-broadhead/opencode-boxd#v0.0.2 ``` Run `opencode reload`, or restart OpenCode, then run `/boxd-status`. -Each release has its own tag, and a Git install does not update by itself. To move to a newer release, remove the old entry with `opencode plugin remove github:joe-broadhead/opencode-boxd#v0.0.0`, then add the new tag with `opencode plugin add github:joe-broadhead/opencode-boxd#v`. +Each release has its own tag, and a Git install does not update by itself. To move to a newer release, remove the old entry with `opencode plugin remove github:joe-broadhead/opencode-boxd#v0.0.1`, then add the new tag with `opencode plugin add github:joe-broadhead/opencode-boxd#v`. Or add the plugin to `opencode.json` instead, with the same spec: ```json -{ "plugins": [{ "package": "github:joe-broadhead/opencode-boxd#v0.0.1" }] } +{ "plugins": [{ "package": "github:joe-broadhead/opencode-boxd#v0.0.2" }] } ``` The install takes the GitHub release tag. Its commit carries the built `dist/`, so the install runs no build. @@ -162,7 +162,7 @@ The snapshot is the one policy worth setting centrally: every member's machines // /Library/Application Support/opencode/opencode.json on macOS { "plugins": [{ - "package": "github:joe-broadhead/opencode-boxd#v0.0.1", + "package": "github:joe-broadhead/opencode-boxd#v0.0.2", "options": { "snapshot": "opencode-golden" } }] } @@ -241,12 +241,14 @@ agents without the default rules, the OpenCode version and one next step. - **Guest credentials are boxd's too.** A guest reads its model key and any push token from `boxd env` at boot; the plugin never reads or renews them. A token that expires, such as a one-hour GitHub App token, stops every guest that uses it unless something renews it, and a guest session cannot finish an interactive sign-in by itself. +- In Jelly, the `access` setting is drawn as a choice of three, which needs a Jelly that reads declared choices: an + older Jelly refuses the plugin's whole declaration. - Updates are not automatic for a Git install, and `opencode plugin update` and `opencode plugin check` do not work on one: to move to a newer release, remove the old entry and add the new tag. ## Uninstall ```sh -opencode plugin remove github:joe-broadhead/opencode-boxd#v0.0.1 +opencode plugin remove github:joe-broadhead/opencode-boxd#v0.0.2 ``` The ledger is yours to keep or delete. Machines are boxd's: `boxd machine remove oc- -y` destroys one. diff --git a/docs/configuration.md b/docs/configuration.md index 9b76588..2d33ed2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -7,7 +7,7 @@ All options go in the plugin entry in `opencode.json`: ```jsonc { "plugins": [{ - "package": "github:joe-broadhead/opencode-boxd#v0.0.1", + "package": "github:joe-broadhead/opencode-boxd#v0.0.2", "options": { "snapshot": "opencode-golden" } }] } diff --git a/docs/golden-image.md b/docs/golden-image.md index 04fbb6a..52c35f9 100644 --- a/docs/golden-image.md +++ b/docs/golden-image.md @@ -141,7 +141,7 @@ Point the plugin at it in `opencode.json` (or rely on the default name): ```jsonc { "plugins": [{ - "package": "github:joe-broadhead/opencode-boxd#v0.0.1", + "package": "github:joe-broadhead/opencode-boxd#v0.0.2", "options": { "snapshot": "opencode-golden" } }] } diff --git a/docs/jelly.md b/docs/jelly.md index 52c9040..79f9416 100644 --- a/docs/jelly.md +++ b/docs/jelly.md @@ -10,7 +10,10 @@ what a Jelly may call. - **`skills`** — `boxd`. - **`signIns`** — empty. The plugin has no sign-in of its own: it runs your `boxd` CLI, which is already signed in. - **`icon`** — `assets/icon.svg`, this family's own mark, not boxd's logo. -- **`contributes`** — empty: no home feed, widget, work kind or setting. +- **`contributes`** — one setting, `access`, the option of the same name, as a choice of three: **Allowed** (`all`, the + default), **Ask before changes** (`ask-changes`) and **Read only** (`read-only`), with Jelly's own line for each. + Jelly draws it as a choice and saves only one of the three; a Jelly that does not know declared choices yet refuses + the whole declaration. No home feed, widget or work kind. One rule stays for good: a tool boxd adds later is not asked about in Jelly, a write included. It is not in `jelly.tools` until it is classified in `src/tools.ts`, and status names it as unknown until then. diff --git a/package-lock.json b/package-lock.json index 9e38049..30da20e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@joe-broadhead/opencode-boxd", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@joe-broadhead/opencode-boxd", - "version": "0.0.1", + "version": "0.0.2", "license": "MIT", "devDependencies": { "@opencode/client": "2.2.3", diff --git a/package.json b/package.json index f257dea..0d5c784 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@joe-broadhead/opencode-boxd", - "version": "0.0.1", + "version": "0.0.2", "description": "Unofficial OpenCode plugin for boxd: run a session on a private boxd machine and keep the transcript on your laptop", "keywords": [ "opencode", @@ -141,8 +141,25 @@ "key": "access", "label": "Access", "type": "string", - "default": "all", - "description": "all, ask-changes or read-only. Generates default rules for agents with a base allow rule." + "description": "How much the plugin's tools may do. Generates default rules for agents with a base allow rule.", + "choices": [ + { + "value": "all", + "label": "Allowed", + "description": "Every tool runs without asking, reads and changes alike." + }, + { + "value": "ask-changes", + "label": "Ask before changes", + "description": "Reads run without asking. Each change asks you first." + }, + { + "value": "read-only", + "label": "Read only", + "description": "Reads run. Changes never run." + } + ], + "default": "all" } ] } diff --git a/scripts/check-copies.mjs b/scripts/check-copies.mjs index ff42047..9da8e28 100644 --- a/scripts/check-copies.mjs +++ b/scripts/check-copies.mjs @@ -1,32 +1,42 @@ -// The tools RPC module is shared by several plugins: identical outside its PLUGIN-SPECIFIC block. This fails when this -// repository's copy drifted from the pin every plugin carries, so a one-plugin edit cannot break the hand-over. -import { readFileSync } from "node:fs" +// Fails when a shared module's copy in this plugin differs from the one every plugin carries. +// node scripts/check-copies.mjs check the copies this plugin has +// node scripts/check-copies.mjs --print print each copy's hash, after a change made in every plugin at once +// Each plugin keeps its own copy, because each bundle and each test suite stands alone. The hashes below name the one +// version all plugins carry: the tools RPC outside its PLUGIN-SPECIFIC block, and Jelly's setting rules whole. import { createHash } from "node:crypto" -import { fileURLToPath } from "node:url" +import { existsSync, readFileSync } from "node:fs" -/** The v2 shared-part hash, as hey-jelly computes it. */ -const PIN = "15d82caa53aaae7ad9efb88dddb97056d3895c4dbf49b8efbb85b743871b2a6d" -const START = "// >>> PLUGIN-SPECIFIC >>>" +const BEGIN = "// >>> PLUGIN-SPECIFIC >>>" const END = "// <<< PLUGIN-SPECIFIC <<<" -/** The module minus its plugin block: what every plugin shares, byte for byte. */ -export function sharedPart(text) { - const start = text.indexOf(START) +const COPIES = [ + { path: "src/host/tools-rpc.ts", optional: false, sha256: "15d82caa53aaae7ad9efb88dddb97056d3895c4dbf49b8efbb85b743871b2a6d", part: sharedPart }, + // Jelly's setting rules, restated without zod (the file's header says what it restates and how to update it). + { path: "test/support/jelly-settings.ts", optional: false, sha256: "966aeac7eddce490da51e8e46780f1fbcfaf9b3e892bd62f2c3952c2a6111383", part: (text) => text }, +] + +function sharedPart(text, path) { + const begin = text.indexOf(BEGIN) const end = text.indexOf(END) - if (start === -1 || end === -1) throw new Error("the tools RPC module has no PLUGIN-SPECIFIC block") - return `${text.slice(0, start)}${text.slice(end + END.length)}` + if (begin === -1 || end === -1 || end < begin) throw new Error(`${path}: the PLUGIN-SPECIFIC markers are missing or out of order`) + return text.slice(0, begin) + text.slice(end + END.length) } -const files = process.argv.slice(2) -if (files.length === 0) files.push(fileURLToPath(new URL("../src/host/tools-rpc.ts", import.meta.url))) -let failed = false -for (const file of files) { - const hash = createHash("sha256").update(sharedPart(readFileSync(file, "utf8"))).digest("hex") - if (hash === PIN) { - console.log(`same: ${file}`) - } else { - console.error(`different: ${file} has shared-part hash ${hash}, the pin is ${PIN}`) - failed = true +const print = process.argv.includes("--print") +let failed = 0 +for (const copy of COPIES) { + if (!existsSync(copy.path)) { + if (copy.optional) continue + console.error(`MISSING: ${copy.path}`) + failed++ + continue + } + const hash = createHash("sha256").update(copy.part(readFileSync(copy.path, "utf8").replaceAll("\r\n", "\n"), copy.path)).digest("hex") + if (print) console.log(`${copy.path}: ${hash}`) + else if (hash === copy.sha256) console.log(`same: ${copy.path}`) + else { + console.error(`DRIFT: ${copy.path} differs from the copy every plugin carries. Change it in every plugin at once, then update its hash here.`) + failed++ } } -process.exit(failed ? 1 : 0) +process.exit(failed === 0 ? 0 : 1) diff --git a/src/options.ts b/src/options.ts index 7bd572e..aad975c 100644 --- a/src/options.ts +++ b/src/options.ts @@ -13,7 +13,7 @@ interface Options { readonly allowedRoots: readonly string[] } -const EXAMPLE = `"plugins": [{ "package": "github:joe-broadhead/opencode-boxd#v0.0.1", "options": { "snapshot": "opencode-golden" } }]` +const EXAMPLE = `"plugins": [{ "package": "github:joe-broadhead/opencode-boxd#v0.0.2", "options": { "snapshot": "opencode-golden" } }]` const ACCEPTED = { snapshot: "a lowercase boxd snapshot name (opencode-golden by default)", allowUntestedHost: "true or false (the default)", diff --git a/test/support/jelly-settings.ts b/test/support/jelly-settings.ts new file mode 100644 index 0000000..d001184 --- /dev/null +++ b/test/support/jelly-settings.ts @@ -0,0 +1,176 @@ +/** + * Jelly's rules for the settings a plugin declares in `jelly.contributes.settings`, restated without a schema library + * so that a test, under Node or Bun, reads this plugin's declaration the way Jelly does. It restates, from + * we-the-rebel-alliance/app at master 6af624591 (which includes app#1301, 34274b8c7): + * - protocol/contracts/src/plugin-setting.light.ts:5-39: `PLUGIN_SETTING_MAX_LENGTH`, `settingChoices` and + * `settingValueIssue`, the check the host runs on every value a person saves, copied as they are; + * - protocol/contracts/src/plugin-setting.ts:14-89: `PluginSettingSchema`, a strict object with its refinements, + * as `settingProblems` (the refinements' messages are Jelly's own); + * - protocol/contracts/src/plugin-contract.ts:98 and 282-316: what `PluginDeclarationSchema` adds for the list, at + * most 32 settings, unique keys, and a `visibleWhen` that names a non-secret setting of the same type with no + * cycle, as `settingsProblems`. + * Every object Jelly knows is strict, so a Jelly before app#1301, which does not know `enum`, `choices`, `min`, `max`, + * `step` or `integer`, refuses the whole declaration. Each function returns sentences, so a test asserts `toEqual([])`. + * + * This is a shared copy: every plugin of the family carries this file byte for byte, pinned by its sha256 in that + * plugin's `scripts/check-copies.mjs`. To change it, change Jelly's source first, then this file to match, run the + * differential check against Jelly's zod schema (it is kept outside the plugin repositories), and land the same bytes + * with the new hash in every plugin at once (`node scripts/check-copies.mjs --print` prints it). Keep the rules + * Jelly's; never loosen them here. `ACCESS_CHOICES` is Jelly's wording for the shared `access` option, word for word. + */ + +/** The longest value a setting may carry, a secret included. */ +export const PLUGIN_SETTING_MAX_LENGTH = 8192 + +/** One value of a closed set, with the words a person reads for it. */ +export interface PluginSettingChoice { + readonly value: string + readonly label?: string + readonly description?: string +} + +/** A setting a plugin declares, as Jelly's schema types it. */ +export interface PluginSetting { + readonly key: string + readonly label: string + readonly type: "string" | "boolean" | "number" | "secret" + readonly description: string + readonly enum?: readonly string[] + readonly choices?: readonly PluginSettingChoice[] + readonly min?: number + readonly max?: number + readonly step?: number + readonly integer?: boolean + readonly visibleWhen?: { readonly setting: string; readonly equals: string | boolean | number } + readonly default?: string | boolean | number +} + +type Bounds = Pick + +/** The closed set a string setting declares, as choices; undefined for free values. */ +export function settingChoices(setting: Pick): readonly PluginSettingChoice[] | undefined { + return setting.choices ?? (setting.enum === undefined ? undefined : setting.enum.map((value) => ({ value }))) +} + +/** + * Why a value does not fit the setting's declared set or bounds, or null when it does. The type itself is checked by + * the caller. The host refuses with this; a form uses it to hold a Save back. + */ +export function settingValueIssue(setting: Bounds, value: string | number | boolean): string | null { + const closed = settingChoices(setting) + if (closed !== undefined && typeof value === "string" && !closed.some((c) => c.value === value)) return "not one of the declared values" + if (typeof value !== "number") return null + if (setting.integer === true && !Number.isInteger(value)) return "not a whole number" + if (setting.min !== undefined && value < setting.min) return "below the minimum" + if (setting.max !== undefined && value > setting.max) return "above the maximum" + if (setting.step !== undefined) { + const from = setting.min ?? 0 + const steps = (value - from) / setting.step + if (Math.abs(steps - Math.round(steps)) > 1e-9) return "not on a step" + } + return null +} + +const ID = /^[a-zA-Z][a-zA-Z0-9._-]{0,127}$/ +const SETTING_KEYS = ["key", "label", "type", "description", "enum", "choices", "min", "max", "step", "integer", "visibleWhen", "default"] +const CHOICE_KEYS = ["value", "label", "description"] +const TYPES = ["string", "boolean", "number", "secret"] +const MAX_SETTINGS = 32 + +type Fields = { readonly [key: string]: unknown } +const isObject = (value: unknown): value is Fields => typeof value === "object" && value !== null && !Array.isArray(value) +const text = (value: unknown, min: number, max: number): boolean => typeof value === "string" && value.length >= min && value.length <= max +const id = (value: unknown): boolean => typeof value === "string" && ID.test(value) +// zod 4's `z.number()` refuses NaN and both infinities, so every number Jelly reads is finite. +const finite = (value: unknown): boolean => typeof value === "number" && Number.isFinite(value) +const settingValue = (value: unknown): boolean => text(value, 0, PLUGIN_SETTING_MAX_LENGTH) || typeof value === "boolean" || finite(value) +const choice = (value: unknown): boolean => + isObject(value) && + Object.keys(value).every((key) => CHOICE_KEYS.includes(key)) && + text(value["value"], 1, 200) && + (value["label"] === undefined || text(value["label"], 1, 200)) && + (value["description"] === undefined || text(value["description"], 0, 300)) +const list = (value: unknown, each: (item: unknown) => boolean): boolean => + Array.isArray(value) && value.length >= 2 && value.length <= 64 && value.every(each) + +/** What Jelly's `PluginSettingSchema` refuses in one setting, as `setting : `; empty when it reads it. */ +export function settingProblems(raw: unknown): string[] { + if (!isObject(raw)) return ["a setting must be an object"] + const at = typeof raw["key"] === "string" ? `setting ${raw["key"]}` : "a setting" + const problems: string[] = [] + const say = (problem: string): void => { + problems.push(`${at}: ${problem}`) + } + for (const key of Object.keys(raw)) if (!SETTING_KEYS.includes(key)) say(`unknown field ${key}`) + if (!id(raw["key"])) say("key must be an identifier") + if (!text(raw["label"], 1, 200)) say("label must be 1 to 200 characters") + if (!TYPES.includes(raw["type"] as string)) say("type must be string, boolean, number or secret") + if (!text(raw["description"], 0, 1000)) say("description must be at most 1000 characters") + if (raw["enum"] !== undefined && !list(raw["enum"], (value) => text(value, 1, 200))) say("enum must be 2 to 64 values of 1 to 200 characters") + if (raw["choices"] !== undefined && !list(raw["choices"], choice)) + say("choices must be 2 to 64 entries of value (1 to 200 characters), label (1 to 200) and description (at most 300) only") + for (const key of ["min", "max", "step"]) if (raw[key] !== undefined && !finite(raw[key])) say(`${key} must be a finite number`) + if (finite(raw["step"]) && !((raw["step"] as number) > 0)) say("step must be positive") + if (raw["integer"] !== undefined && typeof raw["integer"] !== "boolean") say("integer must be true or false") + const when = raw["visibleWhen"] + if (when !== undefined && !(isObject(when) && Object.keys(when).every((key) => key === "setting" || key === "equals") && id(when["setting"]) && settingValue(when["equals"]))) + say("visibleWhen must be { setting, equals } only, naming a setting and a value") + if (raw["default"] !== undefined && !settingValue(raw["default"])) say("default must be a string of at most 8192 characters, a boolean or a finite number") + if (problems.length > 0) return problems + + // Jelly's refinements (plugin-setting.ts:61-89), on a setting whose fields have the right shapes. + const setting = raw as unknown as PluginSetting + if (setting.default !== undefined) { + if (setting.type === "secret" || typeof setting.default !== setting.type) say("A default must match the setting type; secrets never declare a default") + } + const closed = settingChoices(setting) + if (setting.enum !== undefined && setting.choices !== undefined) say("Declare a closed set as enum or as choices, not both") + if (closed !== undefined) { + if (setting.type !== "string") say("Only a string setting has a closed set") + if (new Set(closed.map((c) => c.value)).size !== closed.length) say("The values of a closed set must be unique") + if (typeof setting.default === "string" && !closed.some((c) => c.value === setting.default)) say("A default must be one of the closed set's values") + } + const bounded = setting.min !== undefined || setting.max !== undefined || setting.step !== undefined || setting.integer !== undefined + if (bounded && setting.type !== "number") say("Only a number setting has min, max, step or integer") + if (setting.min !== undefined && setting.max !== undefined && setting.min > setting.max) say("min may not be above max") + if (typeof setting.default === "number" && settingValueIssue(setting, setting.default) !== null) say("A default must satisfy the setting's own bounds") + return problems +} + +/** What Jelly refuses in `contributes.settings`: each setting's problems, then the list's own rules; empty when it reads them. */ +export function settingsProblems(settings: unknown): string[] { + if (!Array.isArray(settings)) return ["contributes.settings must be an array"] + const problems = settings.flatMap(settingProblems) + if (settings.length > MAX_SETTINGS) problems.push(`at most ${MAX_SETTINGS} settings`) + if (problems.length > 0) return problems + + // plugin-contract.ts:282-316, on settings that each passed `PluginSettingSchema`. + const all = settings as PluginSetting[] + if (new Set(all.map((setting) => setting.key)).size !== all.length) problems.push("Contribution ids must be unique within a package") + for (const setting of all) { + const condition = setting.visibleWhen + if (condition === undefined) continue + const target = all.find((field) => field.key === condition.setting) + let invalid = target === undefined || target.type === "secret" || typeof condition.equals !== target.type + const seen = new Set([setting.key]) + let next = target + while (next !== undefined) { + if (seen.has(next.key)) { + invalid = true + break + } + seen.add(next.key) + const key = next.visibleWhen?.setting + next = key === undefined ? undefined : all.find((field) => field.key === key) + } + if (invalid) problems.push(`setting ${setting.key}: Setting visibility needs a non-secret setting of the same type and no dependency cycle`) + } + return problems +} + +/** Jelly's own words for the three `access` values, the same in every plugin of the family (app#172). */ +export const ACCESS_CHOICES: readonly PluginSettingChoice[] = [ + { value: "all", label: "Allowed", description: "Every tool runs without asking, reads and changes alike." }, + { value: "ask-changes", label: "Ask before changes", description: "Reads run without asking. Each change asks you first." }, + { value: "read-only", label: "Read only", description: "Reads run. Changes never run." }, +] diff --git a/test/unit/docs.test.ts b/test/unit/docs.test.ts index 68c30b6..c01613b 100644 --- a/test/unit/docs.test.ts +++ b/test/unit/docs.test.ts @@ -79,7 +79,7 @@ describe("the README", () => { }) test("installs from the GitHub release tag, not from a checkout or the registry", () => { - expect(README).toContain("opencode plugin add github:joe-broadhead/opencode-boxd#v0.0.1") + expect(README).toContain("opencode plugin add github:joe-broadhead/opencode-boxd#v0.0.2") expect(README).not.toMatch(/plugins.*\.\/opencode-boxd/) expect(README).not.toMatch(/npm run build/) }) diff --git a/test/unit/jelly.test.ts b/test/unit/jelly.test.ts index 9445414..6eb5bb7 100644 --- a/test/unit/jelly.test.ts +++ b/test/unit/jelly.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "bun:test" import { readdirSync, readFileSync } from "node:fs" import { DISPLAY_NAME, NAMESPACE } from "../../src/constants.js" -import { defaultRules, evaluate } from "../../src/permissions.js" +import { parseOptions } from "../../src/options.js" +import { ACCESS_VALUES, defaultRules, evaluate } from "../../src/permissions.js" import { TOOLS } from "../../src/tools.js" import { SKILL_NAMES } from "../../src/skills.js" +import { ACCESS_CHOICES, settingChoices, type PluginSetting, settingsProblems, settingValueIssue } from "../support/jelly-settings.js" /** * The `jelly` section of package.json, which Jelly reads without loading the plugin. Jelly's agents have no base allow @@ -22,10 +24,11 @@ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.ur rpc: unknown[] views: unknown[] signIns: unknown[] - contributes: unknown + contributes: { homeFeeds: unknown[]; homeWidgets: unknown[]; workKinds: unknown[]; settings: PluginSetting[] } } } const { jelly } = pkg +const { settings } = jelly.contributes const byPermission = new Map(jelly.tools.map((tool) => [tool.permission, tool])) describe("the jelly declaration", () => { @@ -49,17 +52,41 @@ describe("the jelly declaration", () => { test("uses only the keys Jelly reads: its contracts strip any other", () => { expect(Object.keys(jelly).sort()).toEqual(["contributes", "icon", "rpc", "signIns", "skills", "summary", "title", "tools", "views"]) - // The one setting is the family's access option, with the default the plugin ships with. + // The one setting is the family's access option, with the default the plugin ships and Jelly's own words for its + // three values. expect(jelly.contributes).toEqual({ homeFeeds: [], homeWidgets: [], workKinds: [], - settings: [{ key: "access", label: "Access", type: "string", default: "all", description: "all, ask-changes or read-only. Generates default rules for agents with a base allow rule." }], + settings: [ + { + key: "access", + label: "Access", + type: "string", + description: "How much the plugin's tools may do. Generates default rules for agents with a base allow rule.", + choices: ACCESS_CHOICES, + default: "all", + }, + ], }) expect(jelly.rpc).toEqual([]) expect(jelly.views).toEqual([]) }) + test("the settings follow Jelly's rules for a declared setting", () => { + expect(settingsProblems(settings)).toEqual([]) + }) + + test("the options parser takes every declared access choice, its default included, and refuses a value outside them", () => { + const access = settings.find((setting) => setting.key === "access")! + const values = (settingChoices(access) ?? []).map((choice) => choice.value) + expect(values).toEqual([...ACCESS_VALUES]) + for (const value of values) expect(parseOptions({ access: value }).access).toBe(value) + expect(parseOptions({}).access).toBe(access.default as string) + expect(settingValueIssue(access, "write")).not.toBeNull() + expect(() => parseOptions({ access: "write" })).toThrow("OPTIONS_INVALID") + }) + test("names every skill the package ships, no sign-in, and the family's icon path", () => { expect(jelly.title).toBe(DISPLAY_NAME) expect([...jelly.skills].sort()).toEqual(readdirSync(new URL("../../skills", import.meta.url)).sort())