@north-light/crouter 0.3.186 → 0.3.188
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/dist/builtin-memory/internal/agent-shaping.md +1 -1
- package/dist/builtin-memory/internal/plugins.md +9 -0
- package/dist/clients/attach/viewer.js +326 -326
- package/dist/commands/pkg/plugin-manage.js +39 -5
- package/dist/core/__tests__/plugin-kinds.test.d.ts +1 -0
- package/dist/core/__tests__/plugin-kinds.test.js +180 -0
- package/dist/core/command-plugins/bundle.d.ts +6 -0
- package/dist/core/command-plugins/bundle.js +29 -7
- package/dist/core/config.d.ts +12 -1
- package/dist/core/config.js +114 -62
- package/dist/core/installed-plugins.d.ts +2 -0
- package/dist/core/installed-plugins.js +42 -0
- package/dist/core/resolver.d.ts +1 -1
- package/dist/core/resolver.js +7 -32
- package/dist/types.d.ts +14 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -5,7 +5,7 @@ import { defineLeaf } from '../../core/command.js';
|
|
|
5
5
|
import { notFound, usage, general, network } from '../../core/errors.js';
|
|
6
6
|
import { findMarketplaceByName, findPluginByName, listAllPlugins } from '../../core/resolver.js';
|
|
7
7
|
import { pluginsDir, ensureProjectScopeRoot, userScopeRoot, resolveScopeArg, projectScopeRoot, requireScopeRoot, } from '../../core/scope.js';
|
|
8
|
-
import { updateConfig, updateState, ensureScopeInitialized } from '../../core/config.js';
|
|
8
|
+
import { updateConfig, updateState, ensureScopeInitialized, invalidPluginKindsReasons } from '../../core/config.js';
|
|
9
9
|
import { pathExists, ensureDir, removePath, nowIso, linkOrCopy, isSymlink, atomicWriteJson } from '../../core/fs-utils.js';
|
|
10
10
|
import { clone, pull, deriveNameFromUrl, currentSha, isGitRepo } from '../../core/git.js';
|
|
11
11
|
import { readMarketplaceManifest, readPluginManifest } from '../../core/manifest.js';
|
|
@@ -45,6 +45,19 @@ function validateHttpInstall(name, endpoint, authEnv) {
|
|
|
45
45
|
throw usage(`invalid HTTP plugin transport: ${transportValidation.errors.join('; ')}`);
|
|
46
46
|
return transportValidation.transport;
|
|
47
47
|
}
|
|
48
|
+
/** Fail a SOURCE install (git/local/marketplace) loudly when the plugin's
|
|
49
|
+
* authored manifest declares an invalid `kinds` block — the same install-time
|
|
50
|
+
* strictness `validatePluginBundle` applies to an archive's bundle.json. The
|
|
51
|
+
* read side (`mergeKinds`) drops invalid entries silently, so without this
|
|
52
|
+
* the plugin would install cleanly and its kind would just never register. */
|
|
53
|
+
function assertManifestKindsValid(manifest, where) {
|
|
54
|
+
if (manifest.kinds === undefined)
|
|
55
|
+
return;
|
|
56
|
+
const reasons = invalidPluginKindsReasons(manifest.kinds);
|
|
57
|
+
if (reasons.length > 0) {
|
|
58
|
+
throw general(`plugin manifest at ${where} declares an invalid kinds block`, { issues: reasons });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
48
61
|
function invocationTransport(bundle) {
|
|
49
62
|
return { kind: 'http', endpoint: new URL(bundle.endpoint).origin, ...(bundle.authEnv !== undefined ? { authEnv: bundle.authEnv } : {}) };
|
|
50
63
|
}
|
|
@@ -115,6 +128,11 @@ async function replaceBundlePlugin(name, bundleSource, scope, options) {
|
|
|
115
128
|
commands: 'commands.json',
|
|
116
129
|
transport,
|
|
117
130
|
bundle,
|
|
131
|
+
// Kind-registry contributions declared in the archive's bundle.json,
|
|
132
|
+
// already strictly validated by validatePluginBundle. Copying them here is
|
|
133
|
+
// what registers them: readMergedLaunchConfig reads kinds from installed
|
|
134
|
+
// plugin MANIFESTS, never from bundle.json directly.
|
|
135
|
+
...(validated.bundle.kinds !== undefined ? { kinds: validated.bundle.kinds } : {}),
|
|
118
136
|
};
|
|
119
137
|
const tmpRoot = join(scopeRootPath, 'tmp');
|
|
120
138
|
const staging = join(tmpRoot, `${name}.${process.pid}`);
|
|
@@ -159,7 +177,7 @@ async function replaceBundlePlugin(name, bundleSource, scope, options) {
|
|
|
159
177
|
const commands = await commandReport(name, scope);
|
|
160
178
|
if (commands === undefined)
|
|
161
179
|
throw general(`bundle plugin "${name}" has no staged command report`);
|
|
162
|
-
return { name, scope, path: root, transport: 'http', version, docs: validated.bundle.memory.length, commands };
|
|
180
|
+
return { name, scope, path: root, transport: 'http', version, docs: validated.bundle.memory.length, kinds: Object.keys(validated.bundle.kinds ?? {}).length, commands };
|
|
163
181
|
}
|
|
164
182
|
async function installHttpPlugin(name, endpoint, authEnv, scope) {
|
|
165
183
|
const source = validateHttpInstall(name, endpoint, authEnv);
|
|
@@ -256,6 +274,13 @@ function installFromGit(source, ref, scope) {
|
|
|
256
274
|
removePath(tempDir);
|
|
257
275
|
throw general(`cloned repo does not contain a valid .crouter-plugin/plugin.json: ${source}`);
|
|
258
276
|
}
|
|
277
|
+
try {
|
|
278
|
+
assertManifestKindsValid(manifest, `${source}/.crouter-plugin/plugin.json`);
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
removePath(tempDir);
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
259
284
|
const finalName = manifest.name;
|
|
260
285
|
let finalDir = tempDir;
|
|
261
286
|
if (finalName !== tempName) {
|
|
@@ -281,6 +306,7 @@ function installFromLocal(source, scope) {
|
|
|
281
306
|
if (manifest === null) {
|
|
282
307
|
throw notFound(`plugin manifest not found at ${sourcePath}/.crouter-plugin/plugin.json`);
|
|
283
308
|
}
|
|
309
|
+
assertManifestKindsValid(manifest, `${sourcePath}/.crouter-plugin/plugin.json`);
|
|
284
310
|
const scopeRootPath = scope === 'project' ? ensureProjectScopeRoot() : userScopeRoot();
|
|
285
311
|
ensureScopeInitialized(scope, scopeRootPath);
|
|
286
312
|
const destDir = join(scopeRootPath, 'plugins', manifest.name);
|
|
@@ -328,6 +354,13 @@ export function installFromMarketplace(ref, scope) {
|
|
|
328
354
|
removePath(destPluginDir);
|
|
329
355
|
throw notFound(`plugin manifest not found at ${destPluginDir}/.crouter-plugin/plugin.json`);
|
|
330
356
|
}
|
|
357
|
+
try {
|
|
358
|
+
assertManifestKindsValid(pluginManifest, `${destPluginDir}/.crouter-plugin/plugin.json`);
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
removePath(destPluginDir);
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
331
364
|
const version = entry.version ?? pluginManifest.version;
|
|
332
365
|
updateConfig(scope, (cfg) => {
|
|
333
366
|
const pluginCfg = {
|
|
@@ -344,7 +377,7 @@ async function updateOnePlugin(plugin, marketplaceCache, opts) {
|
|
|
344
377
|
const sourceInstalled = isSourceInstalled(plugin);
|
|
345
378
|
if (!sourceInstalled && plugin.manifest.bundle !== undefined) {
|
|
346
379
|
const replaced = await replaceBundlePlugin(plugin.name, plugin.manifest.bundle, plugin.scope, { enable: plugin.enabled });
|
|
347
|
-
return { name: plugin.name, transport: 'http', updated: true, version: replaced.version, docs: replaced.docs, commands: replaced.commands };
|
|
380
|
+
return { name: plugin.name, transport: 'http', updated: true, version: replaced.version, docs: replaced.docs, kinds: replaced.kinds, commands: replaced.commands };
|
|
348
381
|
}
|
|
349
382
|
if (plugin.manifest.transport?.kind === 'http' && !sourceInstalled)
|
|
350
383
|
rejectLegacyHttpPlugin(plugin.name);
|
|
@@ -400,12 +433,13 @@ export const pluginInstall = defineLeaf({
|
|
|
400
433
|
{ name: 'transport', type: 'string', required: false, constraint: 'exec or http when the plugin declares commands. Always http for an --endpoint install.' },
|
|
401
434
|
{ name: 'version', type: 'string', required: false, constraint: 'First 12 hex characters of the fetched archive SHA-256 for an --endpoint install.' },
|
|
402
435
|
{ name: 'docs', type: 'integer', required: false, constraint: 'Memory document count written from an --endpoint archive.' },
|
|
436
|
+
{ name: 'kinds', type: 'integer', required: false, constraint: 'Kind-registry entry count declared by an --endpoint archive\u2019s bundle.json.' },
|
|
403
437
|
{ name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest. {mounts: string[] (accepted top-level command names now live for the next invocation), issues: object[] (typed validation issues that rejected a contribution — {code, path?, message, received, expected, next})}. Validated statically; commands are never executed.' },
|
|
404
438
|
],
|
|
405
439
|
outputKind: 'object',
|
|
406
440
|
effects: [
|
|
407
441
|
'A ref install clones, links, or copies the plugin into the scope plugins directory and registers it with enabled=true. Marketplace installs refresh the source marketplace before resolving the plugin entry.',
|
|
408
|
-
'An --endpoint install fetches and validates one authenticated uncompressed tar archive before writing. It replaces the complete plugin directory with synthesized provenance and invocation metadata, commands.json, and
|
|
442
|
+
'An --endpoint install fetches and validates one authenticated uncompressed tar archive before writing. It replaces the complete plugin directory with synthesized provenance and invocation metadata, commands.json, memory docs, and any kind-registry entries the bundle.json declares (they join the launch registry below this scope\u2019s own config.json kinds); a fetch or validation failure preserves the prior package.',
|
|
409
443
|
'If the plugin declares a command manifest, its commands go live on the next crtr invocation. Exec commands run trusted local code only when explicitly invoked; HTTP commands call their declared endpoint only when explicitly invoked.',
|
|
410
444
|
],
|
|
411
445
|
},
|
|
@@ -552,7 +586,7 @@ export const pluginUpdate = defineLeaf({
|
|
|
552
586
|
{ kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Narrows resolution.' },
|
|
553
587
|
],
|
|
554
588
|
output: [
|
|
555
|
-
{ name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, transport?, updated, sha?, version?, docs?, commands?, error?}. Archive plugins report transport=http and replace their complete directory after an authenticated refetch; version is the archive content identifier
|
|
589
|
+
{ name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, transport?, updated, sha?, version?, docs?, commands?, error?}. Archive plugins report transport=http and replace their complete directory after an authenticated refetch; version is the archive content identifier, docs is the written memory-document count, and kinds is the declared kind-registry entry count. sha is present for git updates. A bulk update reports and skips a failed archive plugin. commands is present for validated command plugins: {mounts: string[], issues: object[]}.' },
|
|
556
590
|
],
|
|
557
591
|
outputKind: 'object',
|
|
558
592
|
effects: ['Archive plugins unconditionally re-fetch, validate, and replace their complete package; a named failure exits nonzero and preserves the prior package. Bulk update reports a failed archive plugin and continues. Source-installed plugins retain their source update, version, and last_updated behavior.'],
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Run with: node --conditions=crtr-src --import tsx/esm --test src/core/__tests__/plugin-kinds.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Wiring pins for plugin-declared kinds — the layer whose absence is SILENT:
|
|
4
|
+
// `mergeKinds` drops nothing loudly at read time, so if `readMergedLaunchConfig`
|
|
5
|
+
// stopped layering enabled plugins' manifest `kinds` blocks (or layered them in
|
|
6
|
+
// the wrong precedence slot), every plugin-shipped persona kind would simply
|
|
7
|
+
// vanish from the registry with all suites green. Precedence under pin:
|
|
8
|
+
// builtin → user plugins → user config → profile → per project root:
|
|
9
|
+
// (root plugins → root config)
|
|
10
|
+
// Plus the archive path: `validatePluginBundle` must carry a valid bundle.json
|
|
11
|
+
// `kinds` block into `ValidatedBundle.kinds` (what the installer copies into
|
|
12
|
+
// the synthesized manifest) and reject an invalid one loudly.
|
|
13
|
+
import { test, before, beforeEach, afterEach, after } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import * as tar from 'tar';
|
|
19
|
+
import { resetScopeCache } from '../scope.js';
|
|
20
|
+
import { readMergedLaunchConfig } from '../config.js';
|
|
21
|
+
import { validatePluginBundle } from '../command-plugins/bundle.js';
|
|
22
|
+
let isolatedHome;
|
|
23
|
+
let isolatedCwd;
|
|
24
|
+
let prevHome;
|
|
25
|
+
let prevCwd;
|
|
26
|
+
before(() => {
|
|
27
|
+
prevHome = process.env.HOME;
|
|
28
|
+
prevCwd = process.cwd();
|
|
29
|
+
});
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
isolatedHome = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-plugin-kinds-home-')));
|
|
32
|
+
isolatedCwd = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-plugin-kinds-cwd-')));
|
|
33
|
+
process.env.HOME = isolatedHome;
|
|
34
|
+
process.chdir(isolatedCwd);
|
|
35
|
+
resetScopeCache();
|
|
36
|
+
});
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
process.chdir(prevCwd);
|
|
39
|
+
rmSync(isolatedHome, { recursive: true, force: true });
|
|
40
|
+
rmSync(isolatedCwd, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
after(() => {
|
|
43
|
+
process.chdir(prevCwd);
|
|
44
|
+
process.env.HOME = prevHome;
|
|
45
|
+
resetScopeCache();
|
|
46
|
+
});
|
|
47
|
+
/** Plant a manifest-only plugin (kinds need no commands or executable). */
|
|
48
|
+
function plantPlugin(scopeRootPath, name, kinds) {
|
|
49
|
+
const root = join(scopeRootPath, 'plugins', name, '.crouter-plugin');
|
|
50
|
+
mkdirSync(root, { recursive: true });
|
|
51
|
+
writeFileSync(join(root, 'plugin.json'), JSON.stringify({ name, version: '0.1.0', kinds }));
|
|
52
|
+
}
|
|
53
|
+
function userRoot() {
|
|
54
|
+
const root = join(isolatedHome, '.crouter');
|
|
55
|
+
mkdirSync(root, { recursive: true });
|
|
56
|
+
return root;
|
|
57
|
+
}
|
|
58
|
+
function projectRoot() {
|
|
59
|
+
const root = join(isolatedCwd, '.crouter');
|
|
60
|
+
mkdirSync(root, { recursive: true });
|
|
61
|
+
return root;
|
|
62
|
+
}
|
|
63
|
+
test('an enabled user-scope plugin adds a full kind and patches a builtin kind', () => {
|
|
64
|
+
plantPlugin(userRoot(), 'northlight', {
|
|
65
|
+
'applet-builder': { whenToUse: 'Build a Northlight applet.', model: 'anthropic/strong' },
|
|
66
|
+
general: { model: 'openai/light' },
|
|
67
|
+
});
|
|
68
|
+
const kinds = readMergedLaunchConfig().kinds;
|
|
69
|
+
assert.equal(kinds['applet-builder']?.whenToUse, 'Build a Northlight applet.');
|
|
70
|
+
assert.equal(kinds['applet-builder']?.model, 'anthropic/strong');
|
|
71
|
+
assert.equal(kinds['general']?.model, 'openai/light');
|
|
72
|
+
// Patch semantics: the builtin general keeps its whenToUse.
|
|
73
|
+
assert.ok((kinds['general']?.whenToUse ?? '').length > 0);
|
|
74
|
+
});
|
|
75
|
+
test('the scope\u2019s own config.json overrides its plugins; a disabled plugin contributes nothing', () => {
|
|
76
|
+
const root = userRoot();
|
|
77
|
+
plantPlugin(root, 'northlight', { 'applet-builder': { whenToUse: 'plugin gloss', model: 'anthropic/strong' } });
|
|
78
|
+
plantPlugin(root, 'zzz-disabled', { ghost: { whenToUse: 'never registers' } });
|
|
79
|
+
writeFileSync(join(root, 'config.json'), JSON.stringify({
|
|
80
|
+
plugins: { 'zzz-disabled': { enabled: false } },
|
|
81
|
+
kinds: { 'applet-builder': { model: 'openai/medium' } },
|
|
82
|
+
}));
|
|
83
|
+
const kinds = readMergedLaunchConfig().kinds;
|
|
84
|
+
assert.equal(kinds['applet-builder']?.model, 'openai/medium'); // config patch wins
|
|
85
|
+
assert.equal(kinds['applet-builder']?.whenToUse, 'plugin gloss'); // patch merges, not replaces
|
|
86
|
+
assert.equal(kinds['ghost'], undefined);
|
|
87
|
+
});
|
|
88
|
+
test('a whenToUse-only entry re-glosses an existing kind without stripping its model tier', () => {
|
|
89
|
+
// The pre-change footgun: `{whenToUse}` parsed as a FULL entry and wholly
|
|
90
|
+
// replaced the kind, silently dropping explore off its cheap model tier.
|
|
91
|
+
// Now every entry field-merges over an existing kind; only a NEW kind
|
|
92
|
+
// requires whenToUse (a whenToUse-less new kind is dropped).
|
|
93
|
+
const root = userRoot();
|
|
94
|
+
writeFileSync(join(root, 'config.json'), JSON.stringify({ kinds: { explore: { whenToUse: 'custom explore gloss' }, 'new-glossless': { model: 'openai/light' } } }));
|
|
95
|
+
const kinds = readMergedLaunchConfig().kinds;
|
|
96
|
+
assert.equal(kinds['explore']?.whenToUse, 'custom explore gloss');
|
|
97
|
+
assert.equal(kinds['explore']?.model, 'openai/light'); // builtin tier survives
|
|
98
|
+
assert.equal(kinds['explore']?.orchestratorModel, 'anthropic/strong');
|
|
99
|
+
assert.equal(kinds['new-glossless'], undefined);
|
|
100
|
+
});
|
|
101
|
+
test('a project-root plugin layers above user config, and the project config above the plugin', () => {
|
|
102
|
+
const uRoot = userRoot();
|
|
103
|
+
writeFileSync(join(uRoot, 'config.json'), JSON.stringify({ kinds: { shared: { whenToUse: 'user gloss', model: 'openai/light' } } }));
|
|
104
|
+
const pRoot = projectRoot();
|
|
105
|
+
plantPlugin(pRoot, 'proj-plugin', { shared: { model: 'anthropic/strong' }, 'proj-kind': { whenToUse: 'project plugin kind' } });
|
|
106
|
+
writeFileSync(join(pRoot, 'config.json'), JSON.stringify({ kinds: { 'proj-kind': { model: 'openai/medium' } } }));
|
|
107
|
+
const kinds = readMergedLaunchConfig().kinds;
|
|
108
|
+
assert.equal(kinds['shared']?.model, 'anthropic/strong'); // project plugin > user config
|
|
109
|
+
assert.equal(kinds['shared']?.whenToUse, 'user gloss');
|
|
110
|
+
assert.equal(kinds['proj-kind']?.model, 'openai/medium'); // project config > project plugin
|
|
111
|
+
assert.equal(kinds['proj-kind']?.whenToUse, 'project plugin kind');
|
|
112
|
+
});
|
|
113
|
+
// --- archive path -----------------------------------------------------------
|
|
114
|
+
function minimalCommandsJson() {
|
|
115
|
+
return {
|
|
116
|
+
schemaVersion: 1,
|
|
117
|
+
mounts: [
|
|
118
|
+
{
|
|
119
|
+
parent: [],
|
|
120
|
+
node: {
|
|
121
|
+
kind: 'branch',
|
|
122
|
+
name: 'app',
|
|
123
|
+
description: 'application lifecycle and inspection',
|
|
124
|
+
whenToUse: 'you need to inspect an application',
|
|
125
|
+
rootEntry: {
|
|
126
|
+
concept: 'applications hosted by a crouter home',
|
|
127
|
+
description: 'application lifecycle and inspection',
|
|
128
|
+
whenToUse: 'you need to inspect an application',
|
|
129
|
+
},
|
|
130
|
+
summary: 'application lifecycle and inspection',
|
|
131
|
+
model: 'Applications are product resources owned by the selected home.',
|
|
132
|
+
children: [
|
|
133
|
+
{
|
|
134
|
+
kind: 'leaf',
|
|
135
|
+
name: 'show',
|
|
136
|
+
description: 'show one application',
|
|
137
|
+
whenToUse: 'inspect a single application by id',
|
|
138
|
+
tier: 'important',
|
|
139
|
+
summary: 'show one application details',
|
|
140
|
+
params: [{ kind: 'positional', name: 'app-id', required: true, constraint: 'the application id' }],
|
|
141
|
+
output: [{ name: 'app_id', type: 'string', required: true, constraint: 'the echoed application id' }],
|
|
142
|
+
effects: ['None. Read-only.'],
|
|
143
|
+
rest: { method: 'GET', path: '/v1/apps/{app-id}', params: { 'app-id': { in: 'path' } } },
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function makeArchive(bundleJson) {
|
|
152
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-plugin-kinds-tar-'));
|
|
153
|
+
try {
|
|
154
|
+
writeFileSync(join(dir, 'bundle.json'), JSON.stringify(bundleJson));
|
|
155
|
+
writeFileSync(join(dir, 'commands.json'), JSON.stringify(minimalCommandsJson()));
|
|
156
|
+
const pack = tar.create({ sync: true, cwd: dir, portable: true }, ['bundle.json', 'commands.json']);
|
|
157
|
+
const chunks = [];
|
|
158
|
+
let chunk;
|
|
159
|
+
while ((chunk = pack.read()) !== null)
|
|
160
|
+
chunks.push(chunk);
|
|
161
|
+
return new Uint8Array(Buffer.concat(chunks));
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
rmSync(dir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
test('validatePluginBundle carries a valid bundle.json kinds block into ValidatedBundle', async () => {
|
|
168
|
+
const archive = makeArchive({ bundleVersion: 1, kinds: { 'applet-builder': { whenToUse: 'Build an applet.', model: 'anthropic/strong' } } });
|
|
169
|
+
const validated = await validatePluginBundle(archive, { reservedCoreNames: new Set(['node']) });
|
|
170
|
+
assert.deepEqual(validated.issues, []);
|
|
171
|
+
assert.deepEqual(validated.bundle?.kinds, { 'applet-builder': { whenToUse: 'Build an applet.', model: 'anthropic/strong' } });
|
|
172
|
+
});
|
|
173
|
+
test('validatePluginBundle rejects an invalid kinds block loudly', async () => {
|
|
174
|
+
const archive = makeArchive({ bundleVersion: 1, kinds: { broken: { when_to_use: 'typo field' } } });
|
|
175
|
+
const validated = await validatePluginBundle(archive, { reservedCoreNames: new Set(['node']) });
|
|
176
|
+
assert.equal(validated.bundle, undefined);
|
|
177
|
+
assert.equal(validated.issues.length, 1);
|
|
178
|
+
assert.match(validated.issues[0].message, /kinds block is invalid/);
|
|
179
|
+
assert.match(validated.issues[0].received, /when_to_use/);
|
|
180
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ValidatedCommandManifest } from '../command-manifests/manifest.js';
|
|
2
|
+
import type { KindConfig } from '../../types.js';
|
|
2
3
|
export interface BundleIssue {
|
|
3
4
|
code: string;
|
|
4
5
|
path?: string;
|
|
@@ -9,6 +10,11 @@ export interface BundleIssue {
|
|
|
9
10
|
}
|
|
10
11
|
export interface ValidatedBundle {
|
|
11
12
|
bundleVersion: 1;
|
|
13
|
+
/** Kind-registry contributions declared in `bundle.json` (optional). The
|
|
14
|
+
* installer copies this validated block into the synthesized plugin
|
|
15
|
+
* manifest, where `readMergedLaunchConfig` layers it — see
|
|
16
|
+
* `PluginManifest.kinds`. */
|
|
17
|
+
kinds?: Record<string, Partial<KindConfig>>;
|
|
12
18
|
commands: ValidatedCommandManifest;
|
|
13
19
|
commandsBytes: Uint8Array;
|
|
14
20
|
directories: ReadonlyArray<{
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Parser } from 'tar';
|
|
2
2
|
import { validateCommandManifest } from '../command-manifests/manifest.js';
|
|
3
|
+
import { invalidPluginKindsReasons } from '../config.js';
|
|
3
4
|
function issue(code, message, received, expected, next, path) {
|
|
4
5
|
return { code, message, received, expected, next, ...(path !== undefined ? { path } : {}) };
|
|
5
6
|
}
|
|
@@ -134,12 +135,32 @@ function parseBundleMetadata(bytes) {
|
|
|
134
135
|
raw = JSON.parse(Buffer.from(bytes).toString('utf8'));
|
|
135
136
|
}
|
|
136
137
|
catch {
|
|
137
|
-
return bundleInvalid('bundle.json is not valid JSON', 'invalid JSON', '{"bundleVersion":1}', 'Write valid bundle metadata.', 'bundle.json');
|
|
138
|
+
return { issue: bundleInvalid('bundle.json is not valid JSON', 'invalid JSON', '{"bundleVersion":1}', 'Write valid bundle metadata.', 'bundle.json') };
|
|
138
139
|
}
|
|
139
|
-
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)
|
|
140
|
-
return
|
|
140
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
141
|
+
return {
|
|
142
|
+
issue: bundleInvalid('bundle.json must be a JSON object', JSON.stringify(raw), 'an object with bundleVersion set to 1 and optionally kinds', 'Set bundle.json to {"bundleVersion":1}.', 'bundle.json'),
|
|
143
|
+
};
|
|
141
144
|
}
|
|
142
|
-
|
|
145
|
+
const record = raw;
|
|
146
|
+
const unknownKeys = Object.keys(record).filter((key) => key !== 'bundleVersion' && key !== 'kinds');
|
|
147
|
+
if (record['bundleVersion'] !== 1 || unknownKeys.length > 0) {
|
|
148
|
+
return {
|
|
149
|
+
issue: bundleInvalid('bundle.json must contain bundleVersion 1 and at most a kinds block', JSON.stringify(raw), 'an object with bundleVersion set to 1 and optionally kinds', 'Set bundle.json to {"bundleVersion":1} plus an optional kinds object.', 'bundle.json'),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (record['kinds'] === undefined)
|
|
153
|
+
return {};
|
|
154
|
+
// Install-time strictness: a bad kinds block fails the install loudly here,
|
|
155
|
+
// because the read side (`mergeKinds`) drops invalid entries silently and
|
|
156
|
+
// would otherwise ship a kind that never registers.
|
|
157
|
+
const reasons = invalidPluginKindsReasons(record['kinds']);
|
|
158
|
+
if (reasons.length > 0) {
|
|
159
|
+
return {
|
|
160
|
+
issue: bundleInvalid('bundle.json kinds block is invalid', reasons.join('; '), 'each entry a full KindConfig (whenToUse required) or a sparse patch of launch fields', 'Fix the kinds entries in bundle.json.', 'bundle.json'),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { kinds: record['kinds'] };
|
|
143
164
|
}
|
|
144
165
|
function parseCommands(bytes, reservedCoreNames, coreCommandPaths) {
|
|
145
166
|
let raw;
|
|
@@ -175,15 +196,16 @@ export async function validatePluginBundle(archive, options) {
|
|
|
175
196
|
issues: [bundleInvalid('bundle must contain exactly one bundle.json and one commands.json regular file', `bundle.json=${bundle !== undefined}, commands.json=${commands !== undefined}`, 'one regular bundle.json and one regular commands.json member', 'Add the required metadata members to the archive.')],
|
|
176
197
|
};
|
|
177
198
|
}
|
|
178
|
-
const
|
|
179
|
-
if (
|
|
180
|
-
return { issues: [
|
|
199
|
+
const metadata = parseBundleMetadata(bundle.bytes);
|
|
200
|
+
if (metadata.issue !== undefined)
|
|
201
|
+
return { issues: [metadata.issue] };
|
|
181
202
|
const commandValidation = parseCommands(commands.bytes, options.reservedCoreNames, options.coreCommandPaths);
|
|
182
203
|
if (commandValidation.commands === undefined)
|
|
183
204
|
return { issues: commandValidation.issues };
|
|
184
205
|
return {
|
|
185
206
|
bundle: {
|
|
186
207
|
bundleVersion: 1,
|
|
208
|
+
...(metadata.kinds !== undefined ? { kinds: metadata.kinds } : {}),
|
|
187
209
|
commands: commandValidation.commands,
|
|
188
210
|
commandsBytes: commands.bytes,
|
|
189
211
|
directories: parsed.members
|
package/dist/core/config.d.ts
CHANGED
|
@@ -26,6 +26,14 @@ export declare function ensureScopeInitialized(scope: Scope, root: string): void
|
|
|
26
26
|
/** Normalize the user-owned list of attach actions allowed to bridge through an
|
|
27
27
|
* occupied tmux root binding. Unknown and non-attach ids have no effect. */
|
|
28
28
|
export declare function normalizeTmuxPassthrough(raw: unknown): BindingId[];
|
|
29
|
+
/** STRICT install-time validation for a plugin-declared `kinds` block —
|
|
30
|
+
* the loud counterpart to `mergeKinds`'s silent read-time dropping. Read
|
|
31
|
+
* paths must never throw on bad config, but an INSTALL delivering a bad
|
|
32
|
+
* block must fail the install, not ship a kind that silently never
|
|
33
|
+
* registers. Returns one human-readable reason per defect; empty = valid.
|
|
34
|
+
* Used by the archive-bundle validator (`command-plugins/bundle.ts`) on
|
|
35
|
+
* `bundle.json`'s optional `kinds` member. */
|
|
36
|
+
export declare function invalidPluginKindsReasons(raw: unknown): string[];
|
|
29
37
|
/** Raw (un-defaulted) partial config for one scope, or null if the scope has
|
|
30
38
|
* no root or no config.json. Used by `readMergedLaunchConfig` to layer
|
|
31
39
|
* scopes onto each other WITHOUT each scope's own default-fill masking a
|
|
@@ -49,7 +57,10 @@ export interface MergedLaunchConfig {
|
|
|
49
57
|
}
|
|
50
58
|
/** Merge launch knobs (`kinds`, `modelLadders`) across scopes in
|
|
51
59
|
* project stack > profile > user > builtin precedence — the same precedence
|
|
52
|
-
* order used for memory resolution.
|
|
60
|
+
* order used for memory resolution. For `kinds` only, each plugin-bearing
|
|
61
|
+
* scope (user, each project root) additionally layers its enabled plugins'
|
|
62
|
+
* manifest `kinds` blocks directly BELOW that scope's own raw config — see
|
|
63
|
+
* `layerPluginKinds`. A kind or ladder cell declared at a
|
|
53
64
|
* more-specific scope shadows the same key from a less-specific scope; the
|
|
54
65
|
* project STACK (`findProjectScopeRoots` — every ancestor `.crouter/`,
|
|
55
66
|
* widened by a selected profile's `projects`) layers nearest-root-strongest;
|