@coda-console/plugin-check 0.4.9 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -17
- package/bin/check.mjs +4 -3
- package/check/agent.mjs +29 -19
- package/check/coda/builtin.json +1273 -0
- package/check/coda/components/plugins/agent-plugins.mjs +88 -0
- package/check/coda/components/plugins/plugin-extensions.mjs +171 -0
- package/check/coda/components/plugins/plugin-rules.mjs +819 -0
- package/check/coda/components/plugins/plugin-source-rules.mjs +362 -0
- package/check/coda/components/plugins/skill-document.mjs +35 -0
- package/check/coda/components/prompt-recovery/prompt-recovery.mjs +56 -0
- package/check/coda/components/session-config/commands.mjs +111 -0
- package/check/coda/components/workflows/executor-labels.mjs +13 -0
- package/check/coda/contract.json +29 -0
- package/check/coda/domain/agent-protocol.mjs +45 -0
- package/check/coda/domain/duration.mjs +11 -0
- package/check/coda/domain/errors.mjs +11 -0
- package/check/coda/domain/values.mjs +39 -0
- package/check/coda/kernel/agent-failures.mjs +16 -0
- package/check/coda/resources/plugins/resources/panels/command.mjs +46 -0
- package/check/coda/resources/plugins/resources/panels/panels.mjs +225 -0
- package/check/coda/resources/plugins/resources/panels/rules.mjs +242 -0
- package/check/coda/resources/plugins/resources/panels/sign-in.mjs +131 -0
- package/check/index.mjs +3 -4
- package/check/leftovers.mjs +9 -3
- package/check/manifest.mjs +198 -434
- package/check/panel.mjs +49 -100
- package/check/waits.mjs +5 -4
- package/package.json +4 -2
- package/check/coda.mjs +0 -165
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The agents a plugin brings (docs/plugins.md「Agent」): who each is, how the clients draw it, and how Coda starts its
|
|
2
|
+
// process, which speaks ACP with Coda's waits (docs/agent-protocol.md). Nothing here knows any agent by name: Codex and
|
|
3
|
+
// Claude are plugins like any other.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { isAbsolute, join, normalize, sep } from 'node:path';
|
|
6
|
+
import { requireCondition as check } from '../../domain/errors.mjs';
|
|
7
|
+
import { AGENT_ID } from '../../domain/values.mjs';
|
|
8
|
+
|
|
9
|
+
export const ICON_LIMIT = 32 * 1024;
|
|
10
|
+
const NAME = 40, MODE_TITLE = 10, MODE_UNSET = 20, MODE_NOTE = 100, HINT = 200, COMMAND = 4000, CONFIG_FILES = 16, PATH = 1000;
|
|
11
|
+
const code = 'invalid_plugin';
|
|
12
|
+
const keys = (value, allowed, where) => {
|
|
13
|
+
const extra = Object.keys(value).filter(k => !allowed.includes(k));
|
|
14
|
+
check(!extra.length, code, `${where} 里有 Coda 不支持的键:${extra.join('、')}`);
|
|
15
|
+
};
|
|
16
|
+
const object = (value, where) => check(value && typeof value === 'object' && !Array.isArray(value), code, `${where} 须为对象`);
|
|
17
|
+
const line = (value, max, where) => {
|
|
18
|
+
check(typeof value === 'string' && value.trim() && [...value].length <= max && !/[\0\r\n]/.test(value), code, `${where} 须为不超过 ${max} 字的一行文本`);
|
|
19
|
+
return value;
|
|
20
|
+
};
|
|
21
|
+
const optional = (value, max, where) => value === undefined ? undefined : line(value, max, where);
|
|
22
|
+
// A path inside the plugin's directory or the working copy: relative, and never leaving it.
|
|
23
|
+
const inside = (value, where, { segments = Infinity } = {}) => {
|
|
24
|
+
line(value, PATH, where);
|
|
25
|
+
const path = normalize(value);
|
|
26
|
+
check(!isAbsolute(value) && path !== '..' && !path.startsWith(`..${sep}`) && path.split(sep).length <= segments, code,
|
|
27
|
+
`${where} 须为不离开所在目录的相对路径${Number.isFinite(segments) ? `,至多 ${segments} 段` : ''}`);
|
|
28
|
+
return path;
|
|
29
|
+
};
|
|
30
|
+
// An icon file, read once with the plugin: a PDF both clients draw as they drew the brand artwork they shipped with.
|
|
31
|
+
function icon(dir, value, where) {
|
|
32
|
+
const path = inside(value, where);
|
|
33
|
+
let data;
|
|
34
|
+
try { data = readFileSync(join(dir, path)); } catch (e) { check(false, code, `${where} 读不到:${e.message}`); }
|
|
35
|
+
check(data.length <= ICON_LIMIT, code, `${where} 超过 ${ICON_LIMIT / 1024} KiB`);
|
|
36
|
+
check(data.subarray(0, 5).toString('latin1') === '%PDF-', code, `${where} 须为 PDF`);
|
|
37
|
+
return data.toString('base64');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function declaration(raw, where, dir) {
|
|
41
|
+
object(raw, where);
|
|
42
|
+
keys(raw, ['id', 'displayName', 'icon', 'command', 'modes', 'skillsDir', 'configFiles', 'setupHint'], where);
|
|
43
|
+
check(typeof raw.id === 'string' && AGENT_ID.test(raw.id), code, `${where} 的 id 只能用小写字母、数字和「-」,以字母开头,至多 40 个字符`);
|
|
44
|
+
const at = `Agent ${raw.id}`;
|
|
45
|
+
object(raw.icon, `${at} 的 icon`);
|
|
46
|
+
keys(raw.icon, ['light', 'dark', 'template'], `${at} 的 icon`);
|
|
47
|
+
check(raw.icon.template === undefined || typeof raw.icon.template === 'boolean', code, `${at} 的 icon.template 须为开或关`);
|
|
48
|
+
const modes = raw.modes ?? {};
|
|
49
|
+
object(modes, `${at} 的 modes`);
|
|
50
|
+
keys(modes, ['title', 'unset', 'note', 'auto', 'full'], `${at} 的 modes`);
|
|
51
|
+
check(raw.configFiles === undefined || (Array.isArray(raw.configFiles) && raw.configFiles.length <= CONFIG_FILES), code,
|
|
52
|
+
`${at} 的 configFiles 须为至多 ${CONFIG_FILES} 项的数组`);
|
|
53
|
+
return {
|
|
54
|
+
id: raw.id, displayName: line(raw.displayName, NAME, `${at} 的 displayName`),
|
|
55
|
+
icon: { light: icon(dir, raw.icon.light, `${at} 的 icon.light`),
|
|
56
|
+
...(raw.icon.dark !== undefined ? { dark: icon(dir, raw.icon.dark, `${at} 的 icon.dark`) } : {}), template: raw.icon.template === true },
|
|
57
|
+
command: line(raw.command, COMMAND, `${at} 的 command`),
|
|
58
|
+
modes: Object.fromEntries([['title', MODE_TITLE], ['unset', MODE_UNSET], ['note', MODE_NOTE], ['auto', 200], ['full', 200]]
|
|
59
|
+
.filter(([key]) => modes[key] !== undefined).map(([key, max]) => [key, line(modes[key], max, `${at} 的 modes.${key}`)])),
|
|
60
|
+
...(raw.skillsDir !== undefined ? { skillsDir: inside(raw.skillsDir, `${at} 的 skillsDir`, { segments: 3 }) } : {}),
|
|
61
|
+
configFiles: (raw.configFiles ?? []).map((file, i) => line(file, PATH, `${at} 的 configFiles[${i}]`)),
|
|
62
|
+
...(raw.setupHint !== undefined ? { setupHint: optional(raw.setupHint, HINT, `${at} 的 setupHint`) } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// `contributes.agents` of the plugin in `dir`.
|
|
66
|
+
export function agentDeclarations(raw, dir) {
|
|
67
|
+
if (raw === undefined) return [];
|
|
68
|
+
check(Array.isArray(raw), code, 'contributes.agents 须为数组');
|
|
69
|
+
const agents = raw.map((a, i) => declaration(a, `contributes.agents[${i}]`, dir));
|
|
70
|
+
check(new Set(agents.map(a => a.id)).size === agents.length, code, 'contributes.agents 里有重复的 id');
|
|
71
|
+
return agents;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A configuration file an agent reads its models from, as its plugin names it: `~/` is the home directory, `${VAR}` and
|
|
75
|
+
// `${VAR:-default}` the backend's environment (a variable empty and without a default leaves the file out), and a relative
|
|
76
|
+
// path is the project's.
|
|
77
|
+
export function configFile(spec, { env, home, root }) {
|
|
78
|
+
let empty = false;
|
|
79
|
+
const expanded = spec.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_, name, fallback) => {
|
|
80
|
+
const value = env[name];
|
|
81
|
+
if (value) return value;
|
|
82
|
+
if (fallback === undefined) { empty = true; return ''; }
|
|
83
|
+
return fallback;
|
|
84
|
+
});
|
|
85
|
+
if (empty || !expanded) return null;
|
|
86
|
+
const path = expanded === '~' ? home : expanded.startsWith('~/') ? join(home, expanded.slice(2)) : expanded;
|
|
87
|
+
return isAbsolute(path) ? path : join(root, path);
|
|
88
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// How one plugin builds on another (docs/plugins.md「依赖与扩展点」): it names the plugins it needs (`extensionDependencies`), and
|
|
2
|
+
// may plug into a point one of them declares for it (`contributes.extensionPoints` there, `contributes.extensions` here).
|
|
3
|
+
// Coda checks the names and hands each plugin what is plugged into its points, as declared: what passes between the two
|
|
4
|
+
// is the extended plugin's own contract, which Coda neither reads nor shapes. A point may instead take what any plugin
|
|
5
|
+
// writes under a key of its `contributes` (`contributes` there, as the resources plugin's `panels`): kept as written, for
|
|
6
|
+
// the declaring plugin to read, only the commands in it listed where installing confirms them.
|
|
7
|
+
import { requireCondition as check } from '../../domain/errors.mjs';
|
|
8
|
+
|
|
9
|
+
const ID = /^[a-z][a-z0-9-]{0,39}$/;
|
|
10
|
+
const POINT = /^[a-z][a-z0-9-]{0,39}$/;
|
|
11
|
+
const DESCRIPTION = 200, COMMAND = 4000, TITLE = 20, LABEL = 40;
|
|
12
|
+
// A key of `contributes` a point takes, and where in each thing written there a command is (`*`: every key, or element).
|
|
13
|
+
const OFFER = /^[a-z][A-Za-z0-9]{0,39}$/, PATH = /^(\*|[A-Za-z][A-Za-z0-9]{0,39})(\.(\*|[A-Za-z][A-Za-z0-9]{0,39})){0,7}$/;
|
|
14
|
+
const code = 'invalid_plugin';
|
|
15
|
+
const object = (value, where) => check(value && typeof value === 'object' && !Array.isArray(value), code, `${where} 须为对象`);
|
|
16
|
+
const keys = (value, allowed, where) => {
|
|
17
|
+
const extra = Object.keys(value).filter(k => !allowed.includes(k));
|
|
18
|
+
check(!extra.length, code, `${where} 里有 Coda 不支持的键:${extra.join('、')}`);
|
|
19
|
+
};
|
|
20
|
+
const line = (value, max, where) => {
|
|
21
|
+
check(typeof value === 'string' && value.trim() && [...value].length <= max && !/[\0\r\n]/.test(value), code, `${where} 须为不超过 ${max} 字的一行文本`);
|
|
22
|
+
return value;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// `extensionDependencies` (VS Code's name: `dependencies` is npm's): the IDs of the plugins this one needs installed, which installing it installs first.
|
|
26
|
+
export function dependencyDeclarations(raw, self) {
|
|
27
|
+
if (raw === undefined) return [];
|
|
28
|
+
check(Array.isArray(raw) && raw.every(id => typeof id === 'string' && ID.test(id)) && new Set(raw).size === raw.length, code,
|
|
29
|
+
'extensionDependencies 须为插件 ID 的数组,不重复');
|
|
30
|
+
check(!raw.includes(self), code, 'extensionDependencies 不能写自己');
|
|
31
|
+
return [...raw];
|
|
32
|
+
}
|
|
33
|
+
// `contributes.extensionPoints`: name → `{ description }`, what others may plug in here; with `contributes`, the key of
|
|
34
|
+
// `contributes` any plugin writes it under (none of Coda's own, `reserved`), what one thing written there is called
|
|
35
|
+
// (`title`), and where in each a command is, with what it is for (`commands`: path → label).
|
|
36
|
+
export function extensionPointDeclarations(raw, reserved = []) {
|
|
37
|
+
if (raw === undefined) return {};
|
|
38
|
+
object(raw, 'contributes.extensionPoints');
|
|
39
|
+
// A key is one point's, of this plugin as of any other (plugin-source-rules.mjs claims).
|
|
40
|
+
const taken = new Map();
|
|
41
|
+
return Object.fromEntries(Object.entries(raw).map(([name, value]) => {
|
|
42
|
+
const where = `contributes.extensionPoints.${name}`;
|
|
43
|
+
check(POINT.test(name), code, `${where}:扩展点名只能用小写字母、数字和「-」,以字母开头`);
|
|
44
|
+
object(value, where); keys(value, value.contributes === undefined ? ['description'] : ['description', 'contributes', 'title', 'commands'], where);
|
|
45
|
+
const description = line(value.description, DESCRIPTION, `${where} 的 description`);
|
|
46
|
+
if (value.contributes === undefined) return [name, { description }];
|
|
47
|
+
check(typeof value.contributes === 'string' && OFFER.test(value.contributes), code, `${where} 的 contributes 须为字母和数字组成、以小写字母开头的键名`);
|
|
48
|
+
check(!reserved.includes(value.contributes), code, `${where} 的 contributes:${value.contributes} 是 Coda 自己的贡献`);
|
|
49
|
+
check(!taken.has(value.contributes), code, `${where} 的 contributes:${value.contributes} 已由扩展点 ${taken.get(value.contributes)} 声明`);
|
|
50
|
+
taken.set(value.contributes, name);
|
|
51
|
+
const commands = value.commands ?? {};
|
|
52
|
+
object(commands, `${where} 的 commands`);
|
|
53
|
+
for (const [path, label] of Object.entries(commands)) {
|
|
54
|
+
check(PATH.test(path), code, `${where} 的 commands:${path} 须为以「.」连接的键名或「*」`);
|
|
55
|
+
line(label, LABEL, `${where} 的 commands.${path}`);
|
|
56
|
+
}
|
|
57
|
+
return [name, { description, contributes: value.contributes, title: line(value.title, TITLE, `${where} 的 title`), commands: { ...commands } }];
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
// What a plugin writes under the keys of `contributes` that are not Coda's own (`reserved`), as written: each for the
|
|
61
|
+
// point that takes it, which the catalog checks there is (checkCatalog).
|
|
62
|
+
export function offerDeclarations(contributes = {}, reserved = []) {
|
|
63
|
+
const offers = Object.entries(contributes ?? {}).filter(([key]) => !reserved.includes(key));
|
|
64
|
+
for (const [key] of offers) check(OFFER.test(key), code, `contributes 里有 Coda 不支持的贡献:${key}`);
|
|
65
|
+
return Object.fromEntries(offers);
|
|
66
|
+
}
|
|
67
|
+
// The point that takes what is written under this key of `contributes`, with the plugin declaring it, or null.
|
|
68
|
+
export function offerPoint(plugins, key) {
|
|
69
|
+
for (const plugin of plugins.values()) for (const [point, declared] of Object.entries(plugin.extensionPoints ?? {}))
|
|
70
|
+
if (declared.contributes === key) return { plugin, point, declared };
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
// The commands in what a plugin writes for a point (`raw`), each with what it is for, as installing it confirms them:
|
|
74
|
+
// `<title>「<the thing's title, ID or place>」的<label>`, and the keys a `*` stood for. How many things it writes.
|
|
75
|
+
export function offeredCommands(raw, declared) {
|
|
76
|
+
const items = Array.isArray(raw) ? raw.map((value, i) => [typeof value?.title === 'string' ? value.title : typeof value?.id === 'string' ? value.id : String(i + 1), value])
|
|
77
|
+
: raw && typeof raw === 'object' ? Object.entries(raw) : [];
|
|
78
|
+
const commands = [];
|
|
79
|
+
const walk = (value, rest, stood, label, name) => {
|
|
80
|
+
if (!rest.length) {
|
|
81
|
+
if (typeof value === 'string' && value.trim()) commands.push({ label: `${declared.title}「${name}」的${label}${stood.length ? ` ${stood.join(' ')}` : ''}`, command: value });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!value || typeof value !== 'object') return;
|
|
85
|
+
const [head, ...tail] = rest;
|
|
86
|
+
if (head === '*') for (const [key, next] of Object.entries(value)) walk(next, tail, [...stood, key], label, name);
|
|
87
|
+
else if (Object.hasOwn(value, head)) walk(value[head], tail, stood, label, name);
|
|
88
|
+
};
|
|
89
|
+
for (const [name, item] of items) for (const [path, label] of Object.entries(declared.commands ?? {})) walk(item, path.split('.'), [], label, name);
|
|
90
|
+
return { count: items.length, commands };
|
|
91
|
+
}
|
|
92
|
+
// `contributes.extensions`: `<plugin>/<point>` → `{ command }`, plugged into a point of a plugin it depends on.
|
|
93
|
+
export function extensionDeclarations(raw, dependencies) {
|
|
94
|
+
if (raw === undefined) return [];
|
|
95
|
+
object(raw, 'contributes.extensions');
|
|
96
|
+
return Object.entries(raw).map(([key, value]) => {
|
|
97
|
+
const where = `contributes.extensions.${key}`, [plugin, point, ...rest] = key.split('/');
|
|
98
|
+
check(ID.test(plugin ?? '') && POINT.test(point ?? '') && !rest.length, code, `${where}:须写成「插件 ID/扩展点名」`);
|
|
99
|
+
check(dependencies.includes(plugin), code, `${where}:只能接所依赖的插件的扩展点,先把 ${plugin} 写进 extensionDependencies`);
|
|
100
|
+
object(value, where); keys(value, ['command'], where);
|
|
101
|
+
return { plugin, point, command: line(value.command, COMMAND, `${where} 的 command`) };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The catalog as a whole: every plugin needed is there, nothing needs itself round about, every point plugged into is
|
|
106
|
+
// one its plugin declares, and what is written under a key of `contributes` not Coda's own a point takes.
|
|
107
|
+
export function checkCatalog(plugins) {
|
|
108
|
+
for (const plugin of plugins.values()) {
|
|
109
|
+
for (const id of plugin.dependencies) check(plugins.has(id), code, `插件 ${plugin.id} 依赖的 ${id} 不在 Coda 里`);
|
|
110
|
+
for (const e of plugin.extensions) check(Object.hasOwn(plugins.get(e.plugin).extensionPoints, e.point), code,
|
|
111
|
+
`插件 ${plugin.id} 接的 ${e.plugin}/${e.point} 不是 ${e.plugin} 声明的扩展点`);
|
|
112
|
+
const problem = offerProblem(plugin, plugins);
|
|
113
|
+
check(!problem, code, `插件 ${plugin.id}:${problem}`);
|
|
114
|
+
}
|
|
115
|
+
const visiting = new Set(), done = new Set();
|
|
116
|
+
const visit = (id, path) => {
|
|
117
|
+
if (done.has(id)) return;
|
|
118
|
+
check(!visiting.has(id), code, `插件互相依赖:${[...path, id].join(' → ')}`);
|
|
119
|
+
visiting.add(id);
|
|
120
|
+
for (const next of plugins.get(id).dependencies) visit(next, [...path, id]);
|
|
121
|
+
visiting.delete(id); done.add(id);
|
|
122
|
+
};
|
|
123
|
+
for (const id of plugins.keys()) visit(id, []);
|
|
124
|
+
return plugins;
|
|
125
|
+
}
|
|
126
|
+
// Why what a plugin writes under the keys of `contributes` not Coda's own cannot be taken here (`plugins`: the catalog
|
|
127
|
+
// beside it), or null: no point takes one of them.
|
|
128
|
+
export function offerProblem(plugin, plugins) {
|
|
129
|
+
const others = new Map([...plugins, [plugin.id, plugin]]);
|
|
130
|
+
const unknown = Object.keys(plugin.offers ?? {}).filter(key => !offerPoint(others, key));
|
|
131
|
+
return unknown.length ? `contributes 里有 Coda 不支持的贡献:${unknown.join('、')}` : null;
|
|
132
|
+
}
|
|
133
|
+
// What installing a plugin installs: what it needs, before it, then it.
|
|
134
|
+
export function withDependencies(catalog, id) {
|
|
135
|
+
const order = [], seen = new Set();
|
|
136
|
+
const visit = at => {
|
|
137
|
+
if (seen.has(at)) return;
|
|
138
|
+
seen.add(at);
|
|
139
|
+
for (const next of catalog.get(at)?.dependencies ?? []) visit(next);
|
|
140
|
+
order.push(at);
|
|
141
|
+
};
|
|
142
|
+
visit(id);
|
|
143
|
+
return order;
|
|
144
|
+
}
|
|
145
|
+
// The installed plugins that need this one, directly or through another, by ID.
|
|
146
|
+
export function dependents(catalog, installed, id) {
|
|
147
|
+
const found = new Set();
|
|
148
|
+
for (let grew = true; grew;) {
|
|
149
|
+
grew = false;
|
|
150
|
+
for (const plugin of catalog.values()) {
|
|
151
|
+
if (!installed.has(plugin.id) || found.has(plugin.id)) continue;
|
|
152
|
+
if (plugin.dependencies.some(d => d === id || found.has(d))) { found.add(plugin.id); grew = true; }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return [...found].sort();
|
|
156
|
+
}
|
|
157
|
+
// What installed plugins plug into this plugin's points, by point, in plugin ID order: each one's command, its directory,
|
|
158
|
+
// the directory it keeps its state in (`stateDir`) and its settings for every project (`settingsOf`), for the extended
|
|
159
|
+
// plugin to run it as its own contract says, the command answering by its own settings.
|
|
160
|
+
export function extensionsFor(catalog, installed, id, stateDir = () => null, settingsOf = () => null) {
|
|
161
|
+
const points = {};
|
|
162
|
+
for (const plugin of [...catalog.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) {
|
|
163
|
+
if (!installed.has(plugin.id)) continue;
|
|
164
|
+
for (const e of plugin.extensions) {
|
|
165
|
+
if (e.plugin !== id || !Object.hasOwn(catalog.get(id)?.extensionPoints ?? {}, e.point)) continue;
|
|
166
|
+
const state = stateDir(plugin.id), settings = settingsOf(plugin.id);
|
|
167
|
+
(points[e.point] ??= []).push({ plugin: plugin.id, command: e.command, path: plugin.dir, ...(state ? { state } : {}), ...(settings ? { settings } : {}) });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return points;
|
|
171
|
+
}
|