@north-light/crouter 0.3.186 → 0.3.187

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,167 @@
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 project-root plugin layers above user config, and the project config above the plugin', () => {
89
+ const uRoot = userRoot();
90
+ writeFileSync(join(uRoot, 'config.json'), JSON.stringify({ kinds: { shared: { whenToUse: 'user gloss', model: 'openai/light' } } }));
91
+ const pRoot = projectRoot();
92
+ plantPlugin(pRoot, 'proj-plugin', { shared: { model: 'anthropic/strong' }, 'proj-kind': { whenToUse: 'project plugin kind' } });
93
+ writeFileSync(join(pRoot, 'config.json'), JSON.stringify({ kinds: { 'proj-kind': { model: 'openai/medium' } } }));
94
+ const kinds = readMergedLaunchConfig().kinds;
95
+ assert.equal(kinds['shared']?.model, 'anthropic/strong'); // project plugin > user config
96
+ assert.equal(kinds['shared']?.whenToUse, 'user gloss');
97
+ assert.equal(kinds['proj-kind']?.model, 'openai/medium'); // project config > project plugin
98
+ assert.equal(kinds['proj-kind']?.whenToUse, 'project plugin kind');
99
+ });
100
+ // --- archive path -----------------------------------------------------------
101
+ function minimalCommandsJson() {
102
+ return {
103
+ schemaVersion: 1,
104
+ mounts: [
105
+ {
106
+ parent: [],
107
+ node: {
108
+ kind: 'branch',
109
+ name: 'app',
110
+ description: 'application lifecycle and inspection',
111
+ whenToUse: 'you need to inspect an application',
112
+ rootEntry: {
113
+ concept: 'applications hosted by a crouter home',
114
+ description: 'application lifecycle and inspection',
115
+ whenToUse: 'you need to inspect an application',
116
+ },
117
+ summary: 'application lifecycle and inspection',
118
+ model: 'Applications are product resources owned by the selected home.',
119
+ children: [
120
+ {
121
+ kind: 'leaf',
122
+ name: 'show',
123
+ description: 'show one application',
124
+ whenToUse: 'inspect a single application by id',
125
+ tier: 'important',
126
+ summary: 'show one application details',
127
+ params: [{ kind: 'positional', name: 'app-id', required: true, constraint: 'the application id' }],
128
+ output: [{ name: 'app_id', type: 'string', required: true, constraint: 'the echoed application id' }],
129
+ effects: ['None. Read-only.'],
130
+ rest: { method: 'GET', path: '/v1/apps/{app-id}', params: { 'app-id': { in: 'path' } } },
131
+ },
132
+ ],
133
+ },
134
+ },
135
+ ],
136
+ };
137
+ }
138
+ function makeArchive(bundleJson) {
139
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-plugin-kinds-tar-'));
140
+ try {
141
+ writeFileSync(join(dir, 'bundle.json'), JSON.stringify(bundleJson));
142
+ writeFileSync(join(dir, 'commands.json'), JSON.stringify(minimalCommandsJson()));
143
+ const pack = tar.create({ sync: true, cwd: dir, portable: true }, ['bundle.json', 'commands.json']);
144
+ const chunks = [];
145
+ let chunk;
146
+ while ((chunk = pack.read()) !== null)
147
+ chunks.push(chunk);
148
+ return new Uint8Array(Buffer.concat(chunks));
149
+ }
150
+ finally {
151
+ rmSync(dir, { recursive: true, force: true });
152
+ }
153
+ }
154
+ test('validatePluginBundle carries a valid bundle.json kinds block into ValidatedBundle', async () => {
155
+ const archive = makeArchive({ bundleVersion: 1, kinds: { 'applet-builder': { whenToUse: 'Build an applet.', model: 'anthropic/strong' } } });
156
+ const validated = await validatePluginBundle(archive, { reservedCoreNames: new Set(['node']) });
157
+ assert.deepEqual(validated.issues, []);
158
+ assert.deepEqual(validated.bundle?.kinds, { 'applet-builder': { whenToUse: 'Build an applet.', model: 'anthropic/strong' } });
159
+ });
160
+ test('validatePluginBundle rejects an invalid kinds block loudly', async () => {
161
+ const archive = makeArchive({ bundleVersion: 1, kinds: { broken: { when_to_use: 'typo field' } } });
162
+ const validated = await validatePluginBundle(archive, { reservedCoreNames: new Set(['node']) });
163
+ assert.equal(validated.bundle, undefined);
164
+ assert.equal(validated.issues.length, 1);
165
+ assert.match(validated.issues[0].message, /kinds block is invalid/);
166
+ assert.match(validated.issues[0].received, /when_to_use/);
167
+ });
@@ -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;
@@ -5,6 +5,7 @@ import { BINDING_CATALOG, BINDING_IDS, isAttachPaneBinding } from './keybindings
5
5
  import { emitEvent } from './events/emit.js';
6
6
  import { atomicWriteJson, readJsonIfExists, writeJson, ensureDir } from './fs-utils.js';
7
7
  import { scopeRoot, requireScopeRoot, findProjectScopeRoots } from './scope.js';
8
+ import { listInstalledPluginsInRoot } from './installed-plugins.js';
8
9
  import { profileRoot } from './profiles/manifest.js';
9
10
  import { withExclusiveDirectoryLock } from './exclusive-lock.js';
10
11
  import { SCOPE_CONFIG_KEYS } from './user-settings.js';
@@ -341,6 +342,71 @@ function mergeKinds(raw, base = defaultKindsConfig()) {
341
342
  }
342
343
  return out;
343
344
  }
345
+ /** Layer the `kinds` contributions of every ENABLED plugin installed under
346
+ * `scopeRootPath` over `base`, plugin name-sorted for determinism (directory
347
+ * listing order is filesystem-dependent). Each plugin's block goes through
348
+ * the same `mergeKinds` a scope `config.json` does — full entries add or
349
+ * shadow, patches field-merge, invalid entries drop. Called by
350
+ * `readMergedLaunchConfig` BELOW the host scope's own raw `kinds`, so the
351
+ * scope's config.json always overrides its plugins. */
352
+ function layerPluginKinds(scope, scopeRootPath, base) {
353
+ if (scopeRootPath === null)
354
+ return base;
355
+ const plugins = listInstalledPluginsInRoot(scope, scopeRootPath)
356
+ .filter((p) => p.enabled && p.manifest.kinds !== undefined)
357
+ .sort((a, b) => a.name.localeCompare(b.name));
358
+ let out = base;
359
+ for (const plugin of plugins)
360
+ out = mergeKinds(plugin.manifest.kinds, out);
361
+ return out;
362
+ }
363
+ const KIND_CONFIG_KEYS = new Set(['whenToUse', 'model', 'orchestratorModel', 'tools', 'extensions', 'availableTo']);
364
+ /** STRICT install-time validation for a plugin-declared `kinds` block —
365
+ * the loud counterpart to `mergeKinds`'s silent read-time dropping. Read
366
+ * paths must never throw on bad config, but an INSTALL delivering a bad
367
+ * block must fail the install, not ship a kind that silently never
368
+ * registers. Returns one human-readable reason per defect; empty = valid.
369
+ * Used by the archive-bundle validator (`command-plugins/bundle.ts`) on
370
+ * `bundle.json`'s optional `kinds` member. */
371
+ export function invalidPluginKindsReasons(raw) {
372
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
373
+ return ['kinds must be a JSON object keyed by kind name'];
374
+ }
375
+ const reasons = [];
376
+ const isStringArray = (v) => Array.isArray(v) && v.every((item) => typeof item === 'string');
377
+ for (const [kind, value] of Object.entries(raw)) {
378
+ if (kind.trim() === '') {
379
+ reasons.push('kinds contains an empty kind name');
380
+ continue;
381
+ }
382
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
383
+ reasons.push(`kinds.${kind} must be a JSON object`);
384
+ continue;
385
+ }
386
+ const entry = value;
387
+ for (const key of Object.keys(entry)) {
388
+ if (!KIND_CONFIG_KEYS.has(key))
389
+ reasons.push(`kinds.${kind}.${key} is not a KindConfig field (expected one of: ${[...KIND_CONFIG_KEYS].join(', ')})`);
390
+ }
391
+ if (entry['whenToUse'] !== undefined && (typeof entry['whenToUse'] !== 'string' || entry['whenToUse'].trim() === '')) {
392
+ reasons.push(`kinds.${kind}.whenToUse must be a non-empty string`);
393
+ }
394
+ for (const field of ['model', 'orchestratorModel']) {
395
+ if (entry[field] !== undefined && (typeof entry[field] !== 'string' || entry[field].trim() === '')) {
396
+ reasons.push(`kinds.${kind}.${field} must be a non-empty string`);
397
+ }
398
+ }
399
+ for (const field of ['tools', 'extensions', 'availableTo']) {
400
+ if (entry[field] !== undefined && !isStringArray(entry[field])) {
401
+ reasons.push(`kinds.${kind}.${field} must be an array of strings`);
402
+ }
403
+ }
404
+ if (normalizeKindEntry(value) === null && normalizeKindPatch(value) === null) {
405
+ reasons.push(`kinds.${kind} is neither a full kind (whenToUse required) nor a patch with at least one launch field`);
406
+ }
407
+ }
408
+ return reasons;
409
+ }
344
410
  /** Validate one raw `remoteCanvas.targets.<name>` entry, or drop it (return
345
411
  * null) rather than throwing — same rule `normalizeKindEntry` follows. A
346
412
  * valid entry needs a non-empty `previewEndpoint` and `relayTokenRef`; the
@@ -492,17 +558,16 @@ function readRawProfileConfig(profileId) {
492
558
  function readRawProjectScopeConfigs(targetCwd, targetProfileId) {
493
559
  const nearestFirst = findProjectScopeRoots(targetCwd, targetProfileId);
494
560
  const farthestFirst = [...nearestFirst].reverse();
495
- const out = [];
496
- for (const root of farthestFirst) {
497
- const raw = readJsonIfExists(configPathFor(root));
498
- if (raw !== null)
499
- out.push(raw);
500
- }
501
- return out;
561
+ // A root with no config.json still contributes: its installed plugins may
562
+ // declare kinds, so the ROOT rides along and `raw` stays null.
563
+ return farthestFirst.map((root) => ({ root, raw: readJsonIfExists(configPathFor(root)) }));
502
564
  }
503
565
  /** Merge launch knobs (`kinds`, `modelLadders`) across scopes in
504
566
  * project stack > profile > user > builtin precedence — the same precedence
505
- * order used for memory resolution. A kind or ladder cell declared at a
567
+ * order used for memory resolution. For `kinds` only, each plugin-bearing
568
+ * scope (user, each project root) additionally layers its enabled plugins'
569
+ * manifest `kinds` blocks directly BELOW that scope's own raw config — see
570
+ * `layerPluginKinds`. A kind or ladder cell declared at a
506
571
  * more-specific scope shadows the same key from a less-specific scope; the
507
572
  * project STACK (`findProjectScopeRoots` — every ancestor `.crouter/`,
508
573
  * widened by a selected profile's `projects`) layers nearest-root-strongest;
@@ -533,8 +598,13 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
533
598
  const defaults = defaultScopeConfig();
534
599
  const userRaw = readRawScopeConfig('user');
535
600
  const profileRaw = readRawProfileConfig(targetProfileId);
536
- const projectRawsFarthestFirst = readRawProjectScopeConfigs(targetCwd, targetProfileId);
537
- let kinds = mergeKinds(userRaw?.kinds, defaults.kinds);
601
+ const projectLayersFarthestFirst = readRawProjectScopeConfigs(targetCwd, targetProfileId);
602
+ // Kinds layer per scope as: that scope's enabled PLUGINS first, then the
603
+ // scope's own raw config — so a plugin can add or shadow a kind (registry
604
+ // entry in its manifest + gated persona docs in its memory tree) and the
605
+ // scope's config.json still overrides its plugins. Other launch knobs
606
+ // (ladders/routes/spawnEnv) have no plugin channel.
607
+ let kinds = mergeKinds(userRaw?.kinds, layerPluginKinds('user', scopeRoot('user'), defaults.kinds));
538
608
  let modelLadders = mergeModelLadders(userRaw?.modelLadders, defaults.modelLadders);
539
609
  let modelRoutes = mergeModelRoutes(userRaw?.modelRoutes);
540
610
  let modelRouting = mergeModelRouting(userRaw?.modelRouting, undefined);
@@ -546,8 +616,10 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
546
616
  modelRouting = mergeModelRouting(profileRaw.modelRouting, modelRouting);
547
617
  spawnEnv = mergeSpawnEnv(profileRaw.spawnEnv, spawnEnv.allow);
548
618
  }
549
- for (const raw of projectRawsFarthestFirst) {
550
- kinds = mergeKinds(raw.kinds, kinds);
619
+ for (const { root, raw } of projectLayersFarthestFirst) {
620
+ kinds = mergeKinds(raw?.kinds, layerPluginKinds('project', root, kinds));
621
+ if (raw === null)
622
+ continue;
551
623
  modelLadders = mergeModelLadders(raw.modelLadders, modelLadders);
552
624
  modelRoutes = mergeModelRoutes(raw.modelRoutes, modelRoutes);
553
625
  modelRouting = mergeModelRouting(raw.modelRouting, modelRouting);
@@ -0,0 +1,2 @@
1
+ import type { InstalledPlugin, Scope } from '../types.js';
2
+ export declare function listInstalledPluginsInRoot(scope: Scope, scopeRootPath: string): InstalledPlugin[];
@@ -0,0 +1,42 @@
1
+ // installed-plugins.ts — the LEAF-LEVEL installed-plugin reader: list the
2
+ // plugin packages physically present under one scope root, with their
3
+ // manifests and enabled-state from that root's config.json `plugins` block.
4
+ //
5
+ // This lives below resolver.ts (which re-exports it for its existing callers)
6
+ // so that config.ts can layer plugin-declared launch config (`kinds`) into
7
+ // `readMergedLaunchConfig` without an import cycle: resolver.ts imports
8
+ // config.ts, so config.ts can never import resolver.ts. Imports here stay
9
+ // leaf-safe: types, fs-utils, manifest only.
10
+ import { join } from 'node:path';
11
+ import { CONFIG_FILE } from '../types.js';
12
+ import { listDirs, pathExists, readJsonIfExists } from './fs-utils.js';
13
+ import { readPluginManifest } from './manifest.js';
14
+ function pluginConfigForRoot(root) {
15
+ const cfg = readJsonIfExists(join(root, CONFIG_FILE));
16
+ return cfg && cfg.plugins && typeof cfg.plugins === 'object' ? cfg.plugins : {};
17
+ }
18
+ export function listInstalledPluginsInRoot(scope, scopeRootPath) {
19
+ const dir = join(scopeRootPath, 'plugins');
20
+ if (!pathExists(dir))
21
+ return [];
22
+ const cfg = pluginConfigForRoot(scopeRootPath);
23
+ const out = [];
24
+ for (const name of listDirs(dir)) {
25
+ const root = join(dir, name);
26
+ const manifest = readPluginManifest(root);
27
+ if (!manifest)
28
+ continue;
29
+ const entry = cfg[name];
30
+ const version = typeof entry?.version === 'string' ? entry.version : manifest.version;
31
+ out.push({
32
+ name,
33
+ scope,
34
+ root,
35
+ manifest,
36
+ enabled: typeof entry?.enabled === 'boolean' ? entry.enabled : true,
37
+ sourceMarketplace: typeof entry?.source_marketplace === 'string' ? entry.source_marketplace : undefined,
38
+ version,
39
+ });
40
+ }
41
+ return out;
42
+ }
@@ -1,5 +1,5 @@
1
1
  import type { InstalledMarketplace, InstalledPlugin, Scope } from '../types.js';
2
- export declare function listInstalledPluginsInRoot(scope: Scope, scopeRootPath: string): InstalledPlugin[];
2
+ export { listInstalledPluginsInRoot } from './installed-plugins.js';
3
3
  export declare function listInstalledPlugins(scope: Scope): InstalledPlugin[];
4
4
  export declare function listAllPlugins(): InstalledPlugin[];
5
5
  export declare function findPluginByName(name: string, scope?: Scope): InstalledPlugin | null;
@@ -1,39 +1,14 @@
1
1
  import { join } from 'node:path';
2
- import { CONFIG_FILE } from '../types.js';
3
2
  import { readConfig } from './config.js';
4
- import { listDirs, pathExists, readJsonIfExists } from './fs-utils.js';
5
- import { readMarketplaceManifest, readPluginManifest } from './manifest.js';
3
+ import { listDirs, pathExists } from './fs-utils.js';
4
+ import { readMarketplaceManifest } from './manifest.js';
6
5
  import { InputError } from './io.js';
7
6
  import { marketplacesDir, projectScopeRoot, scopeRoot, userScopeRoot, } from './scope.js';
8
- function pluginConfigForRoot(root) {
9
- const cfg = readJsonIfExists(join(root, CONFIG_FILE));
10
- return cfg && cfg.plugins && typeof cfg.plugins === 'object' ? cfg.plugins : {};
11
- }
12
- export function listInstalledPluginsInRoot(scope, scopeRootPath) {
13
- const dir = join(scopeRootPath, 'plugins');
14
- if (!pathExists(dir))
15
- return [];
16
- const cfg = pluginConfigForRoot(scopeRootPath);
17
- const out = [];
18
- for (const name of listDirs(dir)) {
19
- const root = join(dir, name);
20
- const manifest = readPluginManifest(root);
21
- if (!manifest)
22
- continue;
23
- const entry = cfg[name];
24
- const version = typeof entry?.version === 'string' ? entry.version : manifest.version;
25
- out.push({
26
- name,
27
- scope,
28
- root,
29
- manifest,
30
- enabled: typeof entry?.enabled === 'boolean' ? entry.enabled : true,
31
- sourceMarketplace: typeof entry?.source_marketplace === 'string' ? entry.source_marketplace : undefined,
32
- version,
33
- });
34
- }
35
- return out;
36
- }
7
+ // Moved to installed-plugins.ts (a leaf module below config.ts) so
8
+ // `readMergedLaunchConfig` can layer plugin-declared kinds without an import
9
+ // cycle; re-exported here for this module's existing callers.
10
+ export { listInstalledPluginsInRoot } from './installed-plugins.js';
11
+ import { listInstalledPluginsInRoot } from './installed-plugins.js';
37
12
  export function listInstalledPlugins(scope) {
38
13
  // The builtin scope has no scopeRoot, so this returns [] — builtin content is
39
14
  // the memory substrate, not plugins.