@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.
@@ -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 memory docs; a fetch or validation failure preserves the prior package.',
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 and docs is the written memory-document 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[]}.' },
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) || Object.keys(raw).length !== 1 || raw['bundleVersion'] !== 1) {
140
- return bundleInvalid('bundle.json must contain exactly {"bundleVersion":1}', JSON.stringify(raw), 'an object with only bundleVersion set to 1', 'Set bundle.json to {"bundleVersion":1}.', 'bundle.json');
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
- return undefined;
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 metadataIssue = parseBundleMetadata(bundle.bytes);
179
- if (metadataIssue !== undefined)
180
- return { issues: [metadataIssue] };
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
@@ -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. A kind or ladder cell declared at a
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;