@north-light/crouter 0.3.314 → 0.3.316
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/plugin-manifest-schema.d.ts +2 -1
- package/dist/build-root.d.ts +1 -0
- package/dist/build-root.js +41 -6
- package/dist/commands/human.js +1 -0
- package/dist/commands/pkg/browse/catalog.js +4 -4
- package/dist/commands/pkg/plugin-inspect.js +6 -6
- package/dist/commands/pkg/plugin-manage.js +13 -10
- package/dist/commands/sys/doctor.js +3 -2
- package/dist/core/__tests__/integration/command-plugins.test.js +84 -0
- package/dist/core/command-manifests/manifest.d.ts +9 -0
- package/dist/core/command-manifests/manifest.js +22 -3
- package/dist/core/command-manifests/registry.d.ts +11 -1
- package/dist/core/command-manifests/registry.js +1 -0
- package/dist/core/command-plugins/bundle.d.ts +1 -0
- package/dist/core/command-plugins/bundle.js +3 -3
- package/dist/core/command-plugins/compose.d.ts +5 -3
- package/dist/core/command-plugins/compose.js +43 -25
- package/dist/core/command-plugins/discovery.d.ts +13 -4
- package/dist/core/command-plugins/discovery.js +57 -25
- package/dist/core/command-plugins/help-addenda.d.ts +1 -0
- package/dist/core/command-plugins/help-addenda.js +3 -2
- package/dist/core/command.d.ts +9 -0
- package/dist/core/command.js +6 -1
- package/dist/core/help.d.ts +2 -0
- package/dist/core/help.js +2 -1
- package/dist/core/human/answer-text.d.ts +4 -2
- package/dist/core/human/answer-text.js +6 -4
- package/dist/daemon/human/finish.js +76 -4
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
|
@@ -161,7 +161,8 @@ export type ManifestNode<L extends ManifestLeafBase = ManifestLeaf> = ManifestBr
|
|
|
161
161
|
/** One mount point in the manifest's self-contained forest. */
|
|
162
162
|
export interface ManifestMount<L extends ManifestLeafBase = ManifestLeaf> {
|
|
163
163
|
/** `[]` mounts `node` as a new top-level command; a non-empty path names a
|
|
164
|
-
* branch this
|
|
164
|
+
* branch this manifest already contributes or an extensible core branch.
|
|
165
|
+
* `human` is currently the only extensible core branch. */
|
|
165
166
|
parent: string[];
|
|
166
167
|
node: ManifestNode<L>;
|
|
167
168
|
}
|
package/dist/build-root.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { type CoreHookCatalog } from './core/command-hooks/catalog.js';
|
|
|
5
5
|
export declare const SUBTREE_NAMES: readonly string[];
|
|
6
6
|
export declare function coreHookCatalog(): Promise<CoreHookCatalog>;
|
|
7
7
|
export declare function coreCommandPaths(): Promise<ReadonlySet<string>>;
|
|
8
|
+
export declare function extensibleCoreBranches(): Promise<ReadonlySet<string>>;
|
|
8
9
|
/** Whether argv targets the daemon-control subtree after the dispatcher's global-token normalization. This only selects a smaller canonical tree; `runCli` still parses, renders help, and reports errors. */
|
|
9
10
|
export declare function isDaemonControlInvocation(argv: readonly string[]): boolean;
|
|
10
11
|
/** Assemble the canonical daemon-control definitions without loading settings or plugin discovery. This path exists so `crtr sys daemon restart` can start the new generation that converts pre-SQL user state. */
|
package/dist/build-root.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineRoot, GLOBAL_TOKENS } from './core/command.js';
|
|
1
|
+
import { defineBranch, defineRoot, GLOBAL_TOKENS } from './core/command.js';
|
|
2
2
|
import { createCoreHookCatalog } from './core/command-hooks/catalog.js';
|
|
3
3
|
import { composeCoreHooks } from './core/command-hooks/compose.js';
|
|
4
4
|
import { mark } from './core/timing.js';
|
|
@@ -89,8 +89,7 @@ function hookRegistryLoader() {
|
|
|
89
89
|
return registry;
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
|
-
|
|
93
|
-
const core = await loadCoreSubtrees();
|
|
92
|
+
function commandPathsFor(core) {
|
|
94
93
|
const paths = new Set();
|
|
95
94
|
const visit = (node, prefix) => {
|
|
96
95
|
if (node.kind === 'branch' && node.passthrough !== undefined)
|
|
@@ -104,6 +103,40 @@ export async function coreCommandPaths() {
|
|
|
104
103
|
visit(subtree, subtree.name);
|
|
105
104
|
return paths;
|
|
106
105
|
}
|
|
106
|
+
export async function coreCommandPaths() {
|
|
107
|
+
return commandPathsFor(await loadCoreSubtrees());
|
|
108
|
+
}
|
|
109
|
+
export async function extensibleCoreBranches() {
|
|
110
|
+
return new Set((await loadCoreSubtrees()).filter((branch) => branch.extensible === true).map((branch) => branch.name));
|
|
111
|
+
}
|
|
112
|
+
async function composeCorePluginMounts(core) {
|
|
113
|
+
const extensible = new Set(core.filter((branch) => branch.extensible === true).map((branch) => branch.name));
|
|
114
|
+
if (extensible.size === 0)
|
|
115
|
+
return core;
|
|
116
|
+
const [{ buildExternalCommandSnapshot }, { composeCoreBranchMounts }] = await Promise.all([
|
|
117
|
+
import('./core/command-plugins/discovery.js'),
|
|
118
|
+
import('./core/command-plugins/compose.js'),
|
|
119
|
+
]);
|
|
120
|
+
const snapshot = buildExternalCommandSnapshot(new Set(SUBTREE_NAMES), undefined, undefined, commandPathsFor(core), extensible);
|
|
121
|
+
return core.map((branch) => {
|
|
122
|
+
const mounted = composeCoreBranchMounts(snapshot.coreMounts, [branch.name]);
|
|
123
|
+
if (mounted.length === 0)
|
|
124
|
+
return branch;
|
|
125
|
+
const { listing: _listing, ...help } = branch.help;
|
|
126
|
+
return defineBranch({
|
|
127
|
+
name: branch.name,
|
|
128
|
+
...(branch.description !== undefined ? { description: branch.description } : {}),
|
|
129
|
+
...(branch.whenToUse !== undefined ? { whenToUse: branch.whenToUse } : {}),
|
|
130
|
+
...(branch.tier !== undefined ? { tier: branch.tier } : {}),
|
|
131
|
+
...(branch.rootEntry !== undefined ? { rootEntry: branch.rootEntry } : {}),
|
|
132
|
+
...(branch.extensible === true ? { extensible: true } : {}),
|
|
133
|
+
...(branch.slash !== undefined ? { slash: branch.slash } : {}),
|
|
134
|
+
...(branch.passthrough !== undefined ? { passthrough: branch.passthrough } : {}),
|
|
135
|
+
help,
|
|
136
|
+
children: [...branch.children, ...mounted],
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
107
140
|
/** Whether argv targets the daemon-control subtree after the dispatcher's global-token normalization. This only selects a smaller canonical tree; `runCli` still parses, renders help, and reports errors. */
|
|
108
141
|
export function isDaemonControlInvocation(argv) {
|
|
109
142
|
const tokens = argv.slice(2).filter((token) => !GLOBAL_TOKENS.has(token));
|
|
@@ -123,7 +156,8 @@ export async function resolveDaemonControlRoot() {
|
|
|
123
156
|
export async function resolveRoot(first) {
|
|
124
157
|
const loader = first !== undefined ? SUBTREE_LOADERS[first] : undefined;
|
|
125
158
|
if (loader !== undefined) {
|
|
126
|
-
|
|
159
|
+
const core = [await loader()];
|
|
160
|
+
return defineRoot({ tagline: TAGLINE, globals: GLOBALS, subtrees: composeCoreHooks(await composeCorePluginMounts(core), hookRegistryLoader()) });
|
|
127
161
|
}
|
|
128
162
|
return buildRoot();
|
|
129
163
|
}
|
|
@@ -156,8 +190,9 @@ export async function buildRoot() {
|
|
|
156
190
|
// ONE collision-resolved snapshot over every command plugin transport. Core always wins: any external contribution claiming a core top-level
|
|
157
191
|
// name is dropped + recorded as an issue (surfaced via pkg plugin show,
|
|
158
192
|
// sys doctor). Reads only stored manifests — zero network.
|
|
159
|
-
const
|
|
193
|
+
const composedCore = await composeCorePluginMounts(core);
|
|
194
|
+
const snapshot = buildExternalCommandSnapshot(new Set(SUBTREE_NAMES), undefined, undefined, commandPathsFor(core), new Set(composedCore.filter((branch) => branch.extensible === true).map((branch) => branch.name)));
|
|
160
195
|
const external = composeExternalSubtrees(snapshot);
|
|
161
196
|
// Only core leaves are hookable, so external contributions compose unwrapped.
|
|
162
|
-
return defineRoot({ tagline: TAGLINE, globals: GLOBALS, subtrees: [...composeCoreHooks(
|
|
197
|
+
return defineRoot({ tagline: TAGLINE, globals: GLOBALS, subtrees: [...composeCoreHooks(composedCore, hookRegistryLoader()), ...external] });
|
|
163
198
|
}
|
package/dist/commands/human.js
CHANGED
|
@@ -28,6 +28,7 @@ export function registerHuman() {
|
|
|
28
28
|
: `Tickets queue in the human inbox; nothing opens on screen. \`send\` routes completion back when the page carries response-bearing components, and publishes a standalone announcement when it does not. ${durableRequests} \`review\` is for live document review with anchored comments; \`show\` is a passive tmux live-watch rather than a ticket.`;
|
|
29
29
|
return defineBranch({
|
|
30
30
|
name: 'human',
|
|
31
|
+
extensible: true,
|
|
31
32
|
rootEntry: {
|
|
32
33
|
concept,
|
|
33
34
|
desc: pageSurface ? 'send, components, feedback, cancel, list' : 'send, review new/comment, show, cancel, list',
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// live in actions.ts.
|
|
3
3
|
import { statSync } from 'node:fs';
|
|
4
4
|
import { isAbsolute, join } from 'node:path';
|
|
5
|
-
import { SUBTREE_NAMES, coreCommandPaths } from '../../../build-root.js';
|
|
5
|
+
import { SUBTREE_NAMES, coreCommandPaths, extensibleCoreBranches } from '../../../build-root.js';
|
|
6
6
|
import { discoverPluginCommandCandidates, validateEffectiveCommandPlugins, validatePluginCommands } from '../../../core/command-plugins/discovery.js';
|
|
7
7
|
import { qualifiedTopLevelCommandName } from '../../../core/command-manifests/registry.js';
|
|
8
8
|
import { listDirs, pathExists, walkFiles } from '../../../core/fs-utils.js';
|
|
@@ -391,15 +391,15 @@ export async function loadCapability(entry) {
|
|
|
391
391
|
const scope = entry.install.kind === "installed" ? entry.install.scope : "user";
|
|
392
392
|
const plugin = { name: entry.name, scope, root: entry.root, manifest, enabled: entry.install.kind !== "installed" || entry.install.enabled };
|
|
393
393
|
const reserved = new Set(SUBTREE_NAMES);
|
|
394
|
-
const corePaths = await coreCommandPaths();
|
|
395
|
-
let validation = validatePluginCommands(plugin, reserved, corePaths);
|
|
394
|
+
const [corePaths, extensibleBranches] = await Promise.all([coreCommandPaths(), extensibleCoreBranches()]);
|
|
395
|
+
let validation = validatePluginCommands(plugin, reserved, corePaths, extensibleBranches);
|
|
396
396
|
const registryIssues = [];
|
|
397
397
|
// Effective validation resolves collisions for the command that is mounted
|
|
398
398
|
// today. An uninstalled or disabled plugin is not in that registry, so its
|
|
399
399
|
// dossier must validate the declared tree and preview the future result.
|
|
400
400
|
if (entry.install.kind === "installed" && entry.install.enabled) {
|
|
401
401
|
try {
|
|
402
|
-
const effective = validateEffectiveCommandPlugins(reserved, undefined, undefined, corePaths);
|
|
402
|
+
const effective = validateEffectiveCommandPlugins(reserved, undefined, undefined, corePaths, extensibleBranches);
|
|
403
403
|
validation = effective.find((candidate) => candidate.plugin.root === entry.root) ?? validation;
|
|
404
404
|
}
|
|
405
405
|
catch (error) {
|
|
@@ -6,7 +6,7 @@ import { listPluginMemoryDocs } from '../../core/memory-resolver.js';
|
|
|
6
6
|
import { validatePluginCommands, validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
|
|
7
7
|
import { compileHookRegistry, discoverHookRegistry } from '../../core/command-hooks/discovery.js';
|
|
8
8
|
import { hasDeclaredHooks, hookReport } from '../../core/command-hooks/report.js';
|
|
9
|
-
import { SUBTREE_NAMES, coreCommandPaths, coreHookCatalog } from '../../build-root.js';
|
|
9
|
+
import { SUBTREE_NAMES, coreCommandPaths, coreHookCatalog, extensibleCoreBranches } from '../../build-root.js';
|
|
10
10
|
import { resolveScopeArg, projectScopeRoot } from '../../core/scope.js';
|
|
11
11
|
async function hookInspectionReport(plugin) {
|
|
12
12
|
if (!hasDeclaredHooks(plugin.manifest))
|
|
@@ -106,7 +106,7 @@ export const pluginShow = defineLeaf({
|
|
|
106
106
|
{ name: 'enabled', type: 'boolean', required: true, constraint: 'Whether the plugin is active.' },
|
|
107
107
|
{ name: 'manifest', type: 'object', required: true, constraint: 'Full plugin.json contents.' },
|
|
108
108
|
{ name: 'docs', type: 'object[]', required: true, constraint: 'Each: {name, path}. Memory docs provided by the plugin (its `<pluginName>/` memory subtree).' },
|
|
109
|
-
{ name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest (manifest.commands). {manifestPath: string, mounts: string[] (accepted
|
|
109
|
+
{ name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest (manifest.commands). {manifestPath: string, mounts: string[] (accepted command paths), extensibleBranches: string[] (plugin mount names accepting repository fragments), issues: object[] ({code, path?, message, received, expected, next})}. Validated statically; command execution never occurs.' },
|
|
110
110
|
{ name: 'hooks', type: 'object', required: false, constraint: 'Present for every hook declaration, including hook-only plugins. {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[] ({code, path?, message, received, expected, next}), trust: string}. Static only: no hook executable runs.' },
|
|
111
111
|
],
|
|
112
112
|
outputKind: 'object',
|
|
@@ -135,17 +135,17 @@ export const pluginShow = defineLeaf({
|
|
|
135
135
|
let commands;
|
|
136
136
|
if (typeof found.manifest.commands === 'string' && found.manifest.commands.length > 0) {
|
|
137
137
|
const reserved = new Set(SUBTREE_NAMES);
|
|
138
|
-
const corePaths = await coreCommandPaths();
|
|
138
|
+
const [corePaths, extensibleBranches] = await Promise.all([coreCommandPaths(), extensibleCoreBranches()]);
|
|
139
139
|
// Validate against the whole effective set so cross-plugin top-level
|
|
140
140
|
// name collisions show up in this plugin's issues (a collided command is
|
|
141
141
|
// NOT mounted). A copy outside the effective set (disabled, or shadowed
|
|
142
142
|
// by a higher-precedence copy) cannot collide, so the per-plugin
|
|
143
143
|
// validator is exact for it.
|
|
144
|
-
const v = validateEffectiveCommandPlugins(reserved, undefined, undefined, corePaths).find((x) => x.plugin.root === found.root) ??
|
|
145
|
-
validatePluginCommands(found, reserved, corePaths);
|
|
144
|
+
const v = validateEffectiveCommandPlugins(reserved, undefined, undefined, corePaths, extensibleBranches).find((x) => x.plugin.root === found.root) ??
|
|
145
|
+
validatePluginCommands(found, reserved, corePaths, extensibleBranches);
|
|
146
146
|
commands = {
|
|
147
147
|
manifestPath: v.manifestPath,
|
|
148
|
-
mounts: v.contributions.map((c) => c.node.name),
|
|
148
|
+
mounts: [...v.contributions.map((c) => c.node.name), ...v.coreMounts.map((mount) => [...mount.parent, mount.node.name].join(' '))],
|
|
149
149
|
extensibleBranches: v.contributions.filter((c) => c.node.extensible === true).map((c) => c.node.name),
|
|
150
150
|
issues: v.issues,
|
|
151
151
|
};
|
|
@@ -25,7 +25,7 @@ import { validatePluginBundle } from '../../core/command-plugins/bundle.js';
|
|
|
25
25
|
import { compileHookRegistry, discoverCandidateHookRegistry, discoverCandidateHookRegistryForCandidates, discoverHookRegistry } from '../../core/command-hooks/discovery.js';
|
|
26
26
|
import { hasDeclaredHooks, hookReport } from '../../core/command-hooks/report.js';
|
|
27
27
|
import { fetchHttpPluginBundle } from '../../core/command-plugins/transport/http-fetch.js';
|
|
28
|
-
import { SUBTREE_NAMES, coreCommandPaths, coreHookCatalog } from '../../build-root.js';
|
|
28
|
+
import { SUBTREE_NAMES, coreCommandPaths, coreHookCatalog, extensibleCoreBranches } from '../../build-root.js';
|
|
29
29
|
import { isGitUrl, resolvePluginConfigTarget, setPluginEnabled, resolveInstallScope } from './shared.js';
|
|
30
30
|
/** Validate a just-installed/updated plugin's command manifest, if it declares
|
|
31
31
|
* one. Returns undefined for plugins without a `commands` pointer. Never
|
|
@@ -36,13 +36,13 @@ async function commandReport(name, scope) {
|
|
|
36
36
|
return undefined;
|
|
37
37
|
}
|
|
38
38
|
const reserved = new Set(SUBTREE_NAMES);
|
|
39
|
-
const corePaths = await coreCommandPaths();
|
|
39
|
+
const [corePaths, extensibleBranches] = await Promise.all([coreCommandPaths(), extensibleCoreBranches()]);
|
|
40
40
|
// Report from the effective set so a cross-plugin top-level collision is
|
|
41
41
|
// visible at install time (a collided command is NOT mounted). A copy
|
|
42
42
|
// outside the effective set (disabled, or shadowed) cannot collide.
|
|
43
|
-
const v = validateEffectiveCommandPlugins(reserved, undefined, undefined, corePaths).find((x) => x.plugin.root === plugin.root) ??
|
|
44
|
-
validatePluginCommands(plugin, reserved, corePaths);
|
|
45
|
-
return { mounts: v.contributions.map((c) => c.node.name), issues: v.issues };
|
|
43
|
+
const v = validateEffectiveCommandPlugins(reserved, undefined, undefined, corePaths, extensibleBranches).find((x) => x.plugin.root === plugin.root) ??
|
|
44
|
+
validatePluginCommands(plugin, reserved, corePaths, extensibleBranches);
|
|
45
|
+
return { mounts: [...v.contributions.map((c) => c.node.name), ...v.coreMounts.map((mount) => [...mount.parent, mount.node.name].join(' '))], issues: v.issues };
|
|
46
46
|
}
|
|
47
47
|
const PLUGIN_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
48
48
|
function validateHttpInstall(name, endpoint, authEnv) {
|
|
@@ -238,7 +238,8 @@ export async function assertPluginCandidateValid(manifest, root, scope, scopeRoo
|
|
|
238
238
|
assertManifestPageComponentsValid(manifest, where);
|
|
239
239
|
assertManifestBinValid(manifest, where);
|
|
240
240
|
assertManifestRequiresValid(manifest, where);
|
|
241
|
-
const
|
|
241
|
+
const [corePaths, extensibleBranches] = await Promise.all([coreCommandPaths(), extensibleCoreBranches()]);
|
|
242
|
+
const commandValidation = validatePluginCommands({ name: manifest.name, scope, root, manifest, enabled: true }, new Set(SUBTREE_NAMES), corePaths, extensibleBranches);
|
|
242
243
|
if (commandValidation.issues.length > 0) {
|
|
243
244
|
throw invalidBundleError(`plugin command behavior at ${where} is invalid`, commandValidation.issues);
|
|
244
245
|
}
|
|
@@ -296,7 +297,8 @@ function writeStagedBundle(stagingRoot, manifest, bundle) {
|
|
|
296
297
|
async function validateStagedBundle(name, scope, root, scopeRootPath, manifest) {
|
|
297
298
|
const memory = await assertPluginCandidateValid(manifest, root, scope, scopeRootPath, `${root}/.crouter-plugin/plugin.json`);
|
|
298
299
|
const staged = { name, scope, root, manifest, enabled: true };
|
|
299
|
-
const
|
|
300
|
+
const [corePaths, extensibleBranches] = await Promise.all([coreCommandPaths(), extensibleCoreBranches()]);
|
|
301
|
+
const validation = validatePluginCommands(staged, new Set(SUBTREE_NAMES), corePaths, extensibleBranches);
|
|
300
302
|
if (validation.issues.length > 0) {
|
|
301
303
|
throw invalidBundleError(`bundle staged package for "${name}" is invalid`, validation.issues);
|
|
302
304
|
}
|
|
@@ -471,7 +473,8 @@ function isInstalledGeneration(name, scope, root, version) {
|
|
|
471
473
|
}
|
|
472
474
|
async function applyBundleArchive(name, scope, target, raw, etag, options, knownVersion) {
|
|
473
475
|
const { bundle, transport, scopeRootPath, root } = target;
|
|
474
|
-
const
|
|
476
|
+
const [corePaths, extensibleBranches] = await Promise.all([coreCommandPaths(), extensibleCoreBranches()]);
|
|
477
|
+
const validated = await validatePluginBundle(raw, { reservedCoreNames: new Set(SUBTREE_NAMES), coreCommandPaths: corePaths, extensibleCoreBranches: extensibleBranches });
|
|
475
478
|
if (validated.bundle === undefined)
|
|
476
479
|
throw invalidBundleError(`HTTP plugin bundle for "${name}" is invalid`, validated.issues);
|
|
477
480
|
const version = knownVersion ?? bundleVersion(raw);
|
|
@@ -881,7 +884,7 @@ export const pluginInstall = defineLeaf({
|
|
|
881
884
|
{ name: 'docs', type: 'integer', required: false, constraint: 'Memory document count written from an --endpoint archive.' },
|
|
882
885
|
{ name: 'kinds', type: 'integer', required: false, constraint: 'Kind-registry entry count declared by an --endpoint archive\u2019s bundle.json.' },
|
|
883
886
|
{ name: 'pageComponents', type: 'integer', required: false, constraint: 'Page-component registration count declared by an --endpoint archive\u2019s bundle.json.' },
|
|
884
|
-
{ name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest. {mounts: string[] (accepted
|
|
887
|
+
{ name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest. {mounts: string[] (accepted command paths 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.' },
|
|
885
888
|
{ name: 'hooks', type: 'object', required: false, constraint: 'Present only when the plugin declares hooks. {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[], trust: string}. Command targets, lifecycle events, and replacement collisions are validated statically; hook executables are never run.' },
|
|
886
889
|
{ name: 'warnings', type: 'string[]', required: false, constraint: 'Advisory missing PATH executable requirements declared by the installed plugin. The install still succeeds and leaves the plugin enabled.' },
|
|
887
890
|
{ name: 'memory', type: 'object', required: false, constraint: 'Present only when the plugin ships a memory store. {docs: integer (documents checked), migrated: string[] (store-relative documents rewritten to reach exact canonical identity), linkedSource?: string (the caller-owned directory converged in place, present only when the install keeps that directory as a live link)}.' },
|
|
@@ -1064,7 +1067,7 @@ export const pluginUpdate = defineLeaf({
|
|
|
1064
1067
|
{ kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Narrows resolution.' },
|
|
1065
1068
|
],
|
|
1066
1069
|
output: [
|
|
1067
|
-
{ name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, transport?, updated, sha?, version?, docs?, kinds?, pageComponents?, commands?, hooks?, memory?, warnings?, error?}. memory is present for a plugin shipping a memory store and reports the same corpus migration install performs: {docs, migrated, linkedSource?}. 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, kinds is the declared kind-registry entry count, and pageComponents is the declared page-component registration 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[]}. hooks is present for plugins declaring hooks: {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[], trust: string}; it is static and never executes hooks. warnings lists advisory PATH requirements that remain absent after update.' }
|
|
1070
|
+
{ name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, transport?, updated, sha?, version?, docs?, kinds?, pageComponents?, commands?, hooks?, memory?, warnings?, error?}. memory is present for a plugin shipping a memory store and reports the same corpus migration install performs: {docs, migrated, linkedSource?}. 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, kinds is the declared kind-registry entry count, and pageComponents is the declared page-component registration 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[] (accepted command paths), issues: object[]}. hooks is present for plugins declaring hooks: {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[], trust: string}; it is static and never executes hooks. warnings lists advisory PATH requirements that remain absent after update.' }
|
|
1068
1071
|
],
|
|
1069
1072
|
outputKind: 'object',
|
|
1070
1073
|
effects: [
|
|
@@ -20,7 +20,7 @@ import { resolveBinContributions } from '../../core/runtime/bin-contributions.js
|
|
|
20
20
|
import { inspectHumanActions } from '../../core/human-actions.js';
|
|
21
21
|
import { buildBrokerEnv, resolvePathExecutable } from '../../core/runtime/spawn-env.js';
|
|
22
22
|
import { listAllPlugins } from '../../core/resolver.js';
|
|
23
|
-
import { SUBTREE_NAMES, coreCommandPaths, coreHookCatalog } from '../../build-root.js';
|
|
23
|
+
import { SUBTREE_NAMES, coreCommandPaths, coreHookCatalog, extensibleCoreBranches } from '../../build-root.js';
|
|
24
24
|
import { processGeneration, processLocatedGeneration, runtimeManifest, selectedGeneration } from './shared.js';
|
|
25
25
|
import { CONFIG_FILE } from '../../types.js';
|
|
26
26
|
function pass(scope, name, message) {
|
|
@@ -125,7 +125,8 @@ export async function runCommandPluginChecks(scopes) {
|
|
|
125
125
|
// collisions surface here. A collision no longer costs anyone their command:
|
|
126
126
|
// each claimant mounts under an origin-qualified name, so it is reported as a
|
|
127
127
|
// healthy mount rather than a failure demanding the user remove a plugin.
|
|
128
|
-
const
|
|
128
|
+
const [corePaths, extensibleBranches] = await Promise.all([coreCommandPaths(), extensibleCoreBranches()]);
|
|
129
|
+
const validations = validateEffectiveCommandPlugins(reserved, undefined, undefined, corePaths, extensibleBranches);
|
|
129
130
|
for (const v of validations) {
|
|
130
131
|
const plugin = v.plugin;
|
|
131
132
|
if (!inScope.has(plugin.scope))
|
|
@@ -117,6 +117,33 @@ function commandsJson(topName = 'app') {
|
|
|
117
117
|
],
|
|
118
118
|
};
|
|
119
119
|
}
|
|
120
|
+
function humanCommandsJson(parent = ['human'], name = 'pr') {
|
|
121
|
+
return {
|
|
122
|
+
schemaVersion: 1,
|
|
123
|
+
helpAddenda: { human: 'Pull request review commands are provided by this plugin.' },
|
|
124
|
+
mounts: [{
|
|
125
|
+
parent,
|
|
126
|
+
node: {
|
|
127
|
+
kind: 'branch',
|
|
128
|
+
name,
|
|
129
|
+
description: 'pull request review',
|
|
130
|
+
whenToUse: 'a person needs to review a pull request',
|
|
131
|
+
summary: 'review a pull request with a person',
|
|
132
|
+
children: [{
|
|
133
|
+
kind: 'leaf',
|
|
134
|
+
name: 'review',
|
|
135
|
+
description: 'start pull request review',
|
|
136
|
+
whenToUse: 'the pull request is ready for review',
|
|
137
|
+
summary: 'start one pull request review',
|
|
138
|
+
params: [{ kind: 'flag', name: 'app-id', type: 'string', required: true, constraint: 'fixture pull request id' }],
|
|
139
|
+
output: [{ name: 'app_id', type: 'string', required: true, constraint: 'echoed fixture pull request id' }, { name: 'status', type: 'string', required: false, constraint: 'fixture status' }],
|
|
140
|
+
outputKind: 'object',
|
|
141
|
+
effects: ['None. Read-only.'],
|
|
142
|
+
}],
|
|
143
|
+
},
|
|
144
|
+
}],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
120
147
|
function extensibleDemoCommandsJson(extensible = true) {
|
|
121
148
|
return {
|
|
122
149
|
schemaVersion: 1,
|
|
@@ -323,6 +350,62 @@ describe('registry composition', () => {
|
|
|
323
350
|
assert.match(leafOut, /None\. Read-only\./);
|
|
324
351
|
});
|
|
325
352
|
});
|
|
353
|
+
describe('extensible core mounts', () => {
|
|
354
|
+
test('a plugin mounts under human in real help and dispatch receives the full path', async () => {
|
|
355
|
+
installPlugin(userRoot, 'pr-review', { manifest: humanCommandsJson() });
|
|
356
|
+
resetScopeCache();
|
|
357
|
+
const { resolveRoot } = await import('../../../build-root.js');
|
|
358
|
+
const { runCli } = await import('../../command.js');
|
|
359
|
+
const root = await resolveRoot('human');
|
|
360
|
+
const chunks = [];
|
|
361
|
+
const realWrite = process.stdout.write.bind(process.stdout);
|
|
362
|
+
const realCwd = process.cwd();
|
|
363
|
+
const requestLog = join(emptyStart, 'human-mount-request.json');
|
|
364
|
+
process.env['FIXTURE_REQUEST_LOG'] = requestLog;
|
|
365
|
+
process.chdir(emptyStart);
|
|
366
|
+
process.stdout.write = ((chunk) => {
|
|
367
|
+
chunks.push(String(chunk));
|
|
368
|
+
return true;
|
|
369
|
+
});
|
|
370
|
+
try {
|
|
371
|
+
await runCli(root, ['node', 'crtr', 'human', '-h']);
|
|
372
|
+
await runCli(root, ['node', 'crtr', 'human', 'pr', 'review', '--app-id', '42', '--json']);
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
process.stdout.write = realWrite;
|
|
376
|
+
process.chdir(realCwd);
|
|
377
|
+
delete process.env['FIXTURE_REQUEST_LOG'];
|
|
378
|
+
}
|
|
379
|
+
assert.match(chunks.join(''), /name="pr"[^>]*plugin="pr-review"/);
|
|
380
|
+
assert.match(chunks.join(''), /<plugin-help plugin="pr-review">/);
|
|
381
|
+
assert.deepEqual(JSON.parse(readFileSync(requestLog, 'utf8')).command, ['human', 'pr', 'review']);
|
|
382
|
+
});
|
|
383
|
+
test('plugin installation accepts a human mount', async () => {
|
|
384
|
+
const source = join(mintDir('crtr-human-mount-source-'), 'fixture');
|
|
385
|
+
mkdirSync(join(source, '.crouter-plugin'), { recursive: true });
|
|
386
|
+
mkdirSync(join(source, 'bin'), { recursive: true });
|
|
387
|
+
writeFileSync(join(source, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: 'human-mount-install', version: '0.1.0', description: 'fixture', commands: 'commands.json', transport: { kind: 'exec', executable: 'bin/cmd.js' } }));
|
|
388
|
+
writeFileSync(join(source, 'commands.json'), JSON.stringify(humanCommandsJson()));
|
|
389
|
+
writeFileSync(join(source, 'bin', 'cmd.js'), FIXTURE_EXEC);
|
|
390
|
+
chmodSync(join(source, 'bin', 'cmd.js'), 0o755);
|
|
391
|
+
await pluginInstall.run({ installRef: source, scope: 'user' });
|
|
392
|
+
});
|
|
393
|
+
test('only explicitly extensible core branches accept mounts and core children win collisions', () => {
|
|
394
|
+
const node = listInstalledPluginsInRoot('user', userRoot);
|
|
395
|
+
installPlugin(userRoot, 'wrong-parent', { manifest: humanCommandsJson(['node']) });
|
|
396
|
+
installPlugin(userRoot, 'core-child', { manifest: humanCommandsJson(['human'], 'send') });
|
|
397
|
+
resetScopeCache();
|
|
398
|
+
const plugins = listInstalledPluginsInRoot('user', userRoot);
|
|
399
|
+
const options = { reserved: new Set(['human', 'node']), paths: new Set(['human', 'human send']), extensible: new Set(['human']) };
|
|
400
|
+
const wrongParent = validatePluginCommands(plugins.find((plugin) => plugin.name === 'wrong-parent'), options.reserved, options.paths, options.extensible);
|
|
401
|
+
const coreChild = validatePluginCommands(plugins.find((plugin) => plugin.name === 'core-child'), options.reserved, options.paths, options.extensible);
|
|
402
|
+
assert.equal(wrongParent.contributions.length, 0);
|
|
403
|
+
assert.match(wrongParent.issues[0]?.next ?? '', /extensible core branches: human/);
|
|
404
|
+
assert.equal(coreChild.contributions.length, 0);
|
|
405
|
+
assert.match(coreChild.issues[0]?.message ?? '', /conflicts with a child the extensible core branch already owns/);
|
|
406
|
+
assert.equal(node.length, 0);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
326
409
|
// 2. Core collision
|
|
327
410
|
describe('core collision', () => {
|
|
328
411
|
test('a fixture claiming a core top-level name is skipped + recorded', () => {
|
|
@@ -872,6 +955,7 @@ describe('plugin help addenda', () => {
|
|
|
872
955
|
const gate = (paths) => ({
|
|
873
956
|
reservedCoreNames: RESERVED,
|
|
874
957
|
coreCommandPaths: () => Promise.resolve(new Set(paths)),
|
|
958
|
+
extensibleCoreBranches: () => Promise.resolve(new Set(['human'])),
|
|
875
959
|
});
|
|
876
960
|
test('a helpAddenda entry survives validation and is collected attributed to its plugin', async () => {
|
|
877
961
|
const m = commandsJson();
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import type { DeclLeaf, DeclNode, TransportKind, ManifestTimeouts, CommandManifestIssue } from './schema.js';
|
|
2
|
+
export interface ValidatedCoreMount {
|
|
3
|
+
/** The explicitly extensible core branch receiving this plugin node. */
|
|
4
|
+
parent: string[];
|
|
5
|
+
node: DeclNode<DeclLeaf>;
|
|
6
|
+
}
|
|
2
7
|
export interface ValidatedCommandManifest {
|
|
3
8
|
schemaVersion: 1;
|
|
4
9
|
baseUrl?: string;
|
|
5
10
|
timeouts?: ManifestTimeouts;
|
|
6
11
|
/** Top-level plugin branches or repository-fragment nodes, ready to mount. */
|
|
7
12
|
roots: DeclNode<DeclLeaf>[];
|
|
13
|
+
/** Plugin nodes mounted below an explicitly extensible core branch. */
|
|
14
|
+
coreMounts: ValidatedCoreMount[];
|
|
8
15
|
/** Core command path (space-joined, e.g. "cron add") → attributed addendum
|
|
9
16
|
* text appended beneath that core command's help. Append-only product
|
|
10
17
|
* guidance — a plugin can never alter core contract text. */
|
|
@@ -36,4 +43,6 @@ export declare function validateCommandManifest(raw: unknown, options: {
|
|
|
36
43
|
coreCommandPaths?: ReadonlySet<string>;
|
|
37
44
|
/** A repository fragment attaches roots below an existing extensible branch. */
|
|
38
45
|
extensionFragment?: boolean;
|
|
46
|
+
/** Core branch names that accept plugin children. */
|
|
47
|
+
extensibleCoreBranches?: ReadonlySet<string>;
|
|
39
48
|
}): CommandManifestValidation;
|
|
@@ -108,8 +108,20 @@ export function validateCommandManifest(raw, options) {
|
|
|
108
108
|
return { issues };
|
|
109
109
|
validatedMounts.push(m);
|
|
110
110
|
}
|
|
111
|
-
// Materialize
|
|
112
|
-
|
|
111
|
+
// Materialize plugin-local trees separately from mounts below an explicitly
|
|
112
|
+
// extensible core branch. Core mounts remain nodes, never fake plugin roots.
|
|
113
|
+
const coreMounts = validatedMounts.filter((mount) => mount.parent.length > 0 && options.extensibleCoreBranches?.has(mount.parent[0]) === true);
|
|
114
|
+
for (const mount of coreMounts) {
|
|
115
|
+
if (mount.parent.length !== 1) {
|
|
116
|
+
issue('command_parent_invalid', 'core mount parent must name an extensible core branch directly', mount.parent.join('.'), `one extensible core branch (one of: ${[...(options.extensibleCoreBranches ?? [])].join(', ')})`, `Mount below one of the extensible core branches: ${[...(options.extensibleCoreBranches ?? [])].join(', ')}.`, mount.parent.join('.'));
|
|
117
|
+
return { issues };
|
|
118
|
+
}
|
|
119
|
+
if (options.coreCommandPaths?.has(`${mount.parent[0]} ${mount.node.name}`) === true) {
|
|
120
|
+
issue('command_collision', `contributed command "${mount.node.name}" conflicts with a child the extensible core branch already owns`, mount.node.name, `a name not already owned by the ${mount.parent[0]} core branch`, 'Rename the contributed command or remove this mount.', `${mount.parent.join('.')}.${mount.node.name}`);
|
|
121
|
+
return { issues };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const roots = materializeForest(validatedMounts.filter((mount) => !coreMounts.includes(mount)), options.reservedCoreNames, options.extensibleCoreBranches, issue);
|
|
113
125
|
if (roots === null)
|
|
114
126
|
return { issues };
|
|
115
127
|
// If we got here with no issues, return the validated manifest
|
|
@@ -121,6 +133,7 @@ export function validateCommandManifest(raw, options) {
|
|
|
121
133
|
...(timeouts !== undefined ? { timeouts } : {}),
|
|
122
134
|
...(helpAddenda !== undefined ? { helpAddenda } : {}),
|
|
123
135
|
roots,
|
|
136
|
+
coreMounts,
|
|
124
137
|
},
|
|
125
138
|
issues: [],
|
|
126
139
|
};
|
|
@@ -220,7 +233,7 @@ function validateMount(raw, index, transport, issue, nodeOptions) {
|
|
|
220
233
|
* Materializes a flat list of mounts into a forest of top-level branches,
|
|
221
234
|
* resolving nested mounts whose parent exists in a top-level inline tree regardless of order. Nested mounts that provide another nested mount's parent must appear first. Validates self-containment and no duplicates.
|
|
222
235
|
*/
|
|
223
|
-
function materializeForest(mounts, reservedCoreNames, issue) {
|
|
236
|
+
function materializeForest(mounts, reservedCoreNames, extensibleCoreBranches, issue) {
|
|
224
237
|
// Separate top-level (parent: []) from nested (parent non-empty)
|
|
225
238
|
const topLevel = mounts.filter((m) => m.parent.length === 0);
|
|
226
239
|
const nested = mounts.filter((m) => m.parent.length > 0);
|
|
@@ -235,6 +248,12 @@ function materializeForest(mounts, reservedCoreNames, issue) {
|
|
|
235
248
|
const roots = topLevel.map((m) => m.node.kind === 'branch'
|
|
236
249
|
? { ...m.node, children: [...m.node.children] }
|
|
237
250
|
: { ...m.node });
|
|
251
|
+
for (const mount of nested) {
|
|
252
|
+
if (reservedCoreNames.has(mount.parent[0])) {
|
|
253
|
+
issue('command_parent_invalid', 'parent path starts with a core branch that is not extensible', mount.parent[0], `an extensible core branch (one of: ${[...(extensibleCoreBranches ?? [])].join(', ')})`, `Mount below one of the extensible core branches: ${[...(extensibleCoreBranches ?? [])].join(', ')}.`, mount.parent.join('.'));
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
238
257
|
// Attach nested mounts
|
|
239
258
|
for (const mount of nested) {
|
|
240
259
|
const resolved = resolveAndAttachMount(roots, mount, issue);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LeafDef } from '../command.js';
|
|
2
|
-
import type { DeclLeafBase, DeclBranch, CommandManifestIssue } from './schema.js';
|
|
2
|
+
import type { DeclLeafBase, DeclBranch, DeclNode, CommandManifestIssue } from './schema.js';
|
|
3
3
|
import type { ExtensionIssueNotice } from '../command-plugins/extensions.js';
|
|
4
4
|
/** Reference to an external contributor (plugin or HTTP-transport plugin). */
|
|
5
5
|
export type CommandContributorRef = {
|
|
@@ -37,12 +37,22 @@ export interface CommandContribution {
|
|
|
37
37
|
export interface ContributorIssue extends CommandManifestIssue {
|
|
38
38
|
contributor: CommandContributorRef;
|
|
39
39
|
}
|
|
40
|
+
/** One plugin node accepted below an explicitly extensible core branch. */
|
|
41
|
+
export interface CoreBranchMount {
|
|
42
|
+
parent: string[];
|
|
43
|
+
contributor: CommandContributorRef;
|
|
44
|
+
plugin: string;
|
|
45
|
+
node: DeclNode;
|
|
46
|
+
adaptLeaf: LeafAdapter;
|
|
47
|
+
}
|
|
40
48
|
/** Invocation-local registry snapshot after collision resolution: the accepted
|
|
41
49
|
* contributions and every discovered issue. */
|
|
42
50
|
export interface CommandRegistrySnapshot {
|
|
43
51
|
/** Accepted top-level contributions, deterministically ordered by
|
|
44
52
|
* contributor name. */
|
|
45
53
|
contributions: CommandContribution[];
|
|
54
|
+
/** Accepted plugin nodes below an extensible core branch. */
|
|
55
|
+
coreMounts: CoreBranchMount[];
|
|
46
56
|
/** Every discovered issue, including collisions. */
|
|
47
57
|
issues: ContributorIssue[];
|
|
48
58
|
}
|
|
@@ -48,4 +48,5 @@ export interface PluginBundleValidation {
|
|
|
48
48
|
export declare function validatePluginBundle(archive: Uint8Array, options: {
|
|
49
49
|
reservedCoreNames: ReadonlySet<string>;
|
|
50
50
|
coreCommandPaths?: ReadonlySet<string>;
|
|
51
|
+
extensibleCoreBranches?: ReadonlySet<string>;
|
|
51
52
|
}): Promise<PluginBundleValidation>;
|
|
@@ -187,7 +187,7 @@ function parseBundleMetadata(bytes) {
|
|
|
187
187
|
...(memoryExtensions !== undefined ? { memory_extensions: memoryExtensions } : {}),
|
|
188
188
|
};
|
|
189
189
|
}
|
|
190
|
-
function parseCommands(bytes, reservedCoreNames, coreCommandPaths) {
|
|
190
|
+
function parseCommands(bytes, reservedCoreNames, coreCommandPaths, extensibleCoreBranches = new Set()) {
|
|
191
191
|
let raw;
|
|
192
192
|
try {
|
|
193
193
|
raw = JSON.parse(Buffer.from(bytes).toString('utf8'));
|
|
@@ -197,7 +197,7 @@ function parseCommands(bytes, reservedCoreNames, coreCommandPaths) {
|
|
|
197
197
|
issues: [bundleInvalid('commands.json is not valid JSON', 'invalid JSON', 'a valid command-manifest JSON object', 'Regenerate commands.json.', 'commands.json')],
|
|
198
198
|
};
|
|
199
199
|
}
|
|
200
|
-
const validation = validateCommandManifest(raw, { transport: 'http', reservedCoreNames, ...(coreCommandPaths !== undefined ? { coreCommandPaths } : {}) });
|
|
200
|
+
const validation = validateCommandManifest(raw, { transport: 'http', reservedCoreNames, extensibleCoreBranches, ...(coreCommandPaths !== undefined ? { coreCommandPaths } : {}) });
|
|
201
201
|
return {
|
|
202
202
|
...(validation.manifest !== undefined ? { commands: validation.manifest } : {}),
|
|
203
203
|
issues: validation.issues,
|
|
@@ -232,7 +232,7 @@ export async function validatePluginBundle(archive, options) {
|
|
|
232
232
|
const metadata = parseBundleMetadata(bundle.bytes);
|
|
233
233
|
if (metadata.issue !== undefined)
|
|
234
234
|
return { issues: [metadata.issue] };
|
|
235
|
-
const commandValidation = parseCommands(commands.bytes, options.reservedCoreNames, options.coreCommandPaths);
|
|
235
|
+
const commandValidation = parseCommands(commands.bytes, options.reservedCoreNames, options.coreCommandPaths, options.extensibleCoreBranches);
|
|
236
236
|
if (commandValidation.commands === undefined)
|
|
237
237
|
return { issues: commandValidation.issues };
|
|
238
238
|
return {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type { BranchDef } from '../command.js';
|
|
2
|
-
import type { CommandRegistrySnapshot } from '../command-manifests/registry.js';
|
|
3
|
-
import type { ValidatedContribution } from './discovery.js';
|
|
1
|
+
import type { BranchDef, LeafDef } from '../command.js';
|
|
2
|
+
import type { CommandRegistrySnapshot, CoreBranchMount } from '../command-manifests/registry.js';
|
|
3
|
+
import type { ValidatedContribution, ValidatedCoreMount } from './discovery.js';
|
|
4
4
|
export declare function adaptPluginContributions(contributions: ValidatedContribution[]): CommandRegistrySnapshot;
|
|
5
|
+
export declare function adaptPluginCoreMounts(mounts: ValidatedCoreMount[]): CoreBranchMount[];
|
|
5
6
|
export declare function composeExternalSubtrees(snapshot: CommandRegistrySnapshot): BranchDef[];
|
|
7
|
+
export declare function composeCoreBranchMounts(mounts: readonly CoreBranchMount[], parent: string[]): (BranchDef | LeafDef)[];
|
|
@@ -1,47 +1,65 @@
|
|
|
1
1
|
import { defineBranch, defineLeaf } from '../command.js';
|
|
2
2
|
import { executeExternalLeaf } from './transport/exec-invoke.js';
|
|
3
3
|
import { invokeHttpLeaf } from './transport/http-invoke.js';
|
|
4
|
+
function adaptLeaf(contribution, commandPathFor) {
|
|
5
|
+
return (leaf, commandPath) => {
|
|
6
|
+
const transport = contribution.transport;
|
|
7
|
+
const manifestCommandPath = commandPathFor(commandPath);
|
|
8
|
+
if (transport.kind === 'exec') {
|
|
9
|
+
const executable = transport.executable;
|
|
10
|
+
if (executable === undefined)
|
|
11
|
+
throw new Error(`exec plugin "${contribution.plugin.name}" has an executable leaf without transport.executable`);
|
|
12
|
+
return {
|
|
13
|
+
outputKind: 'object',
|
|
14
|
+
run: async (input) => executeExternalLeaf({ plugin: contribution.plugin, executable, commandPath: manifestCommandPath, output: leaf.output }, input),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const httpLeaf = leaf;
|
|
18
|
+
return {
|
|
19
|
+
outputKind: httpLeaf.rest.streaming === true ? 'jsonl' : 'object',
|
|
20
|
+
run: (input, context) => invokeHttpLeaf({
|
|
21
|
+
registration: { name: contribution.plugin.name, endpoint: transport.endpoint, ...(transport.authEnv !== undefined ? { authEnv: transport.authEnv } : {}) },
|
|
22
|
+
...(contribution.manifest?.baseUrl !== undefined ? { baseUrl: contribution.manifest.baseUrl } : {}),
|
|
23
|
+
...(contribution.manifest?.timeouts !== undefined ? { timeouts: contribution.manifest.timeouts } : {}),
|
|
24
|
+
params: httpLeaf.params, rest: httpLeaf.rest, output: httpLeaf.output, commandPath: manifestCommandPath,
|
|
25
|
+
}, input, context),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
}
|
|
4
29
|
export function adaptPluginContributions(contributions) {
|
|
5
30
|
return {
|
|
6
31
|
contributions: contributions.map((contribution) => ({
|
|
7
32
|
contributor: { name: contribution.plugin.name, scope: contribution.plugin.scope, sourceMarketplace: contribution.plugin.sourceMarketplace },
|
|
8
33
|
node: contribution.node,
|
|
9
34
|
manifestName: contribution.manifestName,
|
|
10
|
-
adaptLeaf: (
|
|
11
|
-
const transport = contribution.transport;
|
|
12
|
-
const manifestCommandPath = [contribution.manifestName, ...commandPath.slice(1)];
|
|
13
|
-
if (transport.kind === 'exec') {
|
|
14
|
-
const executable = transport.executable;
|
|
15
|
-
if (executable === undefined)
|
|
16
|
-
throw new Error(`exec plugin "${contribution.plugin.name}" has an executable leaf without transport.executable`);
|
|
17
|
-
return {
|
|
18
|
-
outputKind: 'object',
|
|
19
|
-
run: async (input) => executeExternalLeaf({ plugin: contribution.plugin, executable, commandPath: manifestCommandPath, output: leaf.output }, input),
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
const httpLeaf = leaf;
|
|
23
|
-
return {
|
|
24
|
-
outputKind: httpLeaf.rest.streaming === true ? 'jsonl' : 'object',
|
|
25
|
-
run: (input, context) => invokeHttpLeaf({
|
|
26
|
-
registration: { name: contribution.plugin.name, endpoint: transport.endpoint, ...(transport.authEnv !== undefined ? { authEnv: transport.authEnv } : {}) },
|
|
27
|
-
...(contribution.manifest?.baseUrl !== undefined ? { baseUrl: contribution.manifest.baseUrl } : {}),
|
|
28
|
-
...(contribution.manifest?.timeouts !== undefined ? { timeouts: contribution.manifest.timeouts } : {}),
|
|
29
|
-
params: httpLeaf.params, rest: httpLeaf.rest, output: httpLeaf.output, commandPath: manifestCommandPath,
|
|
30
|
-
}, input, context),
|
|
31
|
-
};
|
|
32
|
-
},
|
|
35
|
+
adaptLeaf: adaptLeaf(contribution, (commandPath) => [contribution.manifestName, ...commandPath.slice(1)]),
|
|
33
36
|
})),
|
|
37
|
+
coreMounts: [],
|
|
34
38
|
issues: [],
|
|
35
39
|
};
|
|
36
40
|
}
|
|
41
|
+
export function adaptPluginCoreMounts(mounts) {
|
|
42
|
+
return mounts.map((mount) => ({
|
|
43
|
+
parent: mount.parent,
|
|
44
|
+
plugin: mount.plugin.name,
|
|
45
|
+
contributor: { name: mount.plugin.name, scope: mount.plugin.scope, sourceMarketplace: mount.plugin.sourceMarketplace },
|
|
46
|
+
node: mount.node,
|
|
47
|
+
adaptLeaf: adaptLeaf({ ...mount, manifestName: mount.node.name }, (commandPath) => [...commandPath]),
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
37
50
|
export function composeExternalSubtrees(snapshot) {
|
|
38
51
|
return snapshot.contributions.map((contribution) => buildBranch(contribution, contribution.node, [contribution.node.name]));
|
|
39
52
|
}
|
|
53
|
+
export function composeCoreBranchMounts(mounts, parent) {
|
|
54
|
+
return mounts.filter((mount) => mount.parent.join('\u0000') === parent.join('\u0000')).map((mount) => mount.node.kind === 'branch'
|
|
55
|
+
? buildBranch(mount, mount.node, [...parent, mount.node.name])
|
|
56
|
+
: buildLeaf(mount, mount.node, [...parent, mount.node.name]));
|
|
57
|
+
}
|
|
40
58
|
function buildBranch(contribution, node, path) {
|
|
41
59
|
const rootEntry = node.rootEntry === undefined ? undefined : { concept: node.rootEntry.concept, desc: node.rootEntry.description, useWhen: node.rootEntry.whenToUse };
|
|
42
|
-
return defineBranch({ name: node.name, description: node.description, whenToUse: node.whenToUse, ...(node.tier !== undefined ? { tier: node.tier } : {}), ...(rootEntry !== undefined ? { rootEntry } : {}), help: { name: path.join(' '), summary: node.summary, ...(node.model !== undefined ? { model: node.model } : {}), ...(path.length === 1 && contribution.extensionIssue !== undefined ? { extensionIssue: contribution.extensionIssue } : {}) }, ...(node.passthrough !== undefined ? { passthrough: node.passthrough } : {}), children: node.children.map((child) => child.kind === 'branch' ? buildBranch(contribution, child, [...path, child.name]) : buildLeaf(contribution, child, [...path, child.name])) });
|
|
60
|
+
return defineBranch({ name: node.name, description: node.description, whenToUse: node.whenToUse, ...(node.tier !== undefined ? { tier: node.tier } : {}), ...(contribution.plugin !== undefined ? { plugin: contribution.plugin } : {}), ...(rootEntry !== undefined ? { rootEntry } : {}), help: { name: path.join(' '), summary: node.summary, ...(node.model !== undefined ? { model: node.model } : {}), ...(path.length === 1 && contribution.extensionIssue !== undefined ? { extensionIssue: contribution.extensionIssue } : {}) }, ...(node.passthrough !== undefined ? { passthrough: node.passthrough } : {}), children: node.children.map((child) => child.kind === 'branch' ? buildBranch(contribution, child, [...path, child.name]) : buildLeaf(contribution, child, [...path, child.name])) });
|
|
43
61
|
}
|
|
44
62
|
function buildLeaf(contribution, node, path) {
|
|
45
63
|
const adapted = contribution.adaptLeaf(node, path);
|
|
46
|
-
return defineLeaf({ name: node.name, description: node.description, whenToUse: node.whenToUse, ...(node.tier !== undefined ? { tier: node.tier } : {}), help: { name: path.join(' '), summary: node.summary, params: node.params, output: node.output, outputKind: adapted.outputKind, effects: [...node.effects] }, run: adapted.run });
|
|
64
|
+
return defineLeaf({ name: node.name, description: node.description, whenToUse: node.whenToUse, ...(node.tier !== undefined ? { tier: node.tier } : {}), ...(contribution.plugin !== undefined ? { plugin: contribution.plugin } : {}), help: { name: path.join(' '), summary: node.summary, params: node.params, output: node.output, outputKind: adapted.outputKind, effects: [...node.effects] }, run: adapted.run });
|
|
47
65
|
}
|
|
@@ -16,6 +16,13 @@ export interface ValidatedContribution {
|
|
|
16
16
|
manifest: ValidatedCommandManifest;
|
|
17
17
|
manifestName: string;
|
|
18
18
|
}
|
|
19
|
+
export interface ValidatedCoreMount {
|
|
20
|
+
plugin: InstalledPlugin;
|
|
21
|
+
transport: PluginTransport;
|
|
22
|
+
parent: string[];
|
|
23
|
+
node: DeclNode;
|
|
24
|
+
manifest: ValidatedCommandManifest;
|
|
25
|
+
}
|
|
19
26
|
export interface PluginCommandValidation {
|
|
20
27
|
plugin: InstalledPlugin;
|
|
21
28
|
manifestPath: string;
|
|
@@ -24,14 +31,16 @@ export interface PluginCommandValidation {
|
|
|
24
31
|
* Carries `helpAddenda` for the help-render lookup. */
|
|
25
32
|
manifest?: ValidatedCommandManifest;
|
|
26
33
|
contributions: ValidatedContribution[];
|
|
34
|
+
coreMounts: ValidatedCoreMount[];
|
|
27
35
|
issues: CommandDiscoveryIssue[];
|
|
28
36
|
}
|
|
29
37
|
export declare function effectiveCommandPlugins(startDir?: string, profileId?: string | null): InstalledPlugin[];
|
|
30
|
-
export declare function validatePluginCommands(plugin: InstalledPlugin, reservedNames?: ReadonlySet<string>, coreCommandPaths?: ReadonlySet<string>): PluginCommandValidation;
|
|
38
|
+
export declare function validatePluginCommands(plugin: InstalledPlugin, reservedNames?: ReadonlySet<string>, coreCommandPaths?: ReadonlySet<string>, extensibleCoreBranches?: ReadonlySet<string>): PluginCommandValidation;
|
|
31
39
|
export declare function discoverPluginCommandCandidates(startDir?: string, profileId?: string | null): PluginCommandValidation[];
|
|
32
|
-
export declare function validateEffectiveCommandPlugins(reservedNames: ReadonlySet<string>, startDir?: string, profileId?: string | null, coreCommandPaths?: ReadonlySet<string>): PluginCommandValidation[];
|
|
33
|
-
export declare function discoverCommandContributions(reservedNames: ReadonlySet<string>, startDir?: string, profileId?: string | null): {
|
|
40
|
+
export declare function validateEffectiveCommandPlugins(reservedNames: ReadonlySet<string>, startDir?: string, profileId?: string | null, coreCommandPaths?: ReadonlySet<string>, extensibleCoreBranches?: ReadonlySet<string>): PluginCommandValidation[];
|
|
41
|
+
export declare function discoverCommandContributions(reservedNames: ReadonlySet<string>, startDir?: string, profileId?: string | null, coreCommandPaths?: ReadonlySet<string>, extensibleCoreBranches?: ReadonlySet<string>): {
|
|
34
42
|
contributions: ValidatedContribution[];
|
|
43
|
+
coreMounts: ValidatedCoreMount[];
|
|
35
44
|
issues: CommandDiscoveryIssue[];
|
|
36
45
|
};
|
|
37
|
-
export declare function buildExternalCommandSnapshot(reservedCoreNames: ReadonlySet<string>, startDir?: string, profileId?: string | null): CommandRegistrySnapshot;
|
|
46
|
+
export declare function buildExternalCommandSnapshot(reservedCoreNames: ReadonlySet<string>, startDir?: string, profileId?: string | null, coreCommandPaths?: ReadonlySet<string>, extensibleCoreBranches?: ReadonlySet<string>): CommandRegistrySnapshot;
|
|
@@ -4,7 +4,7 @@ import { listInstalledPlugins, listInstalledPluginsInRoot } from '../resolver.js
|
|
|
4
4
|
import { projectScopeRoots } from '../scope.js';
|
|
5
5
|
import { validateCommandManifest } from '../command-manifests/manifest.js';
|
|
6
6
|
import { qualifiedTopLevelCommandName, resolveCommandRegistry } from '../command-manifests/registry.js';
|
|
7
|
-
import { adaptPluginContributions } from './compose.js';
|
|
7
|
+
import { adaptPluginContributions, adaptPluginCoreMounts } from './compose.js';
|
|
8
8
|
import { validateHttpPluginTransport } from './endpoint.js';
|
|
9
9
|
import { applyCommandExtensions } from './extensions.js';
|
|
10
10
|
export function effectiveCommandPlugins(startDir = process.cwd(), profileId) {
|
|
@@ -64,9 +64,12 @@ function transportFor(plugin, issues) {
|
|
|
64
64
|
function hasExecutableLeaf(node) {
|
|
65
65
|
return node.kind === 'leaf' || node.children.some(hasExecutableLeaf);
|
|
66
66
|
}
|
|
67
|
+
function manifestNodes(manifest) {
|
|
68
|
+
return [...manifest.roots, ...manifest.coreMounts.map((mount) => mount.node)];
|
|
69
|
+
}
|
|
67
70
|
function validateExecExecutable(plugin, transport, manifest, issues) {
|
|
68
71
|
if (transport.executable === undefined) {
|
|
69
|
-
if (!manifest.
|
|
72
|
+
if (!manifestNodes(manifest).some(hasExecutableLeaf))
|
|
70
73
|
return transport;
|
|
71
74
|
issues.push({ code: 'command_manifest_invalid', plugin: plugin.name, message: 'exec transport requires executable when commands.json declares executable leaves', received: '(missing)', expected: '{ kind: "exec", executable: "plugin-relative path" }', next: 'Add plugin.json.transport.executable.', path: 'transport.executable' });
|
|
72
75
|
return undefined;
|
|
@@ -110,28 +113,29 @@ function resolvePassthroughBinaries(plugin, manifest, issues) {
|
|
|
110
113
|
for (const child of node.children)
|
|
111
114
|
visit(child);
|
|
112
115
|
};
|
|
113
|
-
for (const root of manifest
|
|
116
|
+
for (const root of manifestNodes(manifest))
|
|
114
117
|
visit(root);
|
|
115
118
|
return valid;
|
|
116
119
|
}
|
|
117
|
-
export function validatePluginCommands(plugin, reservedNames = new Set(), coreCommandPaths) {
|
|
120
|
+
export function validatePluginCommands(plugin, reservedNames = new Set(), coreCommandPaths, extensibleCoreBranches = new Set()) {
|
|
118
121
|
const issues = [];
|
|
119
122
|
const commands = plugin.manifest.commands;
|
|
120
123
|
const transportRaw = plugin.manifest.transport;
|
|
121
124
|
let manifestPath = resolve(plugin.root, typeof commands === 'string' ? commands : 'commands.json');
|
|
125
|
+
const empty = (transport) => ({ plugin, manifestPath, ...(transport === undefined ? {} : { transport }), contributions: [], coreMounts: [], issues });
|
|
122
126
|
if (commands === undefined && transportRaw === undefined)
|
|
123
|
-
return
|
|
127
|
+
return empty();
|
|
124
128
|
if (typeof commands !== 'string' || commands.length === 0) {
|
|
125
129
|
issues.push({ code: 'command_manifest_invalid', plugin: plugin.name, message: 'transport requires commands in plugin.json', received: '(missing)', expected: 'a plugin-root-relative commands path', next: 'Add plugin.json.commands or remove transport.', path: 'commands' });
|
|
126
|
-
return
|
|
130
|
+
return empty();
|
|
127
131
|
}
|
|
128
132
|
const transport = transportFor(plugin, issues);
|
|
129
133
|
if (transport === undefined)
|
|
130
|
-
return
|
|
134
|
+
return empty();
|
|
131
135
|
const safe = safePath(plugin.root, commands);
|
|
132
136
|
if (safe === null) {
|
|
133
137
|
issues.push({ code: 'command_path_unsafe', plugin: plugin.name, message: 'commands pointer escapes the plugin root or is not a regular file', received: commands, expected: 'a relative path to a regular file inside the plugin root', next: 'Fix plugin.json.commands.', path: 'commands' });
|
|
134
|
-
return
|
|
138
|
+
return empty(transport);
|
|
135
139
|
}
|
|
136
140
|
manifestPath = safe;
|
|
137
141
|
let bytes;
|
|
@@ -143,7 +147,7 @@ export function validatePluginCommands(plugin, reservedNames = new Set(), coreCo
|
|
|
143
147
|
}
|
|
144
148
|
if (bytes === null) {
|
|
145
149
|
issues.push({ code: 'command_manifest_unreadable', plugin: plugin.name, message: 'commands.json is missing or unreadable', received: safe, expected: 'a readable JSON file', next: 'Reinstall or update the plugin.' });
|
|
146
|
-
return
|
|
150
|
+
return empty(transport);
|
|
147
151
|
}
|
|
148
152
|
let raw;
|
|
149
153
|
try {
|
|
@@ -151,32 +155,39 @@ export function validatePluginCommands(plugin, reservedNames = new Set(), coreCo
|
|
|
151
155
|
}
|
|
152
156
|
catch {
|
|
153
157
|
issues.push({ code: 'command_manifest_invalid', plugin: plugin.name, message: 'commands.json is not valid JSON', received: safe, expected: 'a JSON object', next: 'Regenerate commands.json.' });
|
|
154
|
-
return
|
|
158
|
+
return empty(transport);
|
|
155
159
|
}
|
|
156
|
-
const validation = validateCommandManifest(raw, { transport: transport.kind, reservedCoreNames: reservedNames, ...(coreCommandPaths !== undefined ? { coreCommandPaths } : {}) });
|
|
160
|
+
const validation = validateCommandManifest(raw, { transport: transport.kind, reservedCoreNames: reservedNames, extensibleCoreBranches, ...(coreCommandPaths !== undefined ? { coreCommandPaths } : {}) });
|
|
157
161
|
if (validation.manifest === undefined) {
|
|
158
162
|
validation.issues.forEach(issueFor(plugin, issues));
|
|
159
|
-
return
|
|
163
|
+
return empty(transport);
|
|
160
164
|
}
|
|
161
165
|
const resolvedTransport = transport.kind === 'exec'
|
|
162
166
|
? validateExecExecutable(plugin, transport, validation.manifest, issues)
|
|
163
167
|
: transport;
|
|
164
168
|
if (resolvedTransport === undefined)
|
|
165
|
-
return
|
|
166
|
-
if (resolvedTransport.kind === 'exec' && !resolvePassthroughBinaries(plugin, validation.manifest, issues))
|
|
167
|
-
return
|
|
168
|
-
}
|
|
169
|
+
return empty(transport);
|
|
170
|
+
if (resolvedTransport.kind === 'exec' && !resolvePassthroughBinaries(plugin, validation.manifest, issues))
|
|
171
|
+
return empty(resolvedTransport);
|
|
169
172
|
const roots = validation.manifest.roots;
|
|
170
173
|
if (!roots.every((node) => node.kind === 'branch')) {
|
|
171
174
|
throw new Error('plugin manifest validation returned a top-level leaf');
|
|
172
175
|
}
|
|
173
|
-
return {
|
|
176
|
+
return {
|
|
177
|
+
plugin,
|
|
178
|
+
manifestPath,
|
|
179
|
+
transport: resolvedTransport,
|
|
180
|
+
manifest: validation.manifest,
|
|
181
|
+
contributions: roots.map((node) => ({ plugin, transport: resolvedTransport, node, manifest: validation.manifest, manifestName: node.name })),
|
|
182
|
+
coreMounts: validation.manifest.coreMounts.map((mount) => ({ plugin, transport: resolvedTransport, parent: mount.parent, node: mount.node, manifest: validation.manifest })),
|
|
183
|
+
issues,
|
|
184
|
+
};
|
|
174
185
|
}
|
|
175
186
|
export function discoverPluginCommandCandidates(startDir = process.cwd(), profileId) {
|
|
176
187
|
return effectiveCommandPlugins(startDir, profileId).map((plugin) => validatePluginCommands(plugin));
|
|
177
188
|
}
|
|
178
|
-
export function validateEffectiveCommandPlugins(reservedNames, startDir = process.cwd(), profileId, coreCommandPaths) {
|
|
179
|
-
const validations = effectiveCommandPlugins(startDir, profileId).map((plugin) => validatePluginCommands(plugin, reservedNames, coreCommandPaths));
|
|
189
|
+
export function validateEffectiveCommandPlugins(reservedNames, startDir = process.cwd(), profileId, coreCommandPaths, extensibleCoreBranches = new Set()) {
|
|
190
|
+
const validations = effectiveCommandPlugins(startDir, profileId).map((plugin) => validatePluginCommands(plugin, reservedNames, coreCommandPaths, extensibleCoreBranches));
|
|
180
191
|
const claims = new Map();
|
|
181
192
|
for (const validation of validations)
|
|
182
193
|
for (const contribution of validation.contributions) {
|
|
@@ -195,19 +206,40 @@ export function validateEffectiveCommandPlugins(reservedNames, startDir = proces
|
|
|
195
206
|
validation.contributions = validation.contributions.map((contribution) => contribution === claimant ? { ...contribution, node: { ...contribution.node, name: qualifiedName } } : contribution);
|
|
196
207
|
validation.issues.push({ code: 'command_collision', plugin: plugin.name, path: name, message: `top-level command "${name}" is claimed by ${claimants.map((entry) => entry.plugin.name).join(', ')} — mounted as "${qualifiedName}"`, received: claimants.map((entry) => entry.plugin.name).join(', '), expected: 'an origin-qualified top-level command for each claimant', next: `Invoke "${qualifiedName}" to use this plugin command.` });
|
|
197
208
|
}
|
|
209
|
+
const coreClaims = new Map();
|
|
210
|
+
for (const validation of validations)
|
|
211
|
+
for (const mount of validation.coreMounts) {
|
|
212
|
+
const key = [...mount.parent, mount.node.name].join('/');
|
|
213
|
+
const claimants = coreClaims.get(key);
|
|
214
|
+
if (claimants === undefined)
|
|
215
|
+
coreClaims.set(key, [mount]);
|
|
216
|
+
else
|
|
217
|
+
claimants.push(mount);
|
|
218
|
+
}
|
|
219
|
+
for (const [path, claimants] of coreClaims)
|
|
220
|
+
if (claimants.length > 1)
|
|
221
|
+
for (const claimant of claimants) {
|
|
222
|
+
const validation = validations.find((candidate) => candidate.plugin.root === claimant.plugin.root);
|
|
223
|
+
validation.coreMounts = validation.coreMounts.filter((mount) => mount !== claimant);
|
|
224
|
+
validation.issues.push({ code: 'command_collision', plugin: claimant.plugin.name, path: path.replace('/', '.'), message: `contributed command "${claimant.node.name}" conflicts with another plugin child below the extensible core branch`, received: claimants.map((entry) => entry.plugin.name).join(', '), expected: `a unique child name below ${claimant.parent.join('.')}`, next: 'Rename the contributed command or remove this mount.' });
|
|
225
|
+
}
|
|
198
226
|
return validations;
|
|
199
227
|
}
|
|
200
228
|
function contributorIssue(plugin, issue) {
|
|
201
229
|
return { ...issue, contributor: { name: plugin.name, scope: plugin.scope, sourceMarketplace: plugin.sourceMarketplace } };
|
|
202
230
|
}
|
|
203
|
-
export function discoverCommandContributions(reservedNames, startDir = process.cwd(), profileId) {
|
|
204
|
-
const validations = validateEffectiveCommandPlugins(reservedNames, startDir, profileId);
|
|
205
|
-
return { contributions: validations.flatMap((validation) => validation.contributions), issues: validations.flatMap((validation) => validation.issues) };
|
|
231
|
+
export function discoverCommandContributions(reservedNames, startDir = process.cwd(), profileId, coreCommandPaths, extensibleCoreBranches = new Set()) {
|
|
232
|
+
const validations = validateEffectiveCommandPlugins(reservedNames, startDir, profileId, coreCommandPaths, extensibleCoreBranches);
|
|
233
|
+
return { contributions: validations.flatMap((validation) => validation.contributions), coreMounts: validations.flatMap((validation) => validation.coreMounts), issues: validations.flatMap((validation) => validation.issues) };
|
|
206
234
|
}
|
|
207
|
-
export function buildExternalCommandSnapshot(reservedCoreNames, startDir = process.cwd(), profileId) {
|
|
208
|
-
const validations =
|
|
235
|
+
export function buildExternalCommandSnapshot(reservedCoreNames, startDir = process.cwd(), profileId, coreCommandPaths, extensibleCoreBranches = new Set()) {
|
|
236
|
+
const validations = validateEffectiveCommandPlugins(reservedCoreNames, startDir, profileId, coreCommandPaths, extensibleCoreBranches);
|
|
209
237
|
const candidates = adaptPluginContributions(validations.flatMap((validation) => validation.contributions)).contributions;
|
|
210
238
|
const resolved = resolveCommandRegistry(candidates, reservedCoreNames);
|
|
211
239
|
const extensions = applyCommandExtensions(resolved.contributions, startDir);
|
|
212
|
-
return {
|
|
240
|
+
return {
|
|
241
|
+
contributions: extensions.contributions,
|
|
242
|
+
coreMounts: adaptPluginCoreMounts(validations.flatMap((validation) => validation.coreMounts)),
|
|
243
|
+
issues: [...validations.flatMap((validation) => validation.issues.map((issue) => contributorIssue(validation.plugin, issue))), ...resolved.issues],
|
|
244
|
+
};
|
|
213
245
|
}
|
|
@@ -18,4 +18,5 @@ export interface HelpAddendum {
|
|
|
18
18
|
export declare function collectHelpAddenda(commandPath: string, gate: {
|
|
19
19
|
reservedCoreNames: ReadonlySet<string>;
|
|
20
20
|
coreCommandPaths: () => Promise<ReadonlySet<string>>;
|
|
21
|
+
extensibleCoreBranches: () => Promise<ReadonlySet<string>>;
|
|
21
22
|
}, startDir?: string, profileId?: string | null): Promise<HelpAddendum[]>;
|
|
@@ -28,13 +28,14 @@ import { effectiveCommandPlugins, validatePluginCommands } from './discovery.js'
|
|
|
28
28
|
export async function collectHelpAddenda(commandPath, gate, startDir, profileId) {
|
|
29
29
|
if (commandPath.length === 0)
|
|
30
30
|
return [];
|
|
31
|
-
const
|
|
31
|
+
const extensibleCoreBranches = await gate.extensibleCoreBranches();
|
|
32
|
+
const claimants = effectiveCommandPlugins(startDir, profileId).filter((plugin) => validatePluginCommands(plugin, gate.reservedCoreNames, undefined, extensibleCoreBranches).manifest?.helpAddenda?.[commandPath] !== undefined);
|
|
32
33
|
if (claimants.length === 0)
|
|
33
34
|
return [];
|
|
34
35
|
const coreCommandPaths = await gate.coreCommandPaths();
|
|
35
36
|
const out = [];
|
|
36
37
|
for (const plugin of claimants) {
|
|
37
|
-
const text = validatePluginCommands(plugin, gate.reservedCoreNames, coreCommandPaths).manifest?.helpAddenda?.[commandPath];
|
|
38
|
+
const text = validatePluginCommands(plugin, gate.reservedCoreNames, coreCommandPaths, extensibleCoreBranches).manifest?.helpAddenda?.[commandPath];
|
|
38
39
|
if (text !== undefined)
|
|
39
40
|
out.push({ plugin: plugin.name, text });
|
|
40
41
|
}
|
package/dist/core/command.d.ts
CHANGED
|
@@ -52,6 +52,8 @@ export interface LeafDef {
|
|
|
52
52
|
effectiveHelp?: () => Promise<EffectiveLeafHelp>;
|
|
53
53
|
/** Opt this conditionally-void leaf out of core command hooks. */
|
|
54
54
|
hookEligible?: false;
|
|
55
|
+
/** Plugin that contributed this leaf to an extensible core branch. */
|
|
56
|
+
plugin?: string;
|
|
55
57
|
/** Opt into editor slash-command exposure (see SlashSpec). */
|
|
56
58
|
slash?: SlashSpec;
|
|
57
59
|
run: (input: Record<string, unknown>, context?: LeafRunContext) => Promise<Record<string, unknown> | void>;
|
|
@@ -77,6 +79,10 @@ export interface BranchDef {
|
|
|
77
79
|
* subtrees (assembled into root -h by defineRoot); omitted on nested
|
|
78
80
|
* branches, whose parent representation is the branch's own children list. */
|
|
79
81
|
rootEntry?: RootEntry;
|
|
82
|
+
/** Allows command plugins to contribute direct children below this core branch. */
|
|
83
|
+
extensible?: true;
|
|
84
|
+
/** Plugin that contributed this branch to an extensible core branch. */
|
|
85
|
+
plugin?: string;
|
|
80
86
|
/** Opt into editor slash-command exposure (see SlashSpec). */
|
|
81
87
|
slash?: SlashSpec;
|
|
82
88
|
/** Opt this branch out of the tree model entirely: every token after this
|
|
@@ -105,6 +111,7 @@ export declare function defineLeaf(opts: {
|
|
|
105
111
|
whenToUse?: string;
|
|
106
112
|
tier?: SubTier;
|
|
107
113
|
help: LeafHelp;
|
|
114
|
+
plugin?: string;
|
|
108
115
|
slash?: SlashSpec;
|
|
109
116
|
/** Opt this conditionally-void leaf out of core command hooks. */
|
|
110
117
|
hookEligible?: false;
|
|
@@ -118,6 +125,8 @@ export declare function defineBranch(opts: {
|
|
|
118
125
|
tier?: SubTier;
|
|
119
126
|
help: BranchHelp;
|
|
120
127
|
rootEntry?: RootEntry;
|
|
128
|
+
extensible?: true;
|
|
129
|
+
plugin?: string;
|
|
121
130
|
slash?: SlashSpec;
|
|
122
131
|
passthrough?: {
|
|
123
132
|
bin: string;
|
package/dist/core/command.js
CHANGED
|
@@ -34,6 +34,7 @@ export function defineLeaf(opts) {
|
|
|
34
34
|
help: freezeCommandMetadata(opts.help),
|
|
35
35
|
slash: opts.slash === undefined ? undefined : freezeCommandMetadata(opts.slash),
|
|
36
36
|
hookEligible: opts.hookEligible,
|
|
37
|
+
plugin: opts.plugin,
|
|
37
38
|
run: opts.run,
|
|
38
39
|
render: opts.render,
|
|
39
40
|
});
|
|
@@ -65,6 +66,7 @@ export function defineBranch(opts) {
|
|
|
65
66
|
description: c.description ?? '',
|
|
66
67
|
whenToUse: c.whenToUse ?? '',
|
|
67
68
|
tier: c.tier ?? 'normal',
|
|
69
|
+
...(c.plugin !== undefined ? { plugin: c.plugin } : {}),
|
|
68
70
|
...(subCount > 0 ? { subCount } : {}),
|
|
69
71
|
};
|
|
70
72
|
});
|
|
@@ -76,6 +78,8 @@ export function defineBranch(opts) {
|
|
|
76
78
|
tier: opts.tier,
|
|
77
79
|
help: opts.help,
|
|
78
80
|
rootEntry: opts.rootEntry,
|
|
81
|
+
extensible: opts.extensible,
|
|
82
|
+
plugin: opts.plugin,
|
|
79
83
|
slash: opts.slash,
|
|
80
84
|
passthrough: opts.passthrough,
|
|
81
85
|
children: opts.children,
|
|
@@ -206,13 +210,14 @@ async function renderNodeWithAddenda(node, path, focusedFlag) {
|
|
|
206
210
|
const { hasEffectiveCommandPlugins } = await import('./command-plugins/presence.js');
|
|
207
211
|
if (!hasEffectiveCommandPlugins())
|
|
208
212
|
return body;
|
|
209
|
-
const [{ collectHelpAddenda }, { SUBTREE_NAMES, coreCommandPaths }] = await Promise.all([
|
|
213
|
+
const [{ collectHelpAddenda }, { SUBTREE_NAMES, coreCommandPaths, extensibleCoreBranches }] = await Promise.all([
|
|
210
214
|
import('./command-plugins/help-addenda.js'),
|
|
211
215
|
import('../build-root.js'),
|
|
212
216
|
]);
|
|
213
217
|
const addenda = await collectHelpAddenda(path.join(' '), {
|
|
214
218
|
reservedCoreNames: new Set(SUBTREE_NAMES),
|
|
215
219
|
coreCommandPaths,
|
|
220
|
+
extensibleCoreBranches,
|
|
216
221
|
});
|
|
217
222
|
return body + addenda.map((a) => `\n\n<plugin-help plugin="${a.plugin}">\n${a.text}\n</plugin-help>`).join('');
|
|
218
223
|
}
|
package/dist/core/help.d.ts
CHANGED
|
@@ -121,6 +121,8 @@ export type SubTier = 'hidden' | 'normal' | 'common' | 'important';
|
|
|
121
121
|
* representation one level up). */
|
|
122
122
|
export interface ListingChild {
|
|
123
123
|
name: string;
|
|
124
|
+
/** Plugin that contributed this child to an extensible core branch. */
|
|
125
|
+
plugin?: string;
|
|
124
126
|
/** Short description for this child's <subcommand> row. */
|
|
125
127
|
description: string;
|
|
126
128
|
/** Selection rubric — plainly states when to reach for this command. Expansive
|
package/dist/core/help.js
CHANGED
|
@@ -152,6 +152,7 @@ export function renderBranch(h) {
|
|
|
152
152
|
for (const c of h.listing ?? []) {
|
|
153
153
|
if (c.tier === 'hidden')
|
|
154
154
|
continue;
|
|
155
|
+
const plugin = c.plugin === undefined ? '' : ` plugin="${attr(c.plugin)}"`;
|
|
155
156
|
const subs = c.subCount !== undefined && c.subCount > 0 ? ` subcommands="${c.subCount}"` : '';
|
|
156
157
|
// whenToUse plainly states when to reach for this child, rendered verbatim —
|
|
157
158
|
// expansive with examples for judgment-heavy commands, concise for
|
|
@@ -159,7 +160,7 @@ export function renderBranch(h) {
|
|
|
159
160
|
// capability-discovery rule in the root footer already teaches that.
|
|
160
161
|
if (lines.length > 1)
|
|
161
162
|
lines.push('');
|
|
162
|
-
lines.push(`<subcommand name="${c.name}" description="${attr(c.description)}" whenToUse="${attr(c.whenToUse)}"${subs}/>`);
|
|
163
|
+
lines.push(`<subcommand name="${c.name}" description="${attr(c.description)}" whenToUse="${attr(c.whenToUse)}"${plugin}${subs}/>`);
|
|
163
164
|
}
|
|
164
165
|
if (h.extensionIssue !== undefined) {
|
|
165
166
|
lines.push(`<extension-issue fragment="${attr(h.extensionIssue.fragment)}">This repository's contributed commands were rejected: ${h.extensionIssue.message}. Run \`crtr sys doctor\` for the full report.</extension-issue>`);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import type { PageAnswer } from './answer.js';
|
|
2
|
-
/** Render a PageAnswer as the deterministic Markdown report delivered to its asking agent.
|
|
3
|
-
|
|
2
|
+
/** Render a PageAnswer as the deterministic Markdown report delivered to its asking agent.
|
|
3
|
+
* The person sees this same prose in their own chat surface, so it carries the answer
|
|
4
|
+
* itself and nothing addressed only to the agent. */
|
|
5
|
+
export declare function renderAnswerText(answer: PageAnswer): string;
|
|
@@ -80,19 +80,21 @@ function slotLines(slot, totalSlots) {
|
|
|
80
80
|
lines.push(...commentLines(comment));
|
|
81
81
|
return lines;
|
|
82
82
|
}
|
|
83
|
-
/** Render a PageAnswer as the deterministic Markdown report delivered to its asking agent.
|
|
84
|
-
|
|
83
|
+
/** Render a PageAnswer as the deterministic Markdown report delivered to its asking agent.
|
|
84
|
+
* The person sees this same prose in their own chat surface, so it carries the answer
|
|
85
|
+
* itself and nothing addressed only to the agent. */
|
|
86
|
+
export function renderAnswerText(answer) {
|
|
85
87
|
if (answer.kind === 'acknowledgement') {
|
|
86
88
|
return [
|
|
87
89
|
`The user saw **${answer.title}** and dismissed it. The page asked for nothing, so the acknowledgement is the whole answer.`,
|
|
88
90
|
'',
|
|
89
|
-
`Acknowledged ${answer.completedAt}
|
|
91
|
+
`Acknowledged ${answer.completedAt}.`,
|
|
90
92
|
].join('\n');
|
|
91
93
|
}
|
|
92
94
|
const answered = answer.slots.filter((slot) => slot.state === 'answered');
|
|
93
95
|
const lines = [`The user answered **${answer.title}**.`];
|
|
94
96
|
for (const slot of answered)
|
|
95
97
|
lines.push('', ...slotLines(slot, answer.slots.length));
|
|
96
|
-
lines.push('', `Answered ${answer.completedAt}
|
|
98
|
+
lines.push('', `Answered ${answer.completedAt}.`);
|
|
97
99
|
return lines.join('\n');
|
|
98
100
|
}
|
|
@@ -2,6 +2,11 @@ import { getRequest, getRequestByInboxTicket } from '../../core/human/requests.j
|
|
|
2
2
|
import { buildPageSummary } from '../../core/human/summary.js';
|
|
3
3
|
import { validateFinalPageResponses } from '../../core/human/tickets.js';
|
|
4
4
|
import { composeHumanCompletion } from '../../core/human/completion.js';
|
|
5
|
+
import { describePageAnswer } from '../../core/human/answer.js';
|
|
6
|
+
import { renderAnswerText } from '../../core/human/answer-text.js';
|
|
7
|
+
import { readPageFeedback } from '../../core/human/feedback.js';
|
|
8
|
+
import { pageDocumentSha256 } from '../../core/human/feedback-companion.js';
|
|
9
|
+
import { pagePath } from '../../core/human/convention.js';
|
|
5
10
|
import { retireCompanion } from '../companion-retire.js';
|
|
6
11
|
import { settleHumanRequest } from './settle.js';
|
|
7
12
|
function requestForTicket(ticketId) {
|
|
@@ -38,10 +43,73 @@ function completionAction(request, result) {
|
|
|
38
43
|
throw new Error(`corrupt frozen completion action: ${request.request_id}`, { cause: error });
|
|
39
44
|
}
|
|
40
45
|
}
|
|
41
|
-
function
|
|
46
|
+
function firstLine(text, maxLen = 80) {
|
|
47
|
+
const line = text.split('\n')[0] ?? '';
|
|
48
|
+
return line.length > maxLen ? `${line.slice(0, maxLen - 1)}…` : line;
|
|
49
|
+
}
|
|
50
|
+
function pageTitle(request) {
|
|
51
|
+
try {
|
|
52
|
+
return manifest(request).title;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** The settle-time feedback addendum — answer and cancellation alike: a change
|
|
59
|
+
* notice when the final page differs from the version first commented on, and
|
|
60
|
+
* each still-open item's quote and note. Silent when the page is unchanged and
|
|
61
|
+
* nothing remains open; never a manufactured prose diff. */
|
|
62
|
+
function feedbackAddendum(request) {
|
|
63
|
+
let sections;
|
|
64
|
+
try {
|
|
65
|
+
const feedback = readPageFeedback(request.request_dir);
|
|
66
|
+
if (feedback.companionNodeId === undefined)
|
|
67
|
+
return '';
|
|
68
|
+
sections = [];
|
|
69
|
+
if (feedback.firstCommentedSha256 !== undefined && pageDocumentSha256(request.request_dir) !== feedback.firstCommentedSha256) {
|
|
70
|
+
sections.push(`The page changed during feedback: the person commented on it and its companion conversation (node ${feedback.companionNodeId}) revised it in place. ` +
|
|
71
|
+
`Reread the final page at ${pagePath(request.request_dir)} before interpreting this outcome.`);
|
|
72
|
+
}
|
|
73
|
+
const open = feedback.comments.filter((comment) => comment.status === 'open');
|
|
74
|
+
if (open.length > 0) {
|
|
75
|
+
const items = open.map((comment) => [
|
|
76
|
+
...comment.quote.split('\n').map((line) => `> ${line}`),
|
|
77
|
+
comment.note,
|
|
78
|
+
].join('\n'));
|
|
79
|
+
sections.push([
|
|
80
|
+
`Feedback left unresolved at settlement — the companion conversation (node ${feedback.companionNodeId}) had not dealt with these items:`,
|
|
81
|
+
...items,
|
|
82
|
+
].join('\n\n'));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// The outcome itself is the deliverable; a broken feedback record adds nothing.
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
return sections.length === 0 ? '' : `\n\n${sections.join('\n\n')}`;
|
|
90
|
+
}
|
|
91
|
+
/** The person's answer as prose the asking agent reads directly. Short bodies
|
|
92
|
+
* ride inline; the mail layer spills a long one to a file and leaves a preview
|
|
93
|
+
* plus its path. */
|
|
94
|
+
function answerBody(request, result) {
|
|
95
|
+
const answer = describePageAnswer(manifest(request), result.responses, { completedAt: result.completedAt });
|
|
96
|
+
return renderAnswerText(answer) + feedbackAddendum(request);
|
|
97
|
+
}
|
|
98
|
+
/** A dismissal is a rejection, not a routing event: say so plainly so the agent
|
|
99
|
+
* acts on the "no" instead of re-raising the same ask. */
|
|
100
|
+
function cancelBody(request, dismissed, reason) {
|
|
101
|
+
const title = pageTitle(request);
|
|
102
|
+
const named = title === undefined ? `the human interaction ${request.request_id}` : `“${title}”`;
|
|
103
|
+
const why = reason !== undefined && reason !== '' ? reason : 'no reason was given';
|
|
104
|
+
const body = dismissed
|
|
105
|
+
? `The user dismissed ${named} without answering — a deliberate “no, I'm not answering this.” Treat it as a rejection: no response will arrive, no partial answer exists, and the same ask must not be raised again.`
|
|
106
|
+
: `${title === undefined ? `The human interaction ${request.request_id}` : `The page “${title}”`} was canceled — ${why}. No response will arrive, and any partial answers the user had entered were discarded unsent.`;
|
|
107
|
+
return body + feedbackAddendum(request);
|
|
108
|
+
}
|
|
109
|
+
function notification(request, disposition, body, result) {
|
|
42
110
|
if (request.requester_node_id === null)
|
|
43
111
|
return undefined;
|
|
44
|
-
return { label:
|
|
112
|
+
return { label: firstLine(body), disposition, body_json: { body, result } };
|
|
45
113
|
}
|
|
46
114
|
function retire(request) {
|
|
47
115
|
return request.companion_node_id === null ? undefined : { retire: async (nodeId, outcome) => { await retireCompanion(nodeId, outcome); } };
|
|
@@ -60,7 +128,9 @@ export async function resolvePageTicket(ticketId, responses, opts = {}) {
|
|
|
60
128
|
request_id: request.request_id,
|
|
61
129
|
outcome: 'answered',
|
|
62
130
|
result_json: JSON.stringify(result),
|
|
63
|
-
...(notification(request, 'human-answer',
|
|
131
|
+
...(notification(request, 'human-answer', answerBody(request, result), result) === undefined
|
|
132
|
+
? {}
|
|
133
|
+
: { notification: notification(request, 'human-answer', answerBody(request, result), result) }),
|
|
64
134
|
...(completionAction(request, result) === undefined ? {} : { completion_action: completionAction(request, result) }),
|
|
65
135
|
...(retire(request) === undefined ? {} : { post_commit: retire(request) }),
|
|
66
136
|
});
|
|
@@ -84,7 +154,9 @@ export async function cancelHumanTicket(ticketId, opts) {
|
|
|
84
154
|
request_id: request.request_id,
|
|
85
155
|
outcome: opts.disposition === 'dismissed' ? 'dismissed' : 'canceled',
|
|
86
156
|
result_json: JSON.stringify(result),
|
|
87
|
-
...(notification(request, 'human-canceled',
|
|
157
|
+
...(notification(request, 'human-canceled', cancelBody(request, opts.disposition === 'dismissed', opts.reason), result) === undefined
|
|
158
|
+
? {}
|
|
159
|
+
: { notification: notification(request, 'human-canceled', cancelBody(request, opts.disposition === 'dismissed', opts.reason), result) }),
|
|
88
160
|
...(completionAction(request, result) === undefined ? {} : { completion_action: completionAction(request, result) }),
|
|
89
161
|
...(retire(request) === undefined ? {} : { post_commit: retire(request) }),
|
|
90
162
|
});
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.316",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.316",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"workspaces": [
|
|
@@ -5206,17 +5206,17 @@
|
|
|
5206
5206
|
},
|
|
5207
5207
|
"packages/crouter-api": {
|
|
5208
5208
|
"name": "@north-light/crouter-api",
|
|
5209
|
-
"version": "0.3.
|
|
5209
|
+
"version": "0.3.316",
|
|
5210
5210
|
"license": "UNLICENSED"
|
|
5211
5211
|
},
|
|
5212
5212
|
"packages/crouter-env-docker": {
|
|
5213
5213
|
"name": "@north-light/crouter-env-docker",
|
|
5214
|
-
"version": "0.3.
|
|
5214
|
+
"version": "0.3.316",
|
|
5215
5215
|
"license": "UNLICENSED"
|
|
5216
5216
|
},
|
|
5217
5217
|
"packages/crouter-sdk": {
|
|
5218
5218
|
"name": "@north-light/crouter-sdk",
|
|
5219
|
-
"version": "0.3.
|
|
5219
|
+
"version": "0.3.316",
|
|
5220
5220
|
"license": "UNLICENSED",
|
|
5221
5221
|
"dependencies": {
|
|
5222
5222
|
"@north-light/crouter-api": "^0.3.295"
|