@coda-console/plugin-check 0.4.8 → 0.5.0

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.
@@ -0,0 +1,819 @@
1
+ // Plugins (docs/plugins.md): what Coda installs once for every project, the way VS Code installs an extension. A plugin
2
+ // is a directory with a VS Code style `package.json` whose `contributes.configuration` declares its settings in JSON
3
+ // Schema, and the workflows it brings in `.coda/workflows/`; the capabilities it brings (`contributes.capabilities`, in
4
+ // `.coda/capabilities/`: run only when called), a few words for agents (`contributes.instructions`), panels for the
5
+ // resources page (`contributes.panels`, the resources plugin's point, kept as written), agents (`contributes.agents`, components/plugins/agent-plugins.mjs),
6
+ // concurrency groups (`contributes.groups`), the events it publishes (`contributes.events`), what it gives what starts in a project it applies to — commands
7
+ // (`contributes.commands`), environment variables (`contributes.environment`), directories on the PATH (`contributes.path`)
8
+ // and Agent Skills (`contributes.skills`) —, requests clients send it (`contributes.endpoints`, components/plugins/endpoints.mjs),
9
+ // pages the clients open (`contributes.views`, components/plugins/views.mjs)
10
+ // and what makes a machine ready for it once the user installs or updates it (`contributes.setup`); the plugins it
11
+ // needs (`extensionDependencies`) and the points it declares or plugs into (components/plugins/plugin-extensions.mjs). Its settings have a value for every project (the plugin's
12
+ // settings) that a project may override, as user and workspace settings do in VS Code. Workflows read the result as
13
+ // `vars` (`=vars.…`).
14
+ // Nothing here is Coda's own invention but a few string formats: `coda-agent`, what a new session is started with
15
+ // (agent, model, effort, fast mode, approval mode), `coda-agent-match`, which sessions a row applies to, and `coda-repo`
16
+ // and `coda-directory`, a repository and a directory of the machine, which clients fill in with the same chooser a project
17
+ // is added with. JSON Schema's own `format: duration` (an ISO 8601 duration) is checked as such, and its `writeOnly` marks a
18
+ // secret: a text written and never read back, kept in a file beside the database and given to the plugin's processes as
19
+ // that file's path (`CODA_PLUGIN_SECRETS`).
20
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
21
+ import { basename, join, normalize, posix } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { settings as agentSettings } from '../session-config/commands.mjs';
24
+ import { requireCondition as check, ContractError } from '../../domain/errors.mjs';
25
+ import { isDuration } from '../../domain/duration.mjs';
26
+ import { AGENT_ID } from '../../domain/values.mjs';
27
+ import { agentDeclarations } from './agent-plugins.mjs';
28
+ import { checkCatalog, dependencyDeclarations, extensionDeclarations, extensionPointDeclarations, extensionsFor, offerDeclarations } from './plugin-extensions.mjs';
29
+ import { claims, COMMIT, INTEGRITY, NPM_VERSION, npmPluginId, repoAddress, sourcePath } from './plugin-source-rules.mjs';
30
+ import { readSkillDocument } from './skill-document.mjs';
31
+ import { executorLabels } from '../workflows/executor-labels.mjs';
32
+
33
+ // A setting's name: the plugin's id, then letters and digits after each dot.
34
+ const KEY = /^[a-z][a-z0-9-]*(\.[a-z][A-Za-z0-9]*)+$/;
35
+ const ID = /^[a-z][a-z0-9-]{0,39}$/;
36
+ const NAME = /^[a-z][a-z0-9-]{0,63}$/;
37
+ // An event's name, `<域>.<名>` as Coda's own are (session.facts); the domains of Coda's own no plugin publishes in.
38
+ const EVENT = /^(?=.{3,100}$)[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
39
+ // The domains of the events Coda publishes itself; a plugin's are of a domain of its own.
40
+ export const CODA_EVENT_DOMAINS = ['workflow', 'session', 'user'];
41
+ // What a plugin tells every agent of a project that has it, kept short: it is in every session's instructions.
42
+ export const INSTRUCTIONS_LIMIT = 200;
43
+ // An environment variable's name, as a plugin gives one (`contributes.environment`).
44
+ export const VARIABLE = /^[A-Za-z_][A-Za-z0-9_]*$/;
45
+ // Plugins that ship with Coda, as VS Code's built-in extensions ship with it: updated with Coda itself.
46
+ export const BUILTIN_DIR = fileURLToPath(new URL('../../resources/plugins/', import.meta.url));
47
+ // The package the backend runs from (the runtime clone's, or the release's), whose npm dependencies may be built-in plugins
48
+ // too (dependencyPlugins).
49
+ const PACKAGE_ROOT = fileURLToPath(new URL('../../../', import.meta.url));
50
+ // What a plugin may contribute to Coda itself; anything else in `contributes` is for a point another plugin declares to
51
+ // take it (the resources plugin's `panels`), refused when none does, as any other way of writing Coda cannot read.
52
+ export const CONTRIBUTES = ['configuration', 'capabilities', 'instructions', 'agents', 'extensionPoints', 'extensions', 'groups', 'events', 'commands', 'gitCredentials',
53
+ 'repositories', 'services', 'setup', 'skills', 'environment', 'path', 'endpoints', 'mcpServers', 'views', 'modelProviders', 'executors'];
54
+ // The string formats a setting may have, beside `coda-agent` (an object) and `coda-agent-match` (a table's row names):
55
+ // `coda-model-provider` names a model service another plugin provides (docs/plugins.md「模型服务」); `coda-model-url` is the
56
+ // address of one, its Key and API in fields beside it (`modelKey`, `modelApi`), which Coda checks (「检查可用模型」).
57
+ const STRING_FORMATS = ['duration', 'coda-repo', 'coda-directory', 'coda-model-provider', 'coda-model-url'];
58
+ export const MODEL_PROVIDER = /^[a-z][a-z0-9-]{0,39}\/[a-z][a-z0-9-]{0,39}$/;
59
+ // How many model services a setting of several may name.
60
+ const MODEL_PROVIDERS_AT_MOST = 20;
61
+ // A command's name, as a shell finds it on the PATH; Coda's own come first and are no plugin's.
62
+ const COMMAND = /^[a-z][a-z0-9-]{0,39}$/, CODA_COMMANDS = ['coda-run', 'coda-event'];
63
+ // Where in the clients a view may stay (docs/plugins.md「视图」): beside a session.
64
+ export const VIEW_PLACES = ['side', 'page'];
65
+ // What a view's entry in the clients can show (docs/plugins.md「视图」): a count the clients keep themselves, not one the
66
+ // plugin reports. A page's beside its title — `workflow-runs`: the runs going on, or waiting on the user; `machines`: the
67
+ // view's machines and which answer —, a view beside sessions' above the composer — `changes`: what the session changed
68
+ // in its worktree.
69
+ export const VIEW_READOUTS = ['workflow-runs', 'machines', 'changes'];
70
+ // Which sessions a view beside sessions is for, by where they work (docs/plugins.md「视图」): `worktree`, only those in a
71
+ // worktree of their own.
72
+ export const VIEW_CHECKOUTS = ['worktree'];
73
+ // When else than its views' opening the plugin host loads a plugin's module (docs/plugins.md「插件宿主」).
74
+ const ACTIVATION_EVENTS = ['onStartupFinished'];
75
+ // What a view may take over opening (`handles`): the kind of a Coda reference, as `coda://<kind>/…` names it.
76
+ export const REFERENCE_KIND = /^[a-z][a-z0-9-]{0,39}$/;
77
+ // An SF Symbol's name, as a view's icon is given.
78
+ const SYMBOL = /^[a-z0-9]+(\.[a-z0-9]+)*$/;
79
+
80
+ // ---- Declarations ----
81
+ // The JSON Schema Coda reads: a scalar, a choice of several among fixed options (an array of `items.anyOf` constants, as a
82
+ // form's multi-select), several model services (an array of `items` of format `coda-model-provider`, which the
83
+ // machine's model services are offered for), or an object whose entries all share one schema (VS Code shows such a setting as a table): a single
84
+ // value, an Agent combination, or a row of named columns, each a single value, secrets among them (docs/plugins.md「类型」). Anything else is refused when the plugin is read, not ignored. `level`: what is read, a
85
+ // setting, a table's entry, or a column of its rows.
86
+ const COLUMN = /^[a-z][A-Za-z0-9]{0,39}$/, OPTION = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
87
+ function schema(value, where, level = 'setting') {
88
+ check(value && typeof value === 'object' && !Array.isArray(value), 'invalid_plugin', `${where} 须为 JSON Schema 对象`);
89
+ const known = ['type', 'default', 'description', 'markdownDescription', 'title', 'enum', 'enumDescriptions', 'minimum', 'maximum', 'format', 'order',
90
+ ...{ setting: ['additionalProperties', 'propertyNames', 'writeOnly', 'modelKey', 'modelApi', 'items', 'uniqueItems'], entry: ['properties', 'required'],
91
+ column: ['writeOnly', 'modelKey', 'modelApi'] }[level]];
92
+ check(level !== 'column' || value.type !== 'object', 'invalid_plugin', `${where}:一列只能是单个值`);
93
+ check(level !== 'entry' || value.type !== 'object' || value.format === 'coda-agent' || value.properties !== undefined, 'invalid_plugin',
94
+ `${where}:表格的每一项只能是单个值、Agent 组合,或 properties 写明的 1–20 列`);
95
+ check(value.type !== 'array' || level === 'setting', 'invalid_plugin', `${where}:多选只能是插件的一项设置,不能放进表格`);
96
+ const extra = Object.keys(value).filter(k => !known.includes(k));
97
+ check(!extra.length, 'invalid_plugin', `${where} 里有 Coda 不支持的键:${extra.join('、')}`);
98
+ check((value.properties === undefined && value.required === undefined) || (value.type === 'object' && value.format === undefined), 'invalid_plugin',
99
+ `${where}:只有一行几列时才写 properties 与 required`);
100
+ // A secret is a text of its own, with nothing to fall back on: what it is cannot be shown, so neither offered nor defaulted.
101
+ check(value.writeOnly === undefined || typeof value.writeOnly === 'boolean', 'invalid_plugin', `${where} 的 writeOnly 须为开或关`);
102
+ check(!value.writeOnly || (value.type === 'string' && value.format === undefined && value.enum === undefined && value.default === undefined),
103
+ 'invalid_plugin', `${where}:密文设置(writeOnly)只能是没有 format、enum 与默认值的文本`);
104
+ const agent = value.format === 'coda-agent';
105
+ check(['boolean', 'integer', 'number', 'string', 'object', 'array'].includes(value.type), 'invalid_plugin', `${where} 的 type 不受支持:${value.type}`);
106
+ check((value.items === undefined && value.uniqueItems === undefined) || value.type === 'array', 'invalid_plugin', `${where}:只有多选(type: array)才写 items 与 uniqueItems`);
107
+ check(value.format === undefined || agent || value.format === 'coda-agent-match' || STRING_FORMATS.includes(value.format), 'invalid_plugin', `${where} 的 format 不受支持:${value.format}`);
108
+ check(!agent || value.type === 'object', 'invalid_plugin', `${where}:coda-agent 是对象`);
109
+ check(!STRING_FORMATS.includes(value.format) || value.type === 'string', 'invalid_plugin', `${where}:${value.format} 是文本`);
110
+ check(value.enum === undefined || (Array.isArray(value.enum) && value.enum.length && ['string', 'integer', 'number'].includes(value.type)), 'invalid_plugin', `${where} 的 enum 无效`);
111
+ check((value.modelKey === undefined && value.modelApi === undefined) || value.format === 'coda-model-url', 'invalid_plugin',
112
+ `${where}:modelKey 与 modelApi 只写在 coda-model-url 的地址上`);
113
+ const out = { type: value.type, ...(value.format ? { format: value.format } : {}), ...(value.enum ? { enum: value.enum } : {}),
114
+ ...(value.minimum !== undefined ? { minimum: value.minimum } : {}), ...(value.maximum !== undefined ? { maximum: value.maximum } : {}),
115
+ ...(typeof value.description === 'string' ? { description: value.description } : typeof value.markdownDescription === 'string' ? { description: value.markdownDescription } : {}),
116
+ ...(typeof value.title === 'string' ? { title: value.title } : {}), ...(Number.isFinite(value.order) ? { order: value.order } : {}),
117
+ ...(value.writeOnly ? { writeOnly: true } : {}),
118
+ ...(value.modelKey !== undefined ? { modelKey: value.modelKey } : {}), ...(value.modelApi !== undefined ? { modelApi: value.modelApi } : {}) };
119
+ if (value.type === 'array' && value.items?.format === 'coda-model-provider') {
120
+ // Several model services (docs/plugins.md「类型」): each `<plugin>/<id>`, chosen among those the machine offers;
121
+ // `modelApis` the interfaces its plugin takes, the only ones offered for it.
122
+ const items = value.items;
123
+ check(value.format === undefined && value.minimum === undefined && value.maximum === undefined, 'invalid_plugin', `${where}:多个模型服务不写 format、minimum 与 maximum`);
124
+ check(value.uniqueItems === undefined || value.uniqueItems === true, 'invalid_plugin', `${where}:多个模型服务的 uniqueItems 只能是 true`);
125
+ check(Object.keys(items).every(k => ['type', 'format', 'modelApis'].includes(k)) && items.type === 'string', 'invalid_plugin',
126
+ `${where}:多个模型服务写 items: { type: string, format: coda-model-provider, modelApis }`);
127
+ check(items.modelApis === undefined || (Array.isArray(items.modelApis) && items.modelApis.length >= 1 && items.modelApis.length <= 10
128
+ && items.modelApis.every(api => typeof api === 'string' && COMMAND.test(api))), 'invalid_plugin', `${where}.items.modelApis 须为 1–10 个接口名,如 anthropic-messages`);
129
+ out.format = 'coda-model-provider';
130
+ if (items.modelApis) out.modelApis = [...new Set(items.modelApis)];
131
+ } else if (value.type === 'array') {
132
+ // A choice of several: each option a constant of its own, with what the client shows for it; chosen ones kept in this order.
133
+ check(value.format === undefined && value.minimum === undefined && value.maximum === undefined, 'invalid_plugin', `${where}:多选不写 format、minimum 与 maximum`);
134
+ check(value.uniqueItems === undefined || value.uniqueItems === true, 'invalid_plugin', `${where}:多选的 uniqueItems 只能是 true(每项至多选一次)`);
135
+ const items = value.items, options = items?.anyOf;
136
+ check(items && typeof items === 'object' && !Array.isArray(items) && Object.keys(items).every(k => k === 'anyOf') && Array.isArray(options)
137
+ && options.length >= 1 && options.length <= 50, 'invalid_plugin', `${where}:多选写 items: { anyOf: [{ const, title, description }] },1–50 项`);
138
+ out.options = options.map((option, i) => {
139
+ check(option && typeof option === 'object' && !Array.isArray(option) && Object.keys(option).every(k => ['const', 'title', 'description'].includes(k))
140
+ && typeof option.const === 'string' && OPTION.test(option.const), 'invalid_plugin',
141
+ `${where}.items.anyOf[${i}] 须为 { const, title, description },const 只用字母、数字和「._-」`);
142
+ check(['title', 'description'].every(k => option[k] === undefined || (typeof option[k] === 'string' && option[k].length <= 400)), 'invalid_plugin',
143
+ `${where}.items.anyOf[${i}] 的 title 与 description 须为不超过 400 字的文字`);
144
+ return { value: option.const, ...(option.title ? { title: option.title } : {}), ...(option.description ? { description: option.description } : {}) };
145
+ });
146
+ check(new Set(out.options.map(o => o.value)).size === out.options.length, 'invalid_plugin', `${where}:多选的 const 不能重复`);
147
+ } else if (value.type === 'object' && !agent && level === 'entry') {
148
+ // A row of columns: each a single value, the row's `required` ones always there (given, or by their default).
149
+ const columns = value.properties;
150
+ check(columns && typeof columns === 'object' && !Array.isArray(columns) && Object.keys(columns).length >= 1 && Object.keys(columns).length <= 20,
151
+ 'invalid_plugin', `${where}:表格的每一项只能是单个值、Agent 组合,或 properties 写明的 1–20 列`);
152
+ out.columns = Object.fromEntries(Object.entries(columns).map(([name, column]) => {
153
+ check(COLUMN.test(name), 'invalid_plugin', `${where} 的列名只用字母和数字,以小写字母开头:${name}`);
154
+ return [name, schema(column, `${where}.properties.${name}`, 'column')];
155
+ }));
156
+ const required = value.required ?? [];
157
+ check(Array.isArray(required) && required.every(name => typeof name === 'string' && Object.hasOwn(out.columns, name)) && new Set(required).size === required.length,
158
+ 'invalid_plugin', `${where}.required 须为本行的列名,不重复`);
159
+ modelFields(out.columns, `${where} 的列`);
160
+ if (required.length) out.required = [...required];
161
+ } else if (value.type === 'object' && !agent) {
162
+ out.entries = schema(value.additionalProperties ?? invalidPlugin(`${where} 需要 additionalProperties`), `${where}.additionalProperties`, 'entry');
163
+ if (value.propertyNames !== undefined) {
164
+ check(value.propertyNames && Object.keys(value.propertyNames).every(k => k === 'format') && value.propertyNames.format === 'coda-agent-match',
165
+ 'invalid_plugin', `${where}.propertyNames 只支持 format: coda-agent-match`);
166
+ out.keys = 'coda-agent-match';
167
+ }
168
+ }
169
+ if (value.default !== undefined) out.default = checkValue(out, value.default, `${where} 的默认值`, 'invalid_plugin');
170
+ return out;
171
+ }
172
+ const invalidPlugin = message => { throw new ContractError('invalid_plugin', message); };
173
+ // A group of fields (a row's columns, or a plugin's single settings) has at most one model address (docs/plugins.md
174
+ // 「检查可用模型」), its Key a secret field of the group and its API a text field of it.
175
+ function modelFields(fields, where) {
176
+ const urls = Object.entries(fields).filter(([, field]) => field.format === 'coda-model-url');
177
+ check(urls.length <= 1, 'invalid_plugin', `${where}里至多一个 coda-model-url`);
178
+ for (const [name, field] of urls) {
179
+ check(field.modelKey === undefined || (typeof field.modelKey === 'string' && Object.hasOwn(fields, field.modelKey) && fields[field.modelKey].writeOnly),
180
+ 'invalid_plugin', `${where}「${name}」的 modelKey 须为同组的密文`);
181
+ check(field.modelApi === undefined || (typeof field.modelApi === 'string' && Object.hasOwn(fields, field.modelApi) && fields[field.modelApi].type === 'string'
182
+ && !fields[field.modelApi].writeOnly), 'invalid_plugin', `${where}「${name}」的 modelApi 须为同组的文本`);
183
+ }
184
+ }
185
+
186
+ // The category it is listed under (docs/plugins.md「商店」): the first of its manifest's `categories`, as VS Code's are
187
+ // written; empty when it names none. Not checked: readPlugin does that.
188
+ export const manifestCategory = manifest => Array.isArray(manifest?.categories) && typeof manifest.categories[0] === 'string' ? manifest.categories[0].trim().slice(0, 32) : '';
189
+ // A plugin as its directory declares it. `id` is the one its manifest says (for a package whose name cannot be one: a scoped
190
+ // package's, docs/plugins.md「插件的内容」), else its package name; a package from npm's without one, whatever npm calls it,
191
+ // the ID its name gives (docs/plugins.md「从 npm 安装」): its package.json is not changed, for what it imports of itself.
192
+ export function readPlugin(dir, source) {
193
+ let manifest;
194
+ try { manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); }
195
+ catch (e) { throw new ContractError('invalid_plugin', `${dir} 没有可读的 package.json:${e.message}`); }
196
+ const named = manifest?.id !== undefined;
197
+ if (named) manifest = { ...manifest, name: manifest.id };
198
+ else if (source?.package && typeof manifest?.name === 'string') try { manifest = { ...manifest, name: npmPluginId(manifest.name) }; } catch {}
199
+ check(typeof manifest.name === 'string' && ID.test(manifest.name), 'invalid_plugin', `package.json 的 ${named ? 'id' : 'name'} 只能用小写字母、数字和「-」`);
200
+ check(manifest.contributes === undefined || (manifest.contributes && typeof manifest.contributes === 'object' && !Array.isArray(manifest.contributes)),
201
+ 'invalid_plugin', 'contributes 须为对象');
202
+ const offers = offerDeclarations(manifest.contributes, CONTRIBUTES);
203
+ check(manifest.categories === undefined || (Array.isArray(manifest.categories) && manifest.categories.every(c => typeof c === 'string')),
204
+ 'invalid_plugin', 'categories 须为文本的数组');
205
+ const configuration = manifest.contributes?.configuration ?? {};
206
+ const properties = configuration.properties ?? {};
207
+ check(properties && typeof properties === 'object', 'invalid_plugin', 'contributes.configuration.properties 须为对象');
208
+ const declared = Object.fromEntries(Object.entries(properties).map(([key, value]) => {
209
+ check(KEY.test(key) && key.split('.')[0] === manifest.name, 'invalid_plugin', `设置名须以「${manifest.name}.」开头,只用字母和数字:${key}`);
210
+ return [key, schema(value, key)];
211
+ }));
212
+ modelFields(Object.fromEntries(Object.entries(declared).filter(([, spec]) => spec.type !== 'object')), '单值设置');
213
+ // The settings it cannot work without (docs/plugins.md「必填」), as JSON Schema says it: their names, beside `properties`.
214
+ const required = configuration.required ?? [];
215
+ check(Array.isArray(required) && required.every(key => typeof key === 'string' && Object.hasOwn(declared, key)) && new Set(required).size === required.length,
216
+ 'invalid_plugin', 'contributes.configuration.required 须为本插件声明过的设置名的数组,不重复');
217
+ const files = sub => existsSync(join(dir, sub)) ? [...new Set(readdirSync(join(dir, sub)).map(f => /^(.+)\.bpmn$/.exec(f)?.[1]).filter(Boolean))].sort() : [];
218
+ const workflows = files('.coda/workflows');
219
+ // Each capability it lists has its file, and every file there is listed: what it brings is what it says.
220
+ const listed = manifest.contributes?.capabilities ?? [];
221
+ check(Array.isArray(listed) && listed.every(name => typeof name === 'string' && NAME.test(name)) && new Set(listed).size === listed.length, 'invalid_plugin',
222
+ 'contributes.capabilities 须为能力名的数组(小写字母、数字和「-」,以字母开头),不重复');
223
+ const capabilities = [...listed].sort(), found = files('.coda/capabilities');
224
+ check(capabilities.length === found.length && capabilities.every((name, i) => name === found[i]), 'invalid_plugin',
225
+ `contributes.capabilities 与 .coda/capabilities/ 里的文件不一致:列出 ${capabilities.join('、') || '无'},文件 ${found.join('、') || '无'}`);
226
+ const both = capabilities.filter(name => workflows.includes(name));
227
+ check(!both.length, 'invalid_plugin', `${both.join('、')} 既是流程又是能力`);
228
+ // Its module the plugin host runs (docs/plugins.md「插件宿主」), as VS Code's `main`: a JavaScript file in its directory.
229
+ const main = manifest.main;
230
+ check(main === undefined || (typeof main === 'string' && /\.m?js$/.test(main) && !main.startsWith('/') && !normalize(main).split('/').includes('..')
231
+ && existsSync(join(dir, main)) && statSync(join(dir, main)).isFile()), 'invalid_plugin', 'main 须是插件目录里的 .js 或 .mjs 文件');
232
+ // When the host loads that module, as VS Code's `activationEvents`: the first time one of its views opens, as by default,
233
+ // or as soon as the backend has started (`onStartupFinished`), for one that works with no view open.
234
+ const activation = manifest.activationEvents ?? [];
235
+ check(Array.isArray(activation) && activation.every(e => ACTIVATION_EVENTS.includes(e)) && new Set(activation).size === activation.length
236
+ && (!activation.length || main), 'invalid_plugin', `activationEvents 须为 ${ACTIVATION_EVENTS.join('、')} 组成的数组,不重复,且插件须有 main`);
237
+ const instructions = manifest.contributes?.instructions;
238
+ check(instructions === undefined || (typeof instructions === 'string' && instructions.trim() && [...instructions].length <= INSTRUCTIONS_LIMIT), 'invalid_plugin',
239
+ `contributes.instructions 须为不超过 ${INSTRUCTIONS_LIMIT} 字的说明`);
240
+ // The concurrency groups it brings (docs/plugins.md「并发组」): a name any process may take, and the setting of its own that
241
+ // says how many the group lets in at once (an integer).
242
+ const groups = manifest.contributes?.groups ?? [];
243
+ check(Array.isArray(groups) && groups.every(g => g && typeof g === 'object' && Object.keys(g).every(k => ['name', 'capacity'].includes(k))
244
+ && typeof g.name === 'string' && /^\S{1,100}$/.test(g.name) && declared[g.capacity]?.type === 'integer')
245
+ && new Set(groups.map(g => g.name)).size === groups.length, 'invalid_plugin',
246
+ 'contributes.groups 须为 { name, capacity } 的数组:name 为 1–100 个非空白字符、不重复,capacity 是本插件声明的整数设置');
247
+ // The events it publishes (docs/plugins.md「事件」): a name a workflow subscribes to, whether one starts the workflows
248
+ // subscribed to it (`starts`, as by default) or only reaches the runs going for its subject, and whether it ends its subject
249
+ // (`ends`: a pull request closed), which a run re-run after it is told again.
250
+ const events = manifest.contributes?.events ?? [];
251
+ check(Array.isArray(events) && events.length <= 50 && events.every(e => e && typeof e === 'object' && Object.keys(e).every(k => ['name', 'starts', 'ends'].includes(k))
252
+ && typeof e.name === 'string' && EVENT.test(e.name) && !CODA_EVENT_DOMAINS.includes(e.name.split('.')[0])
253
+ && ['starts', 'ends'].every(k => e[k] === undefined || typeof e[k] === 'boolean'))
254
+ && new Set(events.map(e => e.name)).size === events.length, 'invalid_plugin',
255
+ `contributes.events 须为 { name, starts?, ends? } 的数组,至多 50 个:name 是「<域>.<名>」(小写字母、数字和「_」,以字母开头,不超过 100 个字符,不重复,域不能是 Coda 自己的 ${CODA_EVENT_DOMAINS.join('、')}),starts、ends 是真假值`);
256
+ // The commands it puts on the PATH of what starts in a project it applies to (docs/plugins.md「命令」): a name, and what
257
+ // runs for it (by `sh`, given the arguments; adapters/launch.mjs writeCommands).
258
+ const commands = manifest.contributes?.commands ?? [];
259
+ check(Array.isArray(commands) && commands.every(c => c && typeof c === 'object' && Object.keys(c).every(k => ['name', 'command'].includes(k))
260
+ && typeof c.name === 'string' && COMMAND.test(c.name) && !CODA_COMMANDS.includes(c.name)
261
+ && typeof c.command === 'string' && c.command.trim() && c.command.length <= 4000 && !/[\0\r\n]/.test(c.command))
262
+ && new Set(commands.map(c => c.name)).size === commands.length, 'invalid_plugin',
263
+ `contributes.commands 须为 { name, command } 的数组:name 是命令名(小写字母、数字和「-」,以字母开头,不重复,不能是 Coda 自己的 ${CODA_COMMANDS.join('、')}),command 是一行命令`);
264
+ // The requests clients may send it (docs/plugins.md「端点」): an id, and what answers one (by `sh`, the request on its
265
+ // standard input; components/plugins/endpoints.mjs); one may say which of its settings it checks (`checks`), beside which
266
+ // clients offer to check what is filled in (docs/plugins.md「设置的检查」), one endpoint a setting.
267
+ const endpoints = manifest.contributes?.endpoints ?? [];
268
+ check(Array.isArray(endpoints) && endpoints.length <= 20 && endpoints.every(e => e && typeof e === 'object' && Object.keys(e).every(k => ['id', 'command', 'checks'].includes(k))
269
+ && typeof e.id === 'string' && COMMAND.test(e.id)
270
+ && typeof e.command === 'string' && e.command.trim() && e.command.length <= 4000 && !/[\0\r\n]/.test(e.command)
271
+ && (e.checks === undefined || (typeof e.checks === 'string' && Object.hasOwn(declared, e.checks))))
272
+ && new Set(endpoints.map(e => e.id)).size === endpoints.length
273
+ && new Set(endpoints.filter(e => e.checks).map(e => e.checks)).size === endpoints.filter(e => e.checks).length, 'invalid_plugin',
274
+ 'contributes.endpoints 须为 { id, command, checks? } 的数组,至多 20 个:id 是小写字母、数字和「-」,以字母开头,不重复;command 是一行命令;checks 是本插件声明的设置名,一个设置至多一个端点检查');
275
+ // The pages the clients open for it (docs/plugins.md「视图」): an id, what the window or page is called, the HTML file
276
+ // in its directory the client starts it from, in a sandbox, where in the clients it stays, with its icon there, and the
277
+ // references it opens in place of the client (`handles`, as a VS Code custom editor takes the files of its type).
278
+ const views = manifest.contributes?.views ?? [];
279
+ check(Array.isArray(views) && views.length <= 10 && views.every(v => v && typeof v === 'object' && Object.keys(v).every(k => ['id', 'title', 'entry', 'in', 'icon', 'handles', 'readout', 'checkout'].includes(k))
280
+ && typeof v.id === 'string' && COMMAND.test(v.id)
281
+ && typeof v.title === 'string' && v.title.trim() && [...v.title].length <= 40 && !/[\0\r\n]/.test(v.title)
282
+ && typeof v.entry === 'string' && /\.html$/.test(v.entry) && !v.entry.startsWith('/') && !normalize(v.entry).split('/').includes('..')
283
+ && (v.in === undefined || (Array.isArray(v.in) && v.in.every(place => VIEW_PLACES.includes(place)) && new Set(v.in).size === v.in.length))
284
+ && (v.icon === undefined || (typeof v.icon === 'string' && v.icon.length <= 100 && SYMBOL.test(v.icon)))
285
+ && (v.handles === undefined || (Array.isArray(v.handles) && v.handles.length <= 8 && v.handles.every(k => typeof k === 'string' && REFERENCE_KIND.test(k))
286
+ && new Set(v.handles).size === v.handles.length))
287
+ && (v.readout === undefined || VIEW_READOUTS.includes(v.readout)) && (v.checkout === undefined || VIEW_CHECKOUTS.includes(v.checkout)))
288
+ && new Set(views.map(v => v.id)).size === views.length, 'invalid_plugin',
289
+ `contributes.views 须为 { id, title, entry, in?, icon?, handles? } 的数组,至多 10 个:id 是小写字母、数字和「-」,以字母开头,不重复;title 是不超过 40 字的一行;entry 是插件目录里的 .html 文件;in 是它常驻的位置(${VIEW_PLACES.join('、')}),不重复;icon 是 SF Symbol 的名字;handles 是它接管打开的引用种类(如 workflow-runs),至多 8 个,不重复;readout 是它的读数(${VIEW_READOUTS.join('、')});checkout 是它只给哪种工作位置的会话(${VIEW_CHECKOUTS.join('、')})`);
290
+ const handled = views.flatMap(v => v.handles ?? []);
291
+ check(new Set(handled).size === handled.length, 'invalid_plugin', 'contributes.views:同一种引用只能由一个视图接管');
292
+ for (const v of views) {
293
+ const at = join(dir, v.entry);
294
+ check(existsSync(at) && statSync(at).isFile(), 'invalid_plugin', `视图 ${v.id} 的 ${v.entry} 不存在`);
295
+ }
296
+ // The addresses it gives Git's credentials for (docs/plugins.md「Git 凭据」): an `https://` host, and what runs as Git's
297
+ // credential helper for it (by `sh`, given Git's arguments; adapters/launch.mjs writeCredentialHelpers).
298
+ const gitCredentials = manifest.contributes?.gitCredentials ?? [];
299
+ check(Array.isArray(gitCredentials) && gitCredentials.every(c => c && typeof c === 'object' && Object.keys(c).every(k => ['url', 'command'].includes(k))
300
+ && typeof c.url === 'string' && /^https:\/\/[a-z0-9.-]+(:\d+)?$/.test(c.url)
301
+ && typeof c.command === 'string' && c.command.trim() && c.command.length <= 4000 && !/[\0\r\n]/.test(c.command))
302
+ && new Set(gitCredentials.map(c => c.url)).size === gitCredentials.length, 'invalid_plugin',
303
+ 'contributes.gitCredentials 须为 { url, command } 的数组:url 是 https:// 加小写主机名(可带端口,不带路径),不重复;command 是一行命令');
304
+ // The bundled services it turns on (docs/supervisor.md「独立服务进程」): one no installed plugin names does not run.
305
+ const services = manifest.contributes?.services ?? [];
306
+ check(Array.isArray(services) && services.every(id => typeof id === 'string' && NAME.test(id)) && new Set(services).size === services.length,
307
+ 'invalid_plugin', 'contributes.services 须为服务 ID 的数组,不重复');
308
+ // The repositories it can list for a client choosing one (docs/plugins.md「仓库来源」): one command, run by `sh` as a
309
+ // panel's is, printing them (components/plugins/repositories.mjs).
310
+ const repositories = manifest.contributes?.repositories;
311
+ check(repositories === undefined || (repositories && typeof repositories === 'object' && !Array.isArray(repositories) && Object.keys(repositories).every(k => k === 'command')
312
+ && typeof repositories.command === 'string' && repositories.command.trim() && repositories.command.length <= 4000 && !/[\0\r\n]/.test(repositories.command)),
313
+ 'invalid_plugin', 'contributes.repositories 须为 { command }:command 是一行命令');
314
+ // What makes this machine ready for it (docs/plugins.md「准备运行环境」): one command, run by `sh` as a panel's is
315
+ // (components/plugins/plugin-setup.mjs) when the user installs or updates it.
316
+ const setup = manifest.contributes?.setup;
317
+ check(setup === undefined || (setup && typeof setup === 'object' && !Array.isArray(setup) && Object.keys(setup).every(k => k === 'command')
318
+ && typeof setup.command === 'string' && setup.command.trim() && setup.command.length <= 4000 && !/[\0\r\n]/.test(setup.command)),
319
+ 'invalid_plugin', 'contributes.setup 须为 { command }:command 是一行命令');
320
+ const skills = skillDeclarations(manifest.contributes?.skills, dir);
321
+ const environment = environmentDeclarations(manifest.contributes?.environment, declared), path = pathDeclarations(manifest.contributes?.path, dir);
322
+ const modelProviders = modelProviderDeclarations(manifest.contributes?.modelProviders, declared);
323
+ const executors = executorDeclarations(manifest.contributes?.executors);
324
+ const mcpServers = mcpDeclarations(manifest.contributes?.mcpServers, declared);
325
+ const dependencies = dependencyDeclarations(manifest.extensionDependencies, manifest.name);
326
+ return { id: manifest.name, displayName: typeof manifest.displayName === 'string' ? manifest.displayName : manifest.name,
327
+ description: typeof manifest.description === 'string' ? manifest.description : '', version: String(manifest.version ?? ''), category: manifestCategory(manifest),
328
+ source, dir, title: typeof configuration.title === 'string' ? configuration.title : manifest.displayName ?? manifest.name, properties: declared,
329
+ ...(main ? { main: posix.normalize(main) } : {}), ...(activation.includes('onStartupFinished') ? { startup: true } : {}),
330
+ ...(required.length ? { required: [...required] } : {}), workflows,
331
+ capabilities, services: [...services], groups: groups.map(g => ({ name: g.name, capacity: g.capacity })), commands: commands.map(c => ({ name: c.name, command: c.command })),
332
+ ...(events.length ? { events: events.map(e => ({ name: e.name, starts: e.starts !== false, ...(e.ends ? { ends: true } : {}) })) } : {}),
333
+ gitCredentials: gitCredentials.map(c => ({ url: c.url, command: c.command })),
334
+ endpoints: endpoints.map(e => ({ id: e.id, command: e.command, ...(e.checks ? { checks: e.checks } : {}) })),
335
+ // The entry as one path in its directory (`./view/a.html`, `view//a.html` are `view/a.html`): the clients load it by that.
336
+ views: views.map(v => ({ id: v.id, title: v.title, entry: posix.normalize(v.entry), ...(v.in?.length ? { in: [...v.in] } : {}), ...(v.icon ? { icon: v.icon } : {}),
337
+ ...(v.handles?.length ? { handles: [...v.handles] } : {}), ...(v.readout ? { readout: v.readout } : {}),
338
+ ...(v.checkout ? { checkout: v.checkout } : {}) })),
339
+ // What it writes for another plugin's points, as written (components/plugins/plugin-extensions.mjs offerDeclarations).
340
+ ...(Object.keys(offers).length ? { offers } : {}),
341
+ agents: agentDeclarations(manifest.contributes?.agents, dir),
342
+ dependencies, extensionPoints: extensionPointDeclarations(manifest.contributes?.extensionPoints, CONTRIBUTES),
343
+ extensions: extensionDeclarations(manifest.contributes?.extensions, dependencies),
344
+ ...(instructions ? { instructions: instructions.trim() } : {}), ...(setup ? { setup: { command: setup.command } } : {}), ...(skills.length ? { skills } : {}),
345
+ ...(environment.length ? { environment } : {}), ...(path.length ? { path } : {}), ...(mcpServers.length ? { mcpServers } : {}),
346
+ ...(modelProviders.length ? { modelProviders } : {}), ...(executors.length ? { executors } : {}),
347
+ ...(repositories ? { repositories: { command: repositories.command } } : {}) };
348
+ }
349
+ // The environment variables it gives what starts in a project it applies to (docs/plugins.md「变量与 PATH」): each a value
350
+ // written out, which may name one of its own settings of a single value (`${config:<设置名>}`, as it is for the project),
351
+ // its directory (`${CODA_PLUGIN_PATH}`) or its state on the backend (`${CODA_PLUGIN_STATE}`). The PATH is `contributes.path`'s
352
+ // to say; Coda's own variables, and the shells' hooks it sets, are not a plugin's to give.
353
+ const RESERVED_VARIABLE = /^(PATH|ZDOTDIR|BASH_ENV|CODA_.*|BASH_FUNC_.*)$/;
354
+ const PLACE = /\$\{([^}]*)\}/g;
355
+ const PLUGIN_PLACES = ['CODA_PLUGIN_PATH', 'CODA_PLUGIN_STATE'];
356
+ // `where`: what declares them, for what Coda says of one it cannot read (an MCP server's `env` is written the same way).
357
+ function environmentDeclarations(raw, declared, where = 'contributes.environment') {
358
+ if (raw === undefined) return [];
359
+ check(raw && typeof raw === 'object' && !Array.isArray(raw) && Object.keys(raw).length <= 100, 'invalid_plugin',
360
+ `${where} 须为「变量名 → 值」的对象,不超过 100 个`);
361
+ return Object.entries(raw).map(([name, value]) => {
362
+ check(VARIABLE.test(name) && !RESERVED_VARIABLE.test(name), 'invalid_plugin',
363
+ `${where} 的变量名只用字母、数字和「_」,不以数字开头,也不能是 PATH、ZDOTDIR、BASH_ENV 或 CODA_ 开头的:${name}`);
364
+ check(typeof value === 'string' && value.length <= 4096 && !value.includes('\0'), 'invalid_plugin', `${where} 的 ${name} 须为不超过 4096 字的文字`);
365
+ for (const [, place] of value.matchAll(PLACE)) {
366
+ const setting = /^config:(.+)$/.exec(place)?.[1];
367
+ check(PLUGIN_PLACES.includes(place) || (setting && ['string', 'number', 'integer', 'boolean'].includes(declared[setting]?.type)), 'invalid_plugin',
368
+ `${where} 的 ${name} 引用了 \${${place}}:只能是 config:<本插件的单值设置>、CODA_PLUGIN_PATH 或 CODA_PLUGIN_STATE`);
369
+ // A variable is seen by all that starts there and by whatever prints the environment: a secret is read from its file.
370
+ check(!declared[setting]?.writeOnly, 'invalid_plugin', `${where} 的 ${name} 引用了密文设置 ${setting}:密文不进环境变量,插件的进程从 CODA_PLUGIN_SECRETS 给的文件读`);
371
+ }
372
+ return { name, value };
373
+ });
374
+ }
375
+ // The MCP servers it gives the agents of a project it applies to (docs/plugins.md「MCP 服务器」), by name, as Claude Code's
376
+ // `.mcp.json` has them: a program the agent starts and talks to on its standard input and output (`command` and its
377
+ // `args`, not run by a shell), given the variables `env` names, written as `contributes.environment`'s. What it runs may name
378
+ // the plugin's directory, its state on the backend and the backend's own Node; a server's settings reach it through `env`.
379
+ const MCP_PLACES = ['CODA_PLUGIN_PATH', 'CODA_PLUGIN_STATE', 'CODA_NODE'];
380
+ function mcpDeclarations(raw, declared) {
381
+ if (raw === undefined) return [];
382
+ check(raw && typeof raw === 'object' && !Array.isArray(raw) && Object.keys(raw).length <= 20, 'invalid_plugin',
383
+ 'contributes.mcpServers 须为「服务器名 → { command, args, env }」的对象,不超过 20 个');
384
+ return Object.entries(raw).map(([name, server]) => {
385
+ const where = `contributes.mcpServers 的 ${name}`;
386
+ check(NAME.test(name) && name.length <= 40 && name !== 'coda', 'invalid_plugin',
387
+ `contributes.mcpServers 的服务器名只用小写字母、数字和「-」,以字母开头,至多 40 个字符,不能是 Coda 自己的 coda:${name}`);
388
+ check(server && typeof server === 'object' && !Array.isArray(server) && Object.keys(server).every(k => ['type', 'command', 'args', 'env'].includes(k))
389
+ && (server.type === undefined || server.type === 'stdio'), 'invalid_plugin', `${where} 须为 { command, args, env }:现在只支持由 Agent 在本机启动的服务器(stdio)`);
390
+ const word = (value, label) => {
391
+ check(typeof value === 'string' && value && value.length <= 4096 && !/[\0\r\n]/.test(value), 'invalid_plugin', `${where} 的 ${label} 须为一行不超过 4096 字的文字`);
392
+ for (const [, place] of value.matchAll(PLACE)) check(MCP_PLACES.includes(place), 'invalid_plugin',
393
+ `${where} 的 ${label} 引用了 \${${place}}:只能是 CODA_PLUGIN_PATH、CODA_PLUGIN_STATE 或 CODA_NODE,设置经 env 交给它`);
394
+ return value;
395
+ };
396
+ const args = server.args ?? [];
397
+ check(Array.isArray(args) && args.length <= 100, 'invalid_plugin', `${where} 的 args 须为不超过 100 项的数组`);
398
+ return { name, command: word(server.command, 'command'), args: args.map((arg, i) => word(arg, `args[${i}]`)),
399
+ env: environmentDeclarations(server.env, declared, `${where} 的 env`) };
400
+ });
401
+ }
402
+ // The model services it provides (docs/plugins.md「模型服务」), for another plugin to choose by `<plugin>/<id>` with a setting of
403
+ // format `coda-model-provider`, neither needing the other: what it is called, the API it speaks, and where it is — an address
404
+ // written out that may name its own settings of a single value (`${config:…}`), and its key, one of its own secrets.
405
+ function modelProviderDeclarations(raw, declared) {
406
+ if (raw === undefined) return [];
407
+ check(Array.isArray(raw) && raw.length <= 10, 'invalid_plugin', 'contributes.modelProviders 须为不超过 10 项的数组');
408
+ const providers = raw.map((p, i) => {
409
+ const where = `contributes.modelProviders[${i}]`;
410
+ check(p && typeof p === 'object' && !Array.isArray(p) && Object.keys(p).every(k => ['id', 'title', 'api', 'url', 'key'].includes(k)), 'invalid_plugin',
411
+ `${where} 须为 { id, title, api, url, key? }`);
412
+ check(typeof p.id === 'string' && COMMAND.test(p.id), 'invalid_plugin', `${where} 的 id 只用小写字母、数字和「-」,以字母开头`);
413
+ check(typeof p.title === 'string' && p.title.trim() && [...p.title].length <= 40 && !/[\0\r\n]/.test(p.title), 'invalid_plugin', `${where} 的 title 须为不超过 40 字的一行`);
414
+ check(typeof p.api === 'string' && COMMAND.test(p.api), 'invalid_plugin', `${where} 的 api 只用小写字母、数字和「-」,如 openai-completions`);
415
+ check(typeof p.url === 'string' && p.url && p.url.length <= 4096 && !/[\0\r\n]/.test(p.url), 'invalid_plugin', `${where} 的 url 须为一行地址`);
416
+ for (const [, place] of p.url.matchAll(PLACE)) {
417
+ const setting = /^config:(.+)$/.exec(place)?.[1];
418
+ check(setting && ['string', 'number', 'integer'].includes(declared[setting]?.type) && !declared[setting].writeOnly, 'invalid_plugin',
419
+ `${where} 的 url 只能引用本插件的单值设置(\${config:<设置名>},不能是密文)`);
420
+ }
421
+ const key = p.key === undefined ? null : /^\$\{config:([^}]+)\}$/.exec(p.key)?.[1];
422
+ check(p.key === undefined || (key && declared[key]?.writeOnly), 'invalid_plugin', `${where} 的 key 须为 \${config:<本插件的密文设置>}`);
423
+ return { id: p.id, title: p.title.trim(), api: p.api, url: p.url, ...(key ? { key } : {}) };
424
+ });
425
+ check(new Set(providers.map(p => p.id)).size === providers.length, 'invalid_plugin', 'contributes.modelProviders 的 id 不能重复');
426
+ return providers;
427
+ }
428
+ // The executors it offers this machine's script steps (docs/plugins.md「执行环境」): what each is called, the labels it has
429
+ // (as `coda:runsOn` writes them), and its hook — one command, run by `sh` as a panel's is, that the step's runner calls for
430
+ // each part of the job (resources/workflow-executor.mjs).
431
+ function executorDeclarations(raw) {
432
+ if (raw === undefined) return [];
433
+ check(Array.isArray(raw) && raw.length <= 10, 'invalid_plugin', 'contributes.executors 须为不超过 10 项的数组');
434
+ const executors = raw.map((e, i) => {
435
+ const where = `contributes.executors[${i}]`;
436
+ check(e && typeof e === 'object' && !Array.isArray(e) && Object.keys(e).every(k => ['id', 'title', 'labels', 'command'].includes(k)), 'invalid_plugin',
437
+ `${where} 须为 { id, title, labels, command }`);
438
+ check(typeof e.id === 'string' && COMMAND.test(e.id), 'invalid_plugin', `${where} 的 id 只用小写字母、数字和「-」,以字母开头`);
439
+ check(typeof e.title === 'string' && e.title.trim() && [...e.title].length <= 40 && !/[\0\r\n]/.test(e.title), 'invalid_plugin', `${where} 的 title 须为不超过 40 字的一行`);
440
+ const labels = executorLabels(e.labels, `${where} 的 labels`, 'invalid_plugin');
441
+ check(labels.length, 'invalid_plugin', `${where} 的 labels 至少要有一个标签`);
442
+ check(typeof e.command === 'string' && e.command.trim() && e.command.length <= 4000 && !/[\0\r\n]/.test(e.command), 'invalid_plugin', `${where} 的 command 须为一行命令`);
443
+ return { id: e.id, title: e.title.trim(), labels, command: e.command };
444
+ });
445
+ check(new Set(executors.map(e => e.id)).size === executors.length, 'invalid_plugin', 'contributes.executors 的 id 不能重复');
446
+ return executors;
447
+ }
448
+ // Whether a plugin chooses model services (a setting, or a column of a table's rows, of format `coda-model-provider`): its
449
+ // processes are told those of this machine (CODA_MODEL_PROVIDERS).
450
+ export const choosesModelProviders = plugin => Object.values(plugin.properties).some(spec => spec.format === 'coda-model-provider'
451
+ || spec.entries?.format === 'coda-model-provider' || Object.values(spec.entries?.columns ?? {}).some(column => column.format === 'coda-model-provider'));
452
+ // A model service of a plugin as its settings for every project make it (resolveSettings): its address with what it names
453
+ // filled in, none while a setting it names has no value or it comes to nothing.
454
+ export function modelProviderAddress(provider, values) {
455
+ const url = filler({}, values)(provider.url);
456
+ return url && url.trim() ? url.trim() : null;
457
+ }
458
+ // The directories it puts on the PATH of what starts in a project it applies to: one of its own (relative, as a Skill's),
459
+ // or one in its state on the backend (`${CODA_PLUGIN_STATE}/…`), which its setup may make.
460
+ function pathDeclarations(raw, dir) {
461
+ if (raw === undefined) return [];
462
+ check(Array.isArray(raw) && raw.length <= 20, 'invalid_plugin', 'contributes.path 须为不超过 20 项的目录数组');
463
+ return raw.map(entry => {
464
+ check(typeof entry === 'string' && entry && !/[\0:\n]/.test(entry), 'invalid_plugin', `contributes.path 的每项须为一个目录:${entry}`);
465
+ const state = /^\$\{CODA_PLUGIN_STATE\}(\/.*)?$/.exec(entry);
466
+ const relative = normalize(state ? `.${state[1] ?? ''}` : entry).replace(/\/$/, '');
467
+ check(!relative.startsWith('/') && !relative.split('/').includes('..') && !entry.includes('${', state ? 2 : 0), 'invalid_plugin',
468
+ `contributes.path 的每项须为插件目录里的相对路径,或 \${CODA_PLUGIN_STATE} 下的目录:${entry}`);
469
+ if (state) return relative === '.' ? '${CODA_PLUGIN_STATE}' : `\${CODA_PLUGIN_STATE}/${relative}`;
470
+ let found;
471
+ try { found = statSync(join(dir, relative)); } catch { found = null; }
472
+ check(relative !== '.' && found?.isDirectory(), 'invalid_plugin', `contributes.path 的目录不在插件里:${entry}`);
473
+ return relative;
474
+ });
475
+ }
476
+ // A plugin's environment variables and PATH as they are for one project: its settings there (`settings`), its directory and
477
+ // its state on this backend (`state`, null without one). A value naming a setting with none, or its state without one, is
478
+ // not given; nor is a directory in a state there is not.
479
+ // A value with what it names filled in, or null when one of them has none.
480
+ const filler = (places, settings) => value => {
481
+ let missing = false;
482
+ const filled = value.replace(PLACE, (_, place) => {
483
+ const setting = /^config:(.+)$/.exec(place)?.[1], found = setting ? settings[setting] : places[place];
484
+ if (found === null || found === undefined) missing = true;
485
+ return String(found ?? '');
486
+ });
487
+ return missing ? null : filled;
488
+ };
489
+ export function launchValues(plugin, settings, state) {
490
+ const fill = filler({ CODA_PLUGIN_PATH: plugin.dir, CODA_PLUGIN_STATE: state }, settings);
491
+ return { variables: (plugin.environment ?? []).map(({ name, value }) => ({ name, value: fill(value) })).filter(each => each.value !== null),
492
+ path: (plugin.path ?? []).map(entry => entry.startsWith('${') ? fill(entry) : join(plugin.dir, entry)).filter(Boolean) };
493
+ }
494
+ // A plugin's MCP servers as they are for one project, as ACP has them (`env` a list of names and values): what each runs with
495
+ // what it names filled in, given what every process of the plugin is (`base`, components/plugins/plugins.mjs env) and the
496
+ // variables it declares, as `launchValues` gives them. One naming its state on a backend without one is not given.
497
+ export function mcpValues(plugin, settings, state, base) {
498
+ const fill = filler({ CODA_PLUGIN_PATH: plugin.dir, CODA_PLUGIN_STATE: state, CODA_NODE: process.execPath }, settings);
499
+ return (plugin.mcpServers ?? []).flatMap(server => {
500
+ const command = fill(server.command), args = server.args.map(fill);
501
+ if (command === null || args.includes(null)) return [];
502
+ const env = { ...base };
503
+ for (const { name, value } of server.env) { const filled = fill(value); if (filled !== null) env[name] = filled; }
504
+ return [{ name: server.name, command, args, env: Object.entries(env).map(([name, value]) => ({ name, value })) }];
505
+ });
506
+ }
507
+ // The Agent Skills it brings (docs/plugins.md「Skills」): directories of its own, each with its SKILL.md, named after the
508
+ // directory as the agents find them. They are laid into the working copy of each project it applies to.
509
+ export const SKILL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
510
+ function skillDeclarations(raw, dir) {
511
+ if (raw === undefined) return [];
512
+ check(Array.isArray(raw) && raw.length <= 100, 'invalid_plugin', 'contributes.skills 须为不超过 100 项的目录数组');
513
+ const found = raw.map(path => {
514
+ const relative = typeof path === 'string' ? normalize(path).replace(/\/$/, '') : '';
515
+ check(relative && !relative.startsWith('/') && !relative.split('/').includes('..') && relative !== '.', 'invalid_plugin',
516
+ `contributes.skills 的每项须为插件目录里的相对路径:${path}`);
517
+ const name = basename(relative);
518
+ check(SKILL.test(name), 'invalid_plugin', `Skill 目录名只能用字母、数字、「.」「_」「-」:${path}`);
519
+ let file;
520
+ try { file = statSync(join(dir, relative, 'SKILL.md')); } catch { file = null; }
521
+ check(file?.isFile(), 'invalid_plugin', `Skill 目录里没有 SKILL.md:${path}`);
522
+ const skill = { name, path: join(dir, relative) };
523
+ // Descriptions are for browsing only; a missing or unreadable document is explained when opened.
524
+ try { skill.description = readSkillDocument({ dir, skills: [skill] }, name).description; } catch { /* Keep the declaration. */ }
525
+ return skill;
526
+ });
527
+ const names = found.map(skill => skill.name);
528
+ check(new Set(names).size === names.length, 'invalid_plugin', `contributes.skills 里有同名的 Skill:${names.filter((n, i) => names.indexOf(n) !== i).join('、')}`);
529
+ return found;
530
+ }
531
+ // The plugins Coda ships: those in `dir`, which come from nowhere else, and its npm dependencies that are plugins
532
+ // (`dependencies`), each with where it comes from, which a version from that same place may stand in for (docs/plugins.md
533
+ // 「内置插件的来源」).
534
+ export function builtinPlugins(dir = BUILTIN_DIR, dependencies = dir === BUILTIN_DIR ? dependencyPlugins() : new Map()) {
535
+ const plugins = new Map((existsSync(dir) ? readdirSync(dir, { withFileTypes: true }) : []).filter(d => d.isDirectory()).map(d => {
536
+ const plugin = readPlugin(join(dir, d.name), { kind: 'builtin' });
537
+ return [plugin.id, plugin];
538
+ }));
539
+ for (const [id, plugin] of dependencies) {
540
+ check(!plugins.has(id), 'invalid_plugin', `插件 ${id} 同时存在于资源目录与 npm 依赖`);
541
+ plugins.set(id, plugin);
542
+ }
543
+ // An agent, a group and Git's credentials for an address are one plugin's (components/plugins/plugin-source-rules.mjs claims).
544
+ const owner = new Map();
545
+ for (const plugin of plugins.values()) for (const claim of claims(plugin)) {
546
+ check(!owner.has(claim), 'invalid_plugin', `插件 ${owner.get(claim)} 与 ${plugin.id} 都声明了 ${claim}`);
547
+ owner.set(claim, plugin.id);
548
+ }
549
+ return checkCatalog(plugins);
550
+ }
551
+ // A direct npm dependency declaring `contributes` is a bundled plugin (docs/plugins.md「npm 依赖中的插件」). npm installs its
552
+ // files and dependencies; where it comes from is what the dependency pins: a version of a package from the registry, with
553
+ // the integrity package-lock.json has of its tarball and the repository directory the package says it is published from,
554
+ // or a commit of a Git repository. Installed from the start, unless `coda.optionalPlugins` of the backend's package.json
555
+ // names it.
556
+ export function dependencyPlugins(root = PACKAGE_ROOT) {
557
+ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')), plugins = new Map();
558
+ const optional = new Set(manifest.coda?.optionalPlugins ?? []);
559
+ let lock;
560
+ for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
561
+ const dir = join(root, 'node_modules', name), pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
562
+ if (!Object.hasOwn(pkg, 'contributes')) continue;
563
+ let source;
564
+ if (typeof spec === 'string' && NPM_VERSION.test(spec)) {
565
+ lock ??= JSON.parse(readFileSync(join(root, 'package-lock.json'), 'utf8')).packages ?? {};
566
+ const integrity = lock[`node_modules/${name}`]?.integrity;
567
+ check(typeof integrity === 'string' && INTEGRITY.test(integrity), 'invalid_plugin', `package-lock.json 里没有插件依赖 ${name} 的 sha512 完整性摘要`);
568
+ source = { kind: 'builtin', package: name, tag: 'latest', version: spec, integrity, ...publishedFrom(pkg.repository) };
569
+ } else {
570
+ const git = typeof spec === 'string' && /^git\+(.+)#([^#]+)$/.exec(spec);
571
+ check(git && COMMIT.test(git[2]), 'invalid_plugin', `插件依赖 ${name} 须写一个版本号,或用 Git 仓库地址与完整提交号固定版本`);
572
+ source = { kind: 'builtin', repo: repoAddress(git[1]), path: '', commit: git[2] };
573
+ }
574
+ const plugin = readPlugin(dir, source);
575
+ check(!plugins.has(plugin.id), 'invalid_plugin', `多个 npm 依赖声明了插件 ${plugin.id}`);
576
+ plugins.set(plugin.id, { ...plugin, defaultInstalled: !optional.has(name) });
577
+ }
578
+ return plugins;
579
+ }
580
+ // The repository directory a package says it is published from (`repository`: its address, and `directory` in it), as a
581
+ // source names one; nothing where it says none Coda reads.
582
+ export function publishedFrom(repository) {
583
+ const url = typeof repository === 'string' ? repository : repository?.url;
584
+ try { return { repo: repoAddress(typeof url === 'string' ? url.replace(/^git\+/, '') : ''), path: sourcePath(repository?.directory ?? '') }; }
585
+ catch (e) { if (e.code !== 'invalid_input') throw e; return {}; }
586
+ }
587
+
588
+ // ---- Its processes ----
589
+ // What Coda gives each process it runs for a plugin (docs/plugins.md「插件进程的环境」) — its agents', its panels', the steps
590
+ // of its workflows, the commands it puts on sessions' PATH —, beside the environment the process runs in: Coda's own Node,
591
+ // the plugin's directory and its state on this backend (`stateDir(id)`, none without one), its settings as they apply
592
+ // (`settings`), what installed plugins plug into its points and where the plugins it depends on are, whose files (an npm
593
+ // package one of them brings) it may use, none of either when there is none; the files its secrets set there are in
594
+ // (`secrets`, by setting), none when none is; whether this backend is its environment's own (`arbiter`); and the PATH
595
+ // without Coda's own directories, for what it hands to a program that is not Coda's (`userPath`, adapters/installation.mjs).
596
+ export const PLUGIN_VARIABLES = ['CODA_NODE', 'CODA_ARBITER', 'CODA_PLUGIN_PATH', 'CODA_PLUGIN_STATE', 'CODA_PLUGIN_SETTINGS', 'CODA_PLUGIN_SECRETS',
597
+ 'CODA_PLUGIN_EXTENSIONS', 'CODA_PLUGIN_DEPENDENCIES', 'CODA_MODEL_PROVIDERS', 'CODA_USER_PATH'];
598
+ // `providers`: the model services of this machine, for a plugin that chooses them (choosesModelProviders); none for any other.
599
+ export function pluginEnv({ catalog, installed, id, stateDir = () => null, settingsOf = () => null, settings, secrets = {}, providers = null, arbiter = false, userPath = null }) {
600
+ const plugin = catalog.get(id), state = stateDir(id), points = extensionsFor(catalog, installed, id, stateDir, settingsOf);
601
+ return { CODA_NODE: process.execPath, CODA_PLUGIN_PATH: plugin.dir, ...(state ? { CODA_PLUGIN_STATE: state } : {}),
602
+ ...(userPath !== null ? { CODA_USER_PATH: userPath } : {}),
603
+ CODA_PLUGIN_SETTINGS: JSON.stringify(settings), ...(Object.keys(secrets).length ? { CODA_PLUGIN_SECRETS: JSON.stringify(secrets) } : {}),
604
+ ...(providers ? { CODA_MODEL_PROVIDERS: JSON.stringify(providers) } : {}),
605
+ ...(Object.keys(points).length ? { CODA_PLUGIN_EXTENSIONS: JSON.stringify(points) } : {}),
606
+ ...(plugin.dependencies.length ? { CODA_PLUGIN_DEPENDENCIES: JSON.stringify(Object.fromEntries(plugin.dependencies.map(d => [d, catalog.get(d).dir]))) } : {}),
607
+ ...(arbiter ? { CODA_ARBITER: '1' } : {}) };
608
+ }
609
+ // What such a process inherits, cleared of what Coda gives it as a plugin's: it never takes another plugin's for its own.
610
+ export const cleared = base => Object.fromEntries(Object.entries(base).filter(([name]) => !PLUGIN_VARIABLES.includes(name)));
611
+
612
+ // ---- Values ----
613
+ // `coda-agent-match`: `*` for every session, else agent, model and effort from the left, joined by `|`; each level is
614
+ // chosen only after the one before it, so two rows are never equally specific.
615
+ export function agentMatch(key) {
616
+ if (key === '*') return { agent: null, model: null, effort: null };
617
+ const parts = typeof key === 'string' ? key.split('|') : [];
618
+ if (!(parts.length >= 1 && parts.length <= 3 && AGENT_ID.test(parts[0]) && parts.every(p => p.length > 0 && p.length <= 200 && !/[\0\n]/.test(p)))) return null;
619
+ return { agent: parts[0], model: parts[1] ?? null, effort: parts[2] ?? null };
620
+ }
621
+ export const matchSpecificity = key => key === '*' ? 0 : key.split('|').length;
622
+ // The most specific row whose levels all equal the session's.
623
+ export function pickMatch(entries, session) {
624
+ return Object.keys(entries).map(key => [key, agentMatch(key)]).filter(([, m]) => m &&
625
+ (!m.agent || m.agent === session?.agent) && (!m.model || m.model === session?.model) && (!m.effort || m.effort === session?.effort))
626
+ .sort((a, b) => matchSpecificity(b[0]) - matchSpecificity(a[0]))[0]?.[0] ?? null;
627
+ }
628
+ // Whether the agent it names is installed is asked where it is used (a review, a step): a setting stays readable while a
629
+ // plugin it names is uninstalled.
630
+ function agentValue(value, where, code) {
631
+ check(value && typeof value === 'object' && !Array.isArray(value) && typeof value.agent === 'string' && AGENT_ID.test(value.agent), code,
632
+ `${where} 须为 Agent 组合,并指明 Agent`);
633
+ const { agent, ...rest } = value;
634
+ try { return { agent, ...agentSettings(rest) }; } catch (e) { throw new ContractError(code, `${where}:${e.message}`); }
635
+ }
636
+ // A value as the declaration allows it; returns it normalised.
637
+ export function checkValue(spec, value, where, code = 'invalid_input') {
638
+ if (spec.format === 'coda-agent') return agentValue(value, where, code);
639
+ // A whole row, as a table's default has it: its secrets are only ever written one by one, never declared.
640
+ if (spec.columns) {
641
+ check(value && typeof value === 'object' && !Array.isArray(value), code, `${where}须为一行`);
642
+ for (const name of spec.required ?? []) check(Object.hasOwn(value, name) || spec.columns[name].default !== undefined, code, `${where}缺「${spec.columns[name].title ?? name}」`);
643
+ return Object.fromEntries(Object.entries(value).map(([name, v]) => {
644
+ check(own(spec.columns, name) && !spec.columns[name].writeOnly, code, `${where}没有可以这样写的「${name}」:不是本行的列,或是密文`);
645
+ return [name, checkValue(spec.columns[name], v, `${where}「${name}」`, code)];
646
+ }));
647
+ }
648
+ if (spec.type === 'boolean') { check(typeof value === 'boolean', code, `${where}须为开或关`); return value; }
649
+ // Model services, each once, in the order chosen: one named may be of a plugin not installed (yet), as a single one may.
650
+ if (spec.type === 'array' && spec.format === 'coda-model-provider') {
651
+ check(Array.isArray(value) && value.length <= MODEL_PROVIDERS_AT_MOST && value.every(v => typeof v === 'string' && MODEL_PROVIDER.test(v)), code,
652
+ `${where}须为至多 ${MODEL_PROVIDERS_AT_MOST} 个「插件/服务」,如 cliproxy/cliproxy`);
653
+ return [...new Set(value)];
654
+ }
655
+ // The chosen options, each once, in the declaration's order.
656
+ if (spec.type === 'array') {
657
+ const allowed = spec.options.map(o => o.value);
658
+ check(Array.isArray(value) && value.every(v => typeof v === 'string'), code, `${where}须为选中的几项`);
659
+ const unknown = value.filter(v => !allowed.includes(v));
660
+ check(!unknown.length, code, `${where}只能从这些里选:${allowed.join('、')}(没有 ${unknown.join('、')})`);
661
+ return allowed.filter(v => value.includes(v));
662
+ }
663
+ if (spec.type === 'integer' || spec.type === 'number') {
664
+ check(typeof value === 'number' && Number.isFinite(value) && (spec.type === 'number' || Number.isInteger(value)), code, `${where}须为${spec.type === 'integer' ? '整数' : '数字'}`);
665
+ check((spec.minimum === undefined || value >= spec.minimum) && (spec.maximum === undefined || value <= spec.maximum), code,
666
+ `${where}须在 ${spec.minimum ?? '-∞'}–${spec.maximum ?? '∞'} 之间`);
667
+ } else if (spec.type === 'string') {
668
+ check(typeof value === 'string' && value.length <= 20000 && !value.includes('\0'), code, `${where}须为文本`);
669
+ check(spec.format !== 'duration' || isDuration(value), code, `${where}须为 ISO 8601 时长,如 P30D、PT6H`);
670
+ // Empty is none chosen; a repository is kept as written, once Git could be given it.
671
+ check(spec.format !== 'coda-directory' || value === '' || value.startsWith('/'), code, `${where}须为绝对目录`);
672
+ if (spec.format === 'coda-repo' && value !== '') try { repoAddress(value); } catch (e) { throw new ContractError(code, `${where}:${e.message}`); }
673
+ // Empty is none chosen; one named may be of a plugin not installed (yet), as an Agent's may.
674
+ check(spec.format !== 'coda-model-provider' || value === '' || MODEL_PROVIDER.test(value), code, `${where}须为「插件/服务」,如 cliproxy/cliproxy`);
675
+ }
676
+ else if (spec.type === 'object') {
677
+ check(value && typeof value === 'object' && !Array.isArray(value), code, `${where}须为表格`);
678
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => {
679
+ check(spec.keys !== 'coda-agent-match' || agentMatch(k), code, `${where}的「${k}」不是有效的作者`);
680
+ return [k, checkValue(spec.entries, v, `${where}「${k}」`, code)];
681
+ }));
682
+ }
683
+ check(!spec.enum || spec.enum.includes(value), code, `${where}只能是:${spec.enum?.join('、')}`);
684
+ return value;
685
+ }
686
+ // A stored setting: `{ value }`; a table stores its rows that way, each by key, `null` removing a row the declaration's
687
+ // default has.
688
+ function stored(spec, item, where) {
689
+ check(item && typeof item === 'object' && !Array.isArray(item) && 'value' in item && Object.keys(item).length === 1, 'invalid_input', `${where} 须为 { value }`);
690
+ return { value: checkValue(spec, item.value, where) };
691
+ }
692
+ // Applies a patch of settings to what is stored. A key set to null goes back to what is below it; a table is patched
693
+ // row by row (`{ entries: { key: item | null } }`), where a plugin's null removes a default row and a project's restores
694
+ // the plugin's row. A secret is stored only as set (`{ secret: true }`): its text, or null for one cleared, goes to
695
+ // `secrets`, for the caller to keep in its file.
696
+ export function patchSettings(plugin, current, patch, { project = false, secrets = {} } = {}) {
697
+ check(patch && typeof patch === 'object' && !Array.isArray(patch), 'invalid_input', '需要设置');
698
+ const next = structuredClone(current ?? {});
699
+ for (const [key, item] of Object.entries(patch)) {
700
+ const spec = plugin.properties[key];
701
+ check(spec, 'invalid_input', `插件 ${plugin.id} 没有设置 ${key}`);
702
+ if (item === null) { delete next[key]; if (spec.writeOnly || spec.entries?.columns) secrets[key] = null; continue; }
703
+ if (spec.writeOnly) {
704
+ check(item && typeof item === 'object' && !Array.isArray(item) && Object.keys(item).length === 1 && typeof item.value === 'string' && item.value !== '',
705
+ 'invalid_input', `${key} 是密文,须为 { value }:不空的文本`);
706
+ secrets[key] = checkValue(spec, item.value, key);
707
+ next[key] = { secret: true };
708
+ } else if (spec.entries) {
709
+ check(item && typeof item === 'object' && Object.keys(item).length === 1 && item.entries && typeof item.entries === 'object', 'invalid_input', `${key} 须为 { entries }`);
710
+ const rows = { ...(next[key]?.entries ?? {}) };
711
+ for (const [row, value] of Object.entries(item.entries)) {
712
+ check(spec.keys !== 'coda-agent-match' || agentMatch(row), 'invalid_input', `「${row}」不是有效的作者`);
713
+ if (value === null) {
714
+ if (project || !Object.hasOwn(spec.default ?? {}, row)) delete rows[row]; else put(rows, row, null);
715
+ if (spec.entries.columns) put(secrets[key] ??= {}, row, null);
716
+ } else if (spec.entries.columns) {
717
+ const table = secrets[key] ??= {};
718
+ put(rows, row, storedRow(spec.entries, own(rows, row), value, `${key}「${row}」`, own(table, row) ?? put(table, row, {})));
719
+ } else put(rows, row, stored(spec.entries, value, `${key}「${row}」`));
720
+ }
721
+ if (Object.keys(rows).length) next[key] = { entries: rows }; else delete next[key];
722
+ } else next[key] = stored(spec, item, key);
723
+ }
724
+ return next;
725
+ }
726
+ // A table's row is named by any text, and a row's column by a name a JavaScript object may have of its own (`constructor`,
727
+ // `toString`, `__proto__`): such a name is still only that row or column, read only when it is the object's own and
728
+ // written as the object's own property, never through what every object inherits.
729
+ const own = (object, key) => object && Object.hasOwn(object, key) ? object[key] : undefined;
730
+ const put = (object, key, value) => { Object.defineProperty(object, key, { value, enumerable: true, writable: true, configurable: true }); return value; };
731
+ // A row of columns as a patch leaves it at its layer (`{ value: { 列: 值 | null } }`): what it names over what the layer had,
732
+ // null clearing a column; a secret column's text goes to `secrets` (null when cleared) and is stored only as set. The row's
733
+ // required columns are there after it.
734
+ function storedRow(spec, current, item, where, secrets) {
735
+ check(item && typeof item === 'object' && Object.keys(item).length === 1 && item.value && typeof item.value === 'object' && !Array.isArray(item.value),
736
+ 'invalid_input', `${where} 须为 { value: { 列: 值 } }`);
737
+ const next = { ...(current?.value ?? {}) };
738
+ for (const [name, value] of Object.entries(item.value)) {
739
+ const column = own(spec.columns, name);
740
+ check(column, 'invalid_input', `${where}没有「${name}」这一列`);
741
+ if (value === null) { delete next[name]; if (column.writeOnly) put(secrets, name, null); continue; }
742
+ if (column.writeOnly) {
743
+ check(typeof value === 'string' && value !== '', 'invalid_input', `${where}「${name}」是密文,须为不空的文本`);
744
+ put(secrets, name, checkValue(column, value, `${where}「${name}」`));
745
+ put(next, name, { secret: true });
746
+ } else put(next, name, checkValue(column, value, `${where}「${name}」`));
747
+ }
748
+ for (const name of spec.required ?? []) check(Object.hasOwn(next, name) || spec.columns[name].default !== undefined, 'invalid_input', `${where}缺「${spec.columns[name].title ?? name}」`);
749
+ return { value: next };
750
+ }
751
+ // A row as it is resolved: every column, its default for one not written, a secret's null.
752
+ const rowValue = (spec, stored) => Object.fromEntries(Object.entries(spec.columns).map(([name, column]) =>
753
+ [name, column.writeOnly ? null : own(stored, name) ?? column.default ?? null]));
754
+ // The value of an item, and where it came from. A secret has no value here, only the layer it is set in; whatever else is
755
+ // stored for one (before the plugin made it a secret) is none.
756
+ function read(item, spec, from) {
757
+ if (!item) return null;
758
+ if (spec.writeOnly) return item.secret === true ? { value: null, from } : null;
759
+ if (!('value' in item)) return null;
760
+ // Of options chosen once, those the plugin has no more are not chosen; a value stored before the setting was a choice is none.
761
+ if (spec.type === 'array' && spec.format === 'coda-model-provider')
762
+ return Array.isArray(item.value) ? { value: item.value.filter(v => typeof v === 'string' && MODEL_PROVIDER.test(v)), from } : null;
763
+ if (spec.type === 'array') return Array.isArray(item.value) ? { value: spec.options.map(o => o.value).filter(v => item.value.includes(v)), from } : null;
764
+ return { value: item.value, from };
765
+ }
766
+ // Every setting of a plugin for one project (or for every project, without one): project over plugin over default.
767
+ // A table merges row by row, as VS Code merges object settings across user and workspace. A secret's value is null:
768
+ // `secrets` says which layer of those set has it, whose file its processes read; for a table of rows, by row and column.
769
+ export function resolveSettings(plugin, pluginStored = {}, projectStored = {}) {
770
+ const values = {}, sources = {}, secrets = {};
771
+ for (const [key, spec] of Object.entries(plugin.properties)) {
772
+ if (spec.entries) {
773
+ const rows = {}, from = {};
774
+ for (const [row, value] of Object.entries(spec.default ?? {})) { put(rows, row, value); put(from, row, { from: 'default' }); }
775
+ for (const [layer, store] of [['plugin', pluginStored], ['project', projectStored]])
776
+ for (const [row, item] of Object.entries(store[key]?.entries ?? {})) {
777
+ if (item === null) { delete rows[row]; delete from[row]; continue; }
778
+ const got = read(item, spec.entries, layer);
779
+ if (got) { put(rows, row, got.value); put(from, row, { from: layer }); }
780
+ }
781
+ if (spec.entries.columns) for (const row of Object.keys(rows)) {
782
+ const set = Object.keys(spec.entries.columns).filter(name => own(rows[row], name)?.secret === true);
783
+ if (set.length) put(secrets[key] ??= {}, row, Object.fromEntries(set.map(name => [name, from[row].from])));
784
+ put(rows, row, rowValue(spec.entries, rows[row]));
785
+ }
786
+ values[key] = rows; sources[key] = { from: projectStored[key] ? 'project' : pluginStored[key] ? 'plugin' : 'default', entries: from };
787
+ continue;
788
+ }
789
+ const got = read(projectStored[key], spec, 'project') ?? read(pluginStored[key], spec, 'plugin');
790
+ values[key] = got ? got.value : spec.default ?? null;
791
+ sources[key] = got ? { from: got.from } : { from: 'default' };
792
+ if (got && spec.writeOnly) secrets[key] = got.from;
793
+ }
794
+ return { values, sources, secrets };
795
+ }
796
+ // The required settings (`contributes.configuration.required`) with no value as they are resolved (resolveSettings): none, an
797
+ // empty text, a table without rows, a secret not set.
798
+ export const missingSettings = (plugin, { values, secrets }) => (plugin.required ?? []).filter(key => {
799
+ const spec = plugin.properties[key], value = values[key];
800
+ if (spec.writeOnly) return !Object.hasOwn(secrets, key);
801
+ if (spec.entries) return !value || !Object.keys(value).length;
802
+ return value === null || value === undefined || value === '';
803
+ });
804
+ // What a plugin's command says in place of running while settings it requires have no value (docs/plugins.md「必填」).
805
+ export const missingNotice = (plugin, missing) =>
806
+ `${plugin.displayName} 缺少设置:${missing.map(key => plugin.properties[key].title ?? key).join('、')}。在 Coda 的「设置 › 插件 › ${plugin.displayName}」里填写`;
807
+ // What clients are shown of stored settings: a secret only as set, whatever an older version of the plugin stored for it.
808
+ export const shownSettings = (plugin, stored = {}) =>
809
+ Object.fromEntries(Object.entries(stored ?? {}).filter(([key, item]) => !plugin.properties[key]?.writeOnly || item?.secret === true));
810
+ // `vars` for a workflow: a plugin's settings by their dotted names.
811
+ export function varsContext(values = {}) {
812
+ const vars = {};
813
+ for (const [key, value] of Object.entries(values)) {
814
+ const parts = key.split('.'); let at = vars;
815
+ for (const part of parts.slice(0, -1)) at = at[part] = at[part] && typeof at[part] === 'object' && !Array.isArray(at[part]) ? at[part] : {};
816
+ at[parts.at(-1)] = value;
817
+ }
818
+ return vars;
819
+ }