@north-light/crouter 0.3.185 → 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.
- package/dist/api/dto/broker.d.ts +0 -32
- package/dist/api/dto/messages.d.ts +0 -5
- package/dist/builtin-memory/internal/agent-shaping.md +1 -1
- package/dist/builtin-memory/internal/plugins.md +9 -0
- package/dist/clients/attach/input/controller.js +3 -4
- package/dist/clients/attach/overlays/mcp.js +1 -3
- package/dist/clients/attach/slash/dispatch.js +0 -2
- package/dist/clients/attach/viewer.js +331 -331
- package/dist/commands/memory/list.js +7 -3
- 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 +167 -0
- package/dist/core/__tests__/session-model.test.js +2 -4
- 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 +84 -12
- 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/core/runtime/broker/event-projection.d.ts +1 -8
- package/dist/core/runtime/broker/event-projection.js +1 -25
- package/dist/core/runtime/broker/frame-dispatch.d.ts +0 -2
- package/dist/core/runtime/broker/frame-dispatch.js +8 -41
- package/dist/core/runtime/broker-protocol.d.ts +1 -46
- package/dist/core/runtime/broker.js +1 -8
- package/dist/core/runtime/interactive-deliver.d.ts +2 -5
- package/dist/core/runtime/interactive-deliver.js +3 -6
- package/dist/core/session-model/session-state.d.ts +3 -6
- package/dist/core/session-model/session-state.js +1 -5
- package/dist/daemon/api/handlers/messages.js +1 -7
- package/dist/types.d.ts +12 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/core/runtime/broker/message-ledger.d.ts +0 -53
- package/dist/core/runtime/broker/message-ledger.js +0 -143
|
@@ -22,9 +22,13 @@ export function listMemoryDocs(kindFilter, scopeFilter, quiet = false, docs = li
|
|
|
22
22
|
if (kindFilter !== undefined && sub.kind !== kindFilter)
|
|
23
23
|
continue;
|
|
24
24
|
// A directory INDEX surfaces as a dir entry: its dir path with a trailing
|
|
25
|
-
// slash, flagged is_dir, so the inventory distinguishes it from a doc.
|
|
25
|
+
// slash, flagged is_dir, so the inventory distinguishes it from a doc. A
|
|
26
|
+
// store-root INDEX has no dir path, so it keeps its canonical `INDEX`
|
|
27
|
+
// name — every name in this column must be one `crtr memory read` and a
|
|
28
|
+
// `[[link]]` accept, and a bare `/` is neither.
|
|
26
29
|
const isDir = isIndexName(sub.name);
|
|
27
|
-
const
|
|
30
|
+
const dirPath = isDir ? indexDirOf(sub.name) : '';
|
|
31
|
+
const name = dirPath === '' ? sub.name : dirPath + '/';
|
|
28
32
|
addItem({ name, kind: sub.kind, scope: sub.scope, path: sub.path, shortForm: sub.shortForm, isDir, slash: sub.slash });
|
|
29
33
|
}
|
|
30
34
|
items.sort((a, b) => {
|
|
@@ -53,7 +57,7 @@ export const listLeaf = defineLeaf({
|
|
|
53
57
|
{ kind: 'flag', name: 'cursor', type: 'string', required: false, constraint: 'Opaque token from next_cursor. Omit on first call.' },
|
|
54
58
|
],
|
|
55
59
|
output: [
|
|
56
|
-
{ name: 'items', type: 'object[]', required: true, constraint: 'One row per document: {name, short_form, kind, scope, is_dir}. path is included only with --paths. A directory INDEX.md surfaces as a dir entry (is_dir true, name = the directory path with a trailing slash); is_dir is false for an ordinary doc. short_form is the abbreviated hook — shown here and nowhere else. Sorted by scope then kind then name ascending.' },
|
|
60
|
+
{ name: 'items', type: 'object[]', required: true, constraint: 'One row per document: {name, short_form, kind, scope, is_dir}. path is included only with --paths. A directory INDEX.md surfaces as a dir entry (is_dir true, name = the directory path with a trailing slash, or `INDEX` for a store-root INDEX that has no directory path); is_dir is false for an ordinary doc. Every name is readable as-is with `crtr memory read`. short_form is the abbreviated hook — shown here and nowhere else. Sorted by scope then kind then name ascending.' },
|
|
57
61
|
{ name: 'next_cursor', type: 'string | null', required: true, constraint: 'Opaque token for the next page; null means no more items.' },
|
|
58
62
|
{ name: 'total', type: 'integer | null', required: true, constraint: 'Exact total across all pages when cheap to compute (always cheap here); never null in practice for this leaf.' },
|
|
59
63
|
{ name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands — read a document in full or narrow the inventory.' },
|
|
@@ -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,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
|
+
});
|
|
@@ -98,12 +98,10 @@ test('a welcome is a COMPLETE catch-up: a re-welcome drops a dead broker s level
|
|
|
98
98
|
const s0 = fold(initialSessionState(), [
|
|
99
99
|
welcome(),
|
|
100
100
|
{ type: 'extension_ui_request', id: 'u1', method: 'setStatus', statusKey: 'gone', statusText: 'stale' },
|
|
101
|
-
{ type: 'queue_update', steering: ['one', 'two'], followUp: []
|
|
101
|
+
{ type: 'queue_update', steering: ['one', 'two'], followUp: [] },
|
|
102
102
|
]);
|
|
103
103
|
assert.equal(s0.display.statuses['gone'], 'stale');
|
|
104
|
-
|
|
105
|
-
// short array) still renders as a bare-text entry.
|
|
106
|
-
assert.deepEqual(s0.queued, [{ id: 'id-1', text: 'one' }, { text: 'two' }]);
|
|
104
|
+
assert.deepEqual(s0.queued, [{ text: 'one' }, { text: 'two' }]);
|
|
107
105
|
assert.equal(s0.engine?.pendingMessageCount, 2);
|
|
108
106
|
// A fresh broker (post-revive) welcomes with its OWN levels and an empty queue,
|
|
109
107
|
// and emits no queue_update to overwrite the panel — so the welcome must.
|
|
@@ -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;
|
package/dist/core/config.js
CHANGED
|
@@ -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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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.
|
|
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
|
|
537
|
-
|
|
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
|
|
550
|
-
kinds = mergeKinds(raw
|
|
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,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
|
+
}
|
package/dist/core/resolver.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { InstalledMarketplace, InstalledPlugin, Scope } from '../types.js';
|
|
2
|
-
export
|
|
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;
|