@north-light/crouter 0.3.220 → 0.3.221
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/client.d.ts +9 -0
- package/dist/api/client.js +10 -0
- package/dist/api/dto/chat-inventory.d.ts +43 -0
- package/dist/api/dto/chat-inventory.js +11 -0
- package/dist/api/dto/profiles.d.ts +19 -5
- package/dist/api/dto/profiles.js +2 -1
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/build-root.d.ts +2 -6
- package/dist/build-root.js +51 -4
- package/dist/builtin-memory/internal/agent-shaping.md +3 -1
- package/dist/builtin-memory/internal/memory-loading.md +4 -0
- package/dist/builtin-memory/plan/roadmap.md +7 -1
- package/dist/builtin-memory/spec/guide.md +7 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/claude-plugin-commands.ts +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crtr-commands/index.ts +7 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +7 -2
- package/dist/clients/attach/__tests__/ref-autocomplete.test.js +1 -1
- package/dist/clients/attach/__tests__/titled-editor-preview.test.js +1 -1
- package/dist/clients/attach/overlays/file-review.js +2 -2
- package/dist/clients/attach/session/keys.d.ts +1 -1
- package/dist/clients/attach/session/profile-files.js +1 -1
- package/dist/clients/attach/viewer.js +690 -690
- package/dist/clients/inbox/review/launch.d.ts +8 -4
- package/dist/clients/inbox/review/launch.js +55 -5
- package/dist/clients/inbox/review/review-client.d.ts +1 -0
- package/dist/clients/inbox/review/review-client.js +4 -0
- package/dist/clients/inbox/review-adapter.d.ts +1 -8
- package/dist/clients/inbox/review-adapter.js +4 -52
- package/dist/commands/memory/lint.js +2 -1
- package/dist/commands/memory/read.js +1 -0
- package/dist/commands/memory.js +1 -1
- package/dist/commands/pkg/market-manage.js +165 -75
- package/dist/commands/pkg/plugin-inspect.js +19 -2
- package/dist/commands/pkg/plugin-manage.d.ts +8 -3
- package/dist/commands/pkg/plugin-manage.js +72 -24
- package/dist/commands/profile/default.js +6 -10
- package/dist/commands/profile/list.js +5 -3
- package/dist/commands/profile/new.js +21 -8
- package/dist/commands/profile/project.js +25 -19
- package/dist/commands/profile/show.js +3 -3
- package/dist/commands/surface-inbox.js +1 -0
- package/dist/commands/sys/__tests__/migrate.test.js +16 -5
- package/dist/commands/sys/doctor.js +35 -5
- package/dist/commands/sys/migrate.js +38 -19
- package/dist/commands/sys/setup-core.js +1 -1
- package/dist/commands/sys/sync-project-guidance.js +1 -1
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +1 -1
- package/dist/core/__tests__/fixtures/c5-command-boundary-ext.js +24 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +24 -18
- package/dist/core/__tests__/fixtures/fake-engine.js +8 -1
- package/dist/core/__tests__/inline-memory-refs.test.js +36 -2
- package/dist/core/__tests__/profile-project-memory-delivery.test.js +217 -0
- package/dist/core/__tests__/serial/broker-sdk-wiring.test.js +102 -2
- package/dist/core/bootstrap.js +6 -0
- package/dist/core/canvas/browse/app.js +5 -2
- package/dist/core/canvas/browse/model.d.ts +25 -15
- package/dist/core/canvas/browse/model.js +86 -65
- package/dist/core/canvas/render-source.d.ts +6 -0
- package/dist/core/canvas/render-source.js +7 -1
- package/dist/core/canvas/render.js +10 -2
- package/dist/core/command-hooks/artifact.d.ts +10 -0
- package/dist/core/command-hooks/artifact.js +129 -0
- package/dist/core/command-hooks/catalog.d.ts +14 -0
- package/dist/core/command-hooks/catalog.js +38 -0
- package/dist/core/command-hooks/compose.d.ts +15 -0
- package/dist/core/command-hooks/compose.js +99 -0
- package/dist/core/command-hooks/discovery.d.ts +87 -0
- package/dist/core/command-hooks/discovery.js +174 -0
- package/dist/core/command-hooks/help.d.ts +5 -0
- package/dist/core/command-hooks/help.js +18 -0
- package/dist/core/command-hooks/index.d.ts +6 -0
- package/dist/core/command-hooks/index.js +6 -0
- package/dist/core/command-hooks/report.d.ts +23 -0
- package/dist/core/command-hooks/report.js +19 -0
- package/dist/core/command-hooks/schema.d.ts +27 -0
- package/dist/core/command-hooks/schema.js +68 -0
- package/dist/core/command-hooks/transport/exec-invoke.d.ts +22 -0
- package/dist/core/command-hooks/transport/exec-invoke.js +274 -0
- package/dist/core/command-plugins/presence.d.ts +2 -0
- package/dist/core/command-plugins/presence.js +17 -0
- package/dist/core/command-plugins/transport/exec-invoke.d.ts +5 -0
- package/dist/core/command-plugins/transport/exec-invoke.js +58 -5
- package/dist/core/command.d.ts +8 -1
- package/dist/core/command.js +12 -10
- package/dist/core/help.d.ts +7 -1
- package/dist/core/io.d.ts +9 -1
- package/dist/core/io.js +44 -2
- package/dist/core/memory/inline-ref-inventory.d.ts +2 -1
- package/dist/core/memory/inline-ref-inventory.js +15 -8
- package/dist/core/memory-resolver.d.ts +13 -1
- package/dist/core/memory-resolver.js +25 -19
- package/dist/core/profiles/manifest.d.ts +13 -2
- package/dist/core/profiles/manifest.js +84 -18
- package/dist/core/profiles/select.js +9 -9
- package/dist/core/render.js +11 -0
- package/dist/core/runtime/advertised-command-invocation.d.ts +20 -0
- package/dist/core/runtime/advertised-command-invocation.js +233 -0
- package/dist/core/runtime/bearings.js +1 -1
- package/dist/core/runtime/broker/event-projection.js +7 -0
- package/dist/core/runtime/broker/frame-dispatch.d.ts +1 -1
- package/dist/core/runtime/broker/frame-dispatch.js +15 -10
- package/dist/core/runtime/broker/read-ops.d.ts +4 -0
- package/dist/core/runtime/broker/read-ops.js +6 -2
- package/dist/core/runtime/broker-extension-render.js +1 -1
- package/dist/core/runtime/broker-inventory.d.ts +5 -0
- package/dist/core/runtime/broker-inventory.js +191 -0
- package/dist/core/runtime/broker-protocol.d.ts +9 -2
- package/dist/core/runtime/broker.js +10 -1
- package/dist/core/runtime/command-surface.d.ts +33 -0
- package/dist/core/runtime/command-surface.js +81 -0
- package/dist/core/runtime/node-read.js +5 -0
- package/dist/core/scope.d.ts +26 -1
- package/dist/core/scope.js +52 -12
- package/dist/core/substrate/on-read.d.ts +7 -1
- package/dist/core/substrate/on-read.js +13 -4
- package/dist/core/substrate/render.js +14 -5
- package/dist/core/substrate/schema.d.ts +11 -1
- package/dist/core/substrate/schema.js +11 -2
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +4 -4
- package/dist/daemon/api/handlers/chat-inventory.d.ts +2 -0
- package/dist/daemon/api/handlers/chat-inventory.js +25 -0
- package/dist/daemon/api/handlers/profiles.js +7 -1
- package/dist/daemon/api/map.d.ts +2 -1
- package/dist/daemon/api/map.js +3 -2
- package/dist/daemon/api/server.js +2 -0
- package/dist/hook-authoring.d.ts +75 -0
- package/dist/hook-authoring.js +358 -0
- package/dist/hook-process.d.ts +7 -0
- package/dist/hook-process.js +34 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/migrations/002-profile-project-memory.d.ts +2 -0
- package/dist/migrations/002-profile-project-memory.js +71 -0
- package/dist/migrations/profile-manifests.d.ts +30 -0
- package/dist/migrations/profile-manifests.js +70 -0
- package/dist/migrations/registry.js +10 -5
- package/dist/migrations/types.d.ts +28 -1
- package/dist/migrations/types.js +15 -9
- package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +21 -4
- package/dist/pi-extensions/canvas-structured-output.js +85 -2
- package/dist/types.d.ts +15 -6
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/clients/attach/__tests__/file-review-focus.test.js +0 -49
- /package/dist/{clients/attach/__tests__/file-review-focus.test.d.ts → core/__tests__/profile-project-memory-delivery.test.d.ts} +0 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
function attributedEffects(hook) {
|
|
2
|
+
const attribution = `plugin=${hook.plugin.name} phase=${hook.phase} op=${hook.op}`;
|
|
3
|
+
return hook.effects.map((effect) => `[${attribution}] ${effect}`);
|
|
4
|
+
}
|
|
5
|
+
/** Build a frozen help view whose Effects order matches the effective pipeline.
|
|
6
|
+
* Core help is never modified; all non-effects fields retain their references. */
|
|
7
|
+
export function projectEffectiveLeafHelp(core, plan) {
|
|
8
|
+
const before = plan?.before.flatMap(attributedEffects) ?? [];
|
|
9
|
+
const replacement = plan?.replacements.length === 1 ? plan.replacements[0] : undefined;
|
|
10
|
+
const primary = replacement === undefined
|
|
11
|
+
? core.effects
|
|
12
|
+
: attributedEffects(replacement);
|
|
13
|
+
const after = plan?.after.flatMap(attributedEffects) ?? [];
|
|
14
|
+
return Object.freeze({
|
|
15
|
+
...core,
|
|
16
|
+
effects: Object.freeze([...before, ...primary, ...after]),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createCoreHookCatalog, type CoreHookCatalog, type CoreHookTarget } from './catalog.js';
|
|
2
|
+
export { projectEffectiveLeafHelp } from './help.js';
|
|
3
|
+
export { compileHookRegistry, discoverCandidateHookRegistry, discoverHookRegistry, discoverHookRegistryInScopes, effectiveHookPlugins, hookDiscoveryScopes, type EffectiveHook, type EffectiveHookPlan, type HookDiscoveryIssue, type HookPluginAttribution, type HookPluginValidation, type HookRegistry, type HookScope, } from './discovery.js';
|
|
4
|
+
export { validatePluginHookArtifact, type HookArtifactValidation } from './artifact.js';
|
|
5
|
+
export { hasDeclaredHooks, hookReport, HOOK_TRUST_WARNING, type HookDeclarationReport, type HookReport } from './report.js';
|
|
6
|
+
export { validateHookManifest, type DeclaredHook, type HookManifestIssue, type HookManifestValidation, type HookPhase, type ValidatedHookManifest, } from './schema.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createCoreHookCatalog } from './catalog.js';
|
|
2
|
+
export { projectEffectiveLeafHelp } from './help.js';
|
|
3
|
+
export { compileHookRegistry, discoverCandidateHookRegistry, discoverHookRegistry, discoverHookRegistryInScopes, effectiveHookPlugins, hookDiscoveryScopes, } from './discovery.js';
|
|
4
|
+
export { validatePluginHookArtifact } from './artifact.js';
|
|
5
|
+
export { hasDeclaredHooks, hookReport, HOOK_TRUST_WARNING } from './report.js';
|
|
6
|
+
export { validateHookManifest, } from './schema.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PluginManifest } from '../../types.js';
|
|
2
|
+
import type { HookDiscoveryIssue, HookPluginValidation } from './discovery.js';
|
|
3
|
+
import type { DeclaredHook } from './schema.js';
|
|
4
|
+
export declare const HOOK_TRUST_WARNING = "Hooks receive normalized inputs; after hooks receive results. They run implicitly and may block or replace declared targets. The local executable runs with caller authority and can transmit received data.";
|
|
5
|
+
export interface HookDeclarationReport {
|
|
6
|
+
target: string;
|
|
7
|
+
phase: DeclaredHook['phase'];
|
|
8
|
+
op: string;
|
|
9
|
+
description: string;
|
|
10
|
+
effects: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
/** One static hook artifact report shared by plugin inspection and lifecycle
|
|
13
|
+
* output. It only reads declarations and filesystem metadata; it never runs
|
|
14
|
+
* the hook executable. */
|
|
15
|
+
export interface HookReport {
|
|
16
|
+
manifestPath?: string;
|
|
17
|
+
executablePath?: string;
|
|
18
|
+
declarations: readonly HookDeclarationReport[];
|
|
19
|
+
issues: readonly HookDiscoveryIssue[];
|
|
20
|
+
trust: typeof HOOK_TRUST_WARNING;
|
|
21
|
+
}
|
|
22
|
+
export declare function hasDeclaredHooks(manifest: Pick<PluginManifest, 'hooks' | 'hookExecutable'>): boolean;
|
|
23
|
+
export declare function hookReport(validation: HookPluginValidation, issues?: readonly HookDiscoveryIssue[]): HookReport;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const HOOK_TRUST_WARNING = 'Hooks receive normalized inputs; after hooks receive results. They run implicitly and may block or replace declared targets. The local executable runs with caller authority and can transmit received data.';
|
|
2
|
+
export function hasDeclaredHooks(manifest) {
|
|
3
|
+
return manifest.hooks !== undefined || manifest.hookExecutable !== undefined;
|
|
4
|
+
}
|
|
5
|
+
export function hookReport(validation, issues = validation.issues) {
|
|
6
|
+
return Object.freeze({
|
|
7
|
+
...(validation.manifestPath === undefined ? {} : { manifestPath: validation.manifestPath }),
|
|
8
|
+
...(validation.executablePath === undefined ? {} : { executablePath: validation.executablePath }),
|
|
9
|
+
declarations: Object.freeze((validation.manifest?.hooks ?? []).map((hook) => Object.freeze({
|
|
10
|
+
target: hook.target,
|
|
11
|
+
phase: hook.phase,
|
|
12
|
+
op: hook.op,
|
|
13
|
+
description: hook.description,
|
|
14
|
+
effects: Object.freeze([...hook.effects]),
|
|
15
|
+
}))),
|
|
16
|
+
issues: Object.freeze([...issues]),
|
|
17
|
+
trust: HOOK_TRUST_WARNING,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type HookPhase = 'before' | 'after' | 'replace';
|
|
2
|
+
export interface DeclaredHook {
|
|
3
|
+
target: string;
|
|
4
|
+
phase: HookPhase;
|
|
5
|
+
op: string;
|
|
6
|
+
description: string;
|
|
7
|
+
effects: string[];
|
|
8
|
+
}
|
|
9
|
+
export interface ValidatedHookManifest {
|
|
10
|
+
schemaVersion: 1;
|
|
11
|
+
hooks: DeclaredHook[];
|
|
12
|
+
}
|
|
13
|
+
export type HookIssueCode = 'hook_manifest_unreadable' | 'hook_manifest_invalid' | 'hook_schema_version' | 'hook_path_unsafe' | 'hook_not_executable';
|
|
14
|
+
export interface HookManifestIssue {
|
|
15
|
+
code: HookIssueCode;
|
|
16
|
+
path?: string;
|
|
17
|
+
message: string;
|
|
18
|
+
received: string;
|
|
19
|
+
expected: string;
|
|
20
|
+
next: string;
|
|
21
|
+
}
|
|
22
|
+
export interface HookManifestValidation {
|
|
23
|
+
manifest?: ValidatedHookManifest;
|
|
24
|
+
issues: HookManifestIssue[];
|
|
25
|
+
}
|
|
26
|
+
/** Validates the declarative hooks.json payload without filesystem access. */
|
|
27
|
+
export declare function validateHookManifest(raw: unknown): HookManifestValidation;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isRecord } from '../../shared/predicates.js';
|
|
2
|
+
function typeName(value) {
|
|
3
|
+
if (value === null)
|
|
4
|
+
return 'null';
|
|
5
|
+
if (Array.isArray(value))
|
|
6
|
+
return 'array';
|
|
7
|
+
return typeof value;
|
|
8
|
+
}
|
|
9
|
+
/** Validates the declarative hooks.json payload without filesystem access. */
|
|
10
|
+
export function validateHookManifest(raw) {
|
|
11
|
+
const issues = [];
|
|
12
|
+
const issue = (code, message, received, expected, next, path) => {
|
|
13
|
+
issues.push({ code, message, received, expected, next, ...(path === undefined ? {} : { path }) });
|
|
14
|
+
};
|
|
15
|
+
if (!isRecord(raw)) {
|
|
16
|
+
issue('hook_manifest_invalid', 'manifest must be an object', typeName(raw), '{ schemaVersion, hooks }', 'Provide a valid hooks.json manifest.');
|
|
17
|
+
return { issues };
|
|
18
|
+
}
|
|
19
|
+
const unknown = Object.keys(raw).filter((key) => key !== 'schemaVersion' && key !== 'hooks');
|
|
20
|
+
if (unknown.length > 0) {
|
|
21
|
+
issue('hook_manifest_invalid', 'unknown top-level keys', unknown.join(', '), 'only: schemaVersion, hooks', 'Remove the unknown keys.');
|
|
22
|
+
return { issues };
|
|
23
|
+
}
|
|
24
|
+
if (raw['schemaVersion'] !== 1) {
|
|
25
|
+
issue('hook_schema_version', 'schemaVersion must be exactly 1', String(raw['schemaVersion']), '1', 'Update the manifest schema version to 1.', 'schemaVersion');
|
|
26
|
+
return { issues };
|
|
27
|
+
}
|
|
28
|
+
if (!Array.isArray(raw['hooks']) || raw['hooks'].length === 0) {
|
|
29
|
+
issue('hook_manifest_invalid', 'hooks must be a non-empty array', typeName(raw['hooks']), 'a non-empty array of hook declarations', 'Declare at least one hook.', 'hooks');
|
|
30
|
+
return { issues };
|
|
31
|
+
}
|
|
32
|
+
const hooks = [];
|
|
33
|
+
for (let index = 0; index < raw['hooks'].length; index++) {
|
|
34
|
+
const value = raw['hooks'][index];
|
|
35
|
+
const path = `hooks[${index}]`;
|
|
36
|
+
if (!isRecord(value)) {
|
|
37
|
+
issue('hook_manifest_invalid', 'hook must be an object', typeName(value), '{ target, phase, op, description, effects }', 'Fix the hook declaration.', path);
|
|
38
|
+
return { issues };
|
|
39
|
+
}
|
|
40
|
+
const keys = Object.keys(value).filter((key) => !['target', 'phase', 'op', 'description', 'effects'].includes(key));
|
|
41
|
+
if (keys.length > 0) {
|
|
42
|
+
issue('hook_manifest_invalid', 'unknown hook keys', keys.join(', '), 'only: target, phase, op, description, effects', 'Remove the unknown keys.', path);
|
|
43
|
+
return { issues };
|
|
44
|
+
}
|
|
45
|
+
for (const field of ['target', 'op', 'description']) {
|
|
46
|
+
if (typeof value[field] !== 'string' || value[field].length === 0) {
|
|
47
|
+
issue('hook_manifest_invalid', `${field} must be a non-empty string`, typeName(value[field]), 'a non-empty string', `Set ${field}.`, `${path}.${field}`);
|
|
48
|
+
return { issues };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (value['phase'] !== 'before' && value['phase'] !== 'after' && value['phase'] !== 'replace') {
|
|
52
|
+
issue('hook_manifest_invalid', 'phase must be before|after|replace', String(value['phase']), 'before | after | replace', 'Set phase to a supported hook phase.', `${path}.phase`);
|
|
53
|
+
return { issues };
|
|
54
|
+
}
|
|
55
|
+
if (!Array.isArray(value['effects']) || value['effects'].length === 0 || !value['effects'].every((effect) => typeof effect === 'string' && effect.length > 0)) {
|
|
56
|
+
issue('hook_manifest_invalid', 'effects must be a non-empty string array', typeName(value['effects']), 'a non-empty array of non-empty strings', 'Declare every persistent effect.', `${path}.effects`);
|
|
57
|
+
return { issues };
|
|
58
|
+
}
|
|
59
|
+
hooks.push({
|
|
60
|
+
target: value['target'],
|
|
61
|
+
phase: value['phase'],
|
|
62
|
+
op: value['op'],
|
|
63
|
+
description: value['description'],
|
|
64
|
+
effects: value['effects'],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return { manifest: { schemaVersion: 1, hooks }, issues };
|
|
68
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HookPhase } from '../../../hook-authoring.js';
|
|
2
|
+
import type { Field } from '../../help.js';
|
|
3
|
+
export declare const EXEC_HOOK_TIMEOUT_MS = 15000;
|
|
4
|
+
export interface ExecHookSpec {
|
|
5
|
+
plugin: string;
|
|
6
|
+
/** Absolute, validated hookExecutable path. */
|
|
7
|
+
executable: string;
|
|
8
|
+
op: string;
|
|
9
|
+
phase: HookPhase;
|
|
10
|
+
commandPath: readonly string[];
|
|
11
|
+
/** Required for replacement hooks; ignored for before and after hooks. */
|
|
12
|
+
output?: Field[];
|
|
13
|
+
}
|
|
14
|
+
export interface ExecHookInvocation {
|
|
15
|
+
operationId: string;
|
|
16
|
+
input: Record<string, unknown>;
|
|
17
|
+
providedParams: readonly string[];
|
|
18
|
+
/** The completed primary result, present only for an after hook. */
|
|
19
|
+
result?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
/** Invoke one hook executable; after failures retain the completed primary result. */
|
|
22
|
+
export declare function invokeExecHook(spec: ExecHookSpec, invocation: ExecHookInvocation): Promise<Record<string, unknown> | void>;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { posixProcessGroupSpawnOptions, terminateProcessGroup } from '../../../hook-process.js';
|
|
3
|
+
import { ExitCode } from '../../../types.js';
|
|
4
|
+
import { isRecord } from '../../../shared/predicates.js';
|
|
5
|
+
import { validateCoreDeclaredResult } from '../../command-plugins/transport/exec-invoke.js';
|
|
6
|
+
import { CrtrError } from '../../errors.js';
|
|
7
|
+
import { diag } from '../../io.js';
|
|
8
|
+
const MAX_STDOUT = 10 * 1024 * 1024;
|
|
9
|
+
export const EXEC_HOOK_TIMEOUT_MS = 15_000;
|
|
10
|
+
const RESERVED_CODES = new Set([
|
|
11
|
+
'internal',
|
|
12
|
+
'unknown_path',
|
|
13
|
+
'command_collision',
|
|
14
|
+
'command_hook_collision',
|
|
15
|
+
'plugin_protocol_error',
|
|
16
|
+
'hook_protocol_error',
|
|
17
|
+
]);
|
|
18
|
+
const SNAKE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
|
|
19
|
+
const OPERATION_ID = /^[0-9a-f]{32}$/;
|
|
20
|
+
const NEXT = 'Run `crtr pkg plugin show` or `crtr sys doctor` to inspect the plugin.';
|
|
21
|
+
/** Invoke one hook executable; after failures retain the completed primary result. */
|
|
22
|
+
export async function invokeExecHook(spec, invocation) {
|
|
23
|
+
const cwd = process.cwd();
|
|
24
|
+
const request = buildRequest(spec, invocation, cwd);
|
|
25
|
+
const res = await runHookProcess(spec, invocation, JSON.stringify(request), cwd);
|
|
26
|
+
if (res.signal !== null) {
|
|
27
|
+
throw protocolError(spec, invocation, `executable was killed by signal ${res.signal}`, 'a clean exit with one JSON envelope on stdout');
|
|
28
|
+
}
|
|
29
|
+
const envelope = parseEnvelope(spec, invocation, res.stdout);
|
|
30
|
+
if (envelope.ok && res.status !== 0) {
|
|
31
|
+
diag(`crtr: ${label(spec)} returned ok:true but exited ${res.status} (honoring the envelope)`);
|
|
32
|
+
}
|
|
33
|
+
else if (!envelope.ok && res.status === 0) {
|
|
34
|
+
diag(`crtr: ${label(spec)} returned ok:false but exited 0 (honoring the envelope)`);
|
|
35
|
+
}
|
|
36
|
+
if (!envelope.ok)
|
|
37
|
+
throwHookFailure(spec, invocation, envelope);
|
|
38
|
+
if (spec.phase !== 'replace')
|
|
39
|
+
return;
|
|
40
|
+
const result = envelope.result;
|
|
41
|
+
if (!isRecord(result)) {
|
|
42
|
+
throw protocolError(spec, invocation, 'replacement success result was not an object', 'result: an object matching the target output declaration');
|
|
43
|
+
}
|
|
44
|
+
const output = spec.output;
|
|
45
|
+
if (output === undefined) {
|
|
46
|
+
throw protocolError(spec, invocation, 'replacement target output declaration was missing', 'the target leaf output declaration');
|
|
47
|
+
}
|
|
48
|
+
const issue = validateCoreDeclaredResult(output, result);
|
|
49
|
+
if (issue !== null) {
|
|
50
|
+
const field = output.find((candidate) => candidate.name === issue.field);
|
|
51
|
+
if (issue.receivedType !== undefined) {
|
|
52
|
+
throw protocolError(spec, invocation, `replacement output field "${issue.field}" has the wrong type`, `${issue.field}: ${field?.type ?? 'unknown'}`);
|
|
53
|
+
}
|
|
54
|
+
throw protocolError(spec, invocation, `replacement result is missing required output field "${issue.field}"`, `a "${issue.field}" field of type ${field?.type ?? 'unknown'}`);
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
async function runHookProcess(spec, invocation, input, cwd) {
|
|
59
|
+
let child;
|
|
60
|
+
try {
|
|
61
|
+
child = spawn(spec.executable, ['--crtr-hook-protocol', '1'], {
|
|
62
|
+
cwd,
|
|
63
|
+
env: process.env,
|
|
64
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
65
|
+
...posixProcessGroupSpawnOptions(),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
throw protocolError(spec, invocation, `executable could not be run (${describeError(error)})`, 'a runnable hook executable');
|
|
70
|
+
}
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
let stdoutBytes = 0;
|
|
73
|
+
const stdout = [];
|
|
74
|
+
let failure;
|
|
75
|
+
let closed = false;
|
|
76
|
+
let cleaned = false;
|
|
77
|
+
let status = null;
|
|
78
|
+
let signal = null;
|
|
79
|
+
let timeout;
|
|
80
|
+
const finish = () => {
|
|
81
|
+
if (!closed || (failure !== undefined && !cleaned))
|
|
82
|
+
return;
|
|
83
|
+
if (timeout !== undefined)
|
|
84
|
+
clearTimeout(timeout);
|
|
85
|
+
if (failure?.kind === 'timeout') {
|
|
86
|
+
reject(protocolError(spec, invocation, `executable timed out after ${EXEC_HOOK_TIMEOUT_MS}ms`, 'one JSON envelope before the execution timeout'));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (failure?.kind === 'output_limit') {
|
|
90
|
+
reject(protocolError(spec, invocation, 'stdout exceeded the 10 MiB cap', 'one JSON envelope under 10 MiB on stdout'));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (failure?.kind === 'spawn') {
|
|
94
|
+
reject(protocolError(spec, invocation, `executable could not be run (${describeError(failure.error)})`, 'a runnable hook executable'));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (failure?.kind === 'cleanup') {
|
|
98
|
+
reject(protocolError(spec, invocation, `executable cleanup failed (${describeError(failure.error)})`, 'a hook executable whose process group can be terminated'));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
resolve({ stdout: Buffer.concat(stdout).toString('utf8'), status, signal });
|
|
102
|
+
};
|
|
103
|
+
const terminate = (reason) => {
|
|
104
|
+
if (failure !== undefined)
|
|
105
|
+
return;
|
|
106
|
+
failure = reason;
|
|
107
|
+
if (timeout !== undefined)
|
|
108
|
+
clearTimeout(timeout);
|
|
109
|
+
void terminateProcessGroup(child).then(() => {
|
|
110
|
+
cleaned = true;
|
|
111
|
+
finish();
|
|
112
|
+
}, (error) => {
|
|
113
|
+
failure = { kind: 'cleanup', error };
|
|
114
|
+
cleaned = true;
|
|
115
|
+
finish();
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
child.stdout.on('data', (chunk) => {
|
|
119
|
+
stdoutBytes += chunk.byteLength;
|
|
120
|
+
if (stdoutBytes > MAX_STDOUT) {
|
|
121
|
+
terminate({ kind: 'output_limit' });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
stdout.push(chunk);
|
|
125
|
+
});
|
|
126
|
+
child.once('error', (error) => {
|
|
127
|
+
if (child.pid === undefined) {
|
|
128
|
+
failure = { kind: 'spawn', error };
|
|
129
|
+
closed = true;
|
|
130
|
+
cleaned = true;
|
|
131
|
+
finish();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
terminate({ kind: 'spawn', error });
|
|
135
|
+
});
|
|
136
|
+
child.once('close', (nextStatus, nextSignal) => {
|
|
137
|
+
status = nextStatus;
|
|
138
|
+
signal = nextSignal;
|
|
139
|
+
closed = true;
|
|
140
|
+
finish();
|
|
141
|
+
});
|
|
142
|
+
timeout = setTimeout(() => terminate({ kind: 'timeout' }), EXEC_HOOK_TIMEOUT_MS);
|
|
143
|
+
child.stdin.end(input);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function buildRequest(spec, invocation, cwd) {
|
|
147
|
+
if (!OPERATION_ID.test(invocation.operationId)) {
|
|
148
|
+
throw protocolError(spec, invocation, 'operationId was not a 32-character lowercase hexadecimal string', 'a valid crouter operation identity');
|
|
149
|
+
}
|
|
150
|
+
if (!isJson(invocation.input)) {
|
|
151
|
+
throw protocolError(spec, invocation, 'input was not JSON-compatible', 'a JSON object');
|
|
152
|
+
}
|
|
153
|
+
if (!invocation.providedParams.every((param) => typeof param === 'string')) {
|
|
154
|
+
throw protocolError(spec, invocation, 'providedParams contained a non-string value', 'an array of parameter names');
|
|
155
|
+
}
|
|
156
|
+
const base = {
|
|
157
|
+
protocolVersion: 1,
|
|
158
|
+
op: spec.op,
|
|
159
|
+
command: [...spec.commandPath],
|
|
160
|
+
operationId: invocation.operationId,
|
|
161
|
+
input: invocation.input,
|
|
162
|
+
context: { cwd, providedParams: [...invocation.providedParams] },
|
|
163
|
+
};
|
|
164
|
+
if (spec.phase === 'after') {
|
|
165
|
+
if (invocation.result === undefined || !isJson(invocation.result)) {
|
|
166
|
+
throw protocolError(spec, invocation, 'after hook result was missing or not JSON-compatible', 'the completed primary result');
|
|
167
|
+
}
|
|
168
|
+
return { ...base, phase: 'after', result: invocation.result };
|
|
169
|
+
}
|
|
170
|
+
if (invocation.result !== undefined) {
|
|
171
|
+
throw protocolError(spec, invocation, `${spec.phase} hook invocation included a result`, 'result only for after hooks');
|
|
172
|
+
}
|
|
173
|
+
return { ...base, phase: spec.phase };
|
|
174
|
+
}
|
|
175
|
+
function parseEnvelope(spec, invocation, stdout) {
|
|
176
|
+
let value;
|
|
177
|
+
try {
|
|
178
|
+
value = JSON.parse(stdout.trim());
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
throw protocolError(spec, invocation, 'stdout was not a single JSON envelope', 'exactly one JSON envelope on stdout');
|
|
182
|
+
}
|
|
183
|
+
if (!isRecord(value)) {
|
|
184
|
+
throw protocolError(spec, invocation, 'stdout was not a JSON object', 'a { protocolVersion: 1, ok: boolean, ... } envelope');
|
|
185
|
+
}
|
|
186
|
+
if (value['protocolVersion'] !== 1) {
|
|
187
|
+
throw protocolError(spec, invocation, `unsupported protocolVersion ${String(value['protocolVersion'])}`, 'protocolVersion exactly 1');
|
|
188
|
+
}
|
|
189
|
+
if (value['ok'] === true) {
|
|
190
|
+
const expectedKeys = spec.phase === 'replace' ? ['protocolVersion', 'ok', 'result'] : ['protocolVersion', 'ok'];
|
|
191
|
+
if (!hasExactKeys(value, expectedKeys)) {
|
|
192
|
+
throw protocolError(spec, invocation, 'success envelope had missing or extra fields', `{ ${expectedKeys.join(', ')} } only`);
|
|
193
|
+
}
|
|
194
|
+
if (spec.phase === 'replace') {
|
|
195
|
+
if (!isJson(value['result'])) {
|
|
196
|
+
throw protocolError(spec, invocation, 'replacement result was not JSON-compatible', 'a JSON result');
|
|
197
|
+
}
|
|
198
|
+
return { protocolVersion: 1, ok: true, result: value['result'] };
|
|
199
|
+
}
|
|
200
|
+
return { protocolVersion: 1, ok: true };
|
|
201
|
+
}
|
|
202
|
+
if (value['ok'] !== false || !hasExactKeys(value, ['protocolVersion', 'ok', 'error'])) {
|
|
203
|
+
throw protocolError(spec, invocation, 'failure envelope had missing or extra fields', '{ protocolVersion, ok, error } only');
|
|
204
|
+
}
|
|
205
|
+
const error = value['error'];
|
|
206
|
+
if (!isRecord(error) || !hasExactKeys(error, ['code', 'message'], ['field', 'next'])) {
|
|
207
|
+
throw protocolError(spec, invocation, 'error envelope was missing fields or contained extra fields', 'error: { code, message, field?, next? }');
|
|
208
|
+
}
|
|
209
|
+
if (typeof error['code'] !== 'string' || error['code'] === '' || typeof error['message'] !== 'string' || error['message'] === '') {
|
|
210
|
+
throw protocolError(spec, invocation, 'error envelope had an empty or non-string code/message', 'non-empty error code and message strings');
|
|
211
|
+
}
|
|
212
|
+
if ((error['field'] !== undefined && typeof error['field'] !== 'string') || (error['next'] !== undefined && typeof error['next'] !== 'string')) {
|
|
213
|
+
throw protocolError(spec, invocation, 'error envelope had a non-string field/next', 'string field and next values when present');
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
protocolVersion: 1,
|
|
217
|
+
ok: false,
|
|
218
|
+
error: {
|
|
219
|
+
code: error['code'],
|
|
220
|
+
message: error['message'],
|
|
221
|
+
...(error['field'] === undefined ? {} : { field: error['field'] }),
|
|
222
|
+
...(error['next'] === undefined ? {} : { next: error['next'] }),
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function throwHookFailure(spec, invocation, failure) {
|
|
227
|
+
const { code, message, field, next } = failure.error;
|
|
228
|
+
if (!SNAKE.test(code) || RESERVED_CODES.has(code)) {
|
|
229
|
+
throw protocolError(spec, invocation, `error code "${code}" was invalid or reserved`, 'a non-reserved lowercase snake_case code');
|
|
230
|
+
}
|
|
231
|
+
throw new CrtrError(code, `${label(spec)}: ${message}`, ExitCode.GENERAL, failureDetails(spec, invocation, field, next));
|
|
232
|
+
}
|
|
233
|
+
function protocolError(spec, invocation, received, expected) {
|
|
234
|
+
return new CrtrError('hook_protocol_error', `${label(spec)}: ${received}. Expected ${expected}.`, ExitCode.GENERAL, failureDetails(spec, invocation, undefined, NEXT, received));
|
|
235
|
+
}
|
|
236
|
+
function failureDetails(spec, invocation, field, next, received) {
|
|
237
|
+
const identity = {
|
|
238
|
+
plugin: spec.plugin,
|
|
239
|
+
op: spec.op,
|
|
240
|
+
phase: spec.phase,
|
|
241
|
+
command: [...spec.commandPath],
|
|
242
|
+
...(received === undefined ? {} : { received }),
|
|
243
|
+
...(field === undefined ? {} : { field }),
|
|
244
|
+
};
|
|
245
|
+
if (spec.phase !== 'after')
|
|
246
|
+
return { ...identity, next: next ?? NEXT };
|
|
247
|
+
const guidance = 'Do not retry the original command; its primary action completed. Inspect and reconcile the failed after hook.';
|
|
248
|
+
return {
|
|
249
|
+
...identity,
|
|
250
|
+
primaryCompleted: true,
|
|
251
|
+
result: invocation.result,
|
|
252
|
+
next: next === undefined || next === NEXT ? guidance : `${next} ${guidance}`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function label(spec) {
|
|
256
|
+
return `plugin "${spec.plugin}" ${spec.phase} hook "${spec.op}" for \`${spec.commandPath.join(' ')}\``;
|
|
257
|
+
}
|
|
258
|
+
function hasExactKeys(value, required, optional = []) {
|
|
259
|
+
const keys = Object.keys(value);
|
|
260
|
+
return required.every((key) => Object.hasOwn(value, key))
|
|
261
|
+
&& keys.every((key) => required.includes(key) || optional.includes(key));
|
|
262
|
+
}
|
|
263
|
+
function isJson(value) {
|
|
264
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string')
|
|
265
|
+
return true;
|
|
266
|
+
if (typeof value === 'number')
|
|
267
|
+
return Number.isFinite(value);
|
|
268
|
+
if (Array.isArray(value))
|
|
269
|
+
return value.every(isJson);
|
|
270
|
+
return isRecord(value) && Object.values(value).every(isJson);
|
|
271
|
+
}
|
|
272
|
+
function describeError(error) {
|
|
273
|
+
return error instanceof Error ? error.message : String(error);
|
|
274
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { listInstalledPlugins, listInstalledPluginsInRoot } from '../resolver.js';
|
|
2
|
+
import { projectScopeRoots } from '../scope.js';
|
|
3
|
+
/** Whether an enabled command plugin survives nearest-scope name shadowing. */
|
|
4
|
+
export function hasEffectiveCommandPlugins(startDir = process.cwd(), profileId) {
|
|
5
|
+
const seen = new Set();
|
|
6
|
+
const consider = (plugin) => {
|
|
7
|
+
if (seen.has(plugin.name))
|
|
8
|
+
return false;
|
|
9
|
+
seen.add(plugin.name);
|
|
10
|
+
return plugin.enabled && (plugin.manifest.commands !== undefined || plugin.manifest.transport !== undefined);
|
|
11
|
+
};
|
|
12
|
+
for (const root of projectScopeRoots(startDir, profileId)) {
|
|
13
|
+
if (listInstalledPluginsInRoot('project', root).some(consider))
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
return listInstalledPlugins('user').some(consider);
|
|
17
|
+
}
|
|
@@ -16,6 +16,11 @@ export declare function validateDeclaredResult(output: Field[], result: Record<s
|
|
|
16
16
|
field: string;
|
|
17
17
|
receivedType?: string;
|
|
18
18
|
} | null;
|
|
19
|
+
/** Core leaf declarations may explicitly require a nullable field. */
|
|
20
|
+
export declare function validateCoreDeclaredResult(output: Field[], result: Record<string, unknown>): {
|
|
21
|
+
field: string;
|
|
22
|
+
receivedType?: string;
|
|
23
|
+
} | null;
|
|
19
24
|
/** Execute one external leaf and return its result object (crtr renders it via
|
|
20
25
|
* the generic renderer; `--json` mirrors it). Throws a CrtrError for any
|
|
21
26
|
* protocol violation or the plugin's own recoverable error. */
|
|
@@ -21,13 +21,23 @@ const NEXT = 'Run `crtr pkg plugin show` or `crtr sys doctor` to inspect the plu
|
|
|
21
21
|
* Returns null if valid; otherwise returns a { field, receivedType } error.
|
|
22
22
|
* Transport-neutral: used by exec and HTTP transport adapters. */
|
|
23
23
|
export function validateDeclaredResult(output, result) {
|
|
24
|
+
return validateFields(output, result, false);
|
|
25
|
+
}
|
|
26
|
+
/** Core leaf declarations may explicitly require a nullable field. */
|
|
27
|
+
export function validateCoreDeclaredResult(output, result) {
|
|
28
|
+
return validateFields(output, result, true);
|
|
29
|
+
}
|
|
30
|
+
function validateFields(output, result, honorDeclaredNull) {
|
|
24
31
|
for (const f of output) {
|
|
25
|
-
const
|
|
26
|
-
|
|
32
|
+
const value = result[f.name];
|
|
33
|
+
const present = Object.prototype.hasOwnProperty.call(result, f.name)
|
|
34
|
+
&& value !== undefined
|
|
35
|
+
&& (honorDeclaredNull || value !== null);
|
|
36
|
+
if (f.required && !present)
|
|
27
37
|
return { field: f.name };
|
|
28
|
-
|
|
29
|
-
if (present && !
|
|
30
|
-
return { field: f.name, receivedType: typeof
|
|
38
|
+
const matches = honorDeclaredNull ? coreTypeMatches(f.type, value) : typeMatches(f.type, value);
|
|
39
|
+
if (present && !matches) {
|
|
40
|
+
return { field: f.name, receivedType: value === null ? 'null' : typeof value };
|
|
31
41
|
}
|
|
32
42
|
}
|
|
33
43
|
return null;
|
|
@@ -150,6 +160,49 @@ function typeMatches(type, v) {
|
|
|
150
160
|
return isRecord(v);
|
|
151
161
|
return true;
|
|
152
162
|
}
|
|
163
|
+
function coreTypeMatches(type, v) {
|
|
164
|
+
const alternatives = type.split('|').map((part) => part.trim()).filter(Boolean);
|
|
165
|
+
if (alternatives.length === 1 && !knownType(alternatives[0]))
|
|
166
|
+
return true;
|
|
167
|
+
const allKnown = alternatives.every((part) => knownType(part) || /^[a-z][a-z0-9_-]*$/.test(part));
|
|
168
|
+
if (!allKnown)
|
|
169
|
+
return true;
|
|
170
|
+
return alternatives.some((part) => atomMatches(part, v));
|
|
171
|
+
}
|
|
172
|
+
function knownType(type) {
|
|
173
|
+
const t = type.toLowerCase();
|
|
174
|
+
return t === 'null'
|
|
175
|
+
|| t === 'string'
|
|
176
|
+
|| t === 'path'
|
|
177
|
+
|| t === 'markdown'
|
|
178
|
+
|| t === 'int'
|
|
179
|
+
|| t === 'integer'
|
|
180
|
+
|| t === 'number'
|
|
181
|
+
|| t === 'float'
|
|
182
|
+
|| t === 'bool'
|
|
183
|
+
|| t === 'boolean'
|
|
184
|
+
|| t === 'array'
|
|
185
|
+
|| t.endsWith('[]')
|
|
186
|
+
|| t === 'object';
|
|
187
|
+
}
|
|
188
|
+
function atomMatches(type, v) {
|
|
189
|
+
const t = type.toLowerCase();
|
|
190
|
+
if (t === 'null')
|
|
191
|
+
return v === null;
|
|
192
|
+
if (t === 'string' || t === 'path' || t === 'markdown')
|
|
193
|
+
return typeof v === 'string';
|
|
194
|
+
if (t === 'int' || t === 'integer')
|
|
195
|
+
return typeof v === 'number' && Number.isInteger(v);
|
|
196
|
+
if (t === 'number' || t === 'float')
|
|
197
|
+
return typeof v === 'number';
|
|
198
|
+
if (t === 'bool' || t === 'boolean')
|
|
199
|
+
return typeof v === 'boolean';
|
|
200
|
+
if (t === 'array' || t.endsWith('[]'))
|
|
201
|
+
return Array.isArray(v);
|
|
202
|
+
if (t === 'object')
|
|
203
|
+
return isRecord(v);
|
|
204
|
+
return typeof v === 'string' && v === type;
|
|
205
|
+
}
|
|
153
206
|
function protocolError(spec, received, expected) {
|
|
154
207
|
return new CrtrError('plugin_protocol_error', `${label(spec)}: ${received}. Expected ${expected}.`, ExitCode.GENERAL, { received, next: NEXT });
|
|
155
208
|
}
|
package/dist/core/command.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RootHelp, RootEntry, BranchHelp, LeafHelp, InputParam, SubTier } from './help.js';
|
|
1
|
+
import type { RootHelp, RootEntry, BranchHelp, LeafHelp, EffectiveLeafHelp, InputParam, SubTier } from './help.js';
|
|
2
2
|
import { CrtrError } from './errors.js';
|
|
3
3
|
/** Runtime context passed to a leaf's run function. Includes metadata about
|
|
4
4
|
* which parameters were explicitly provided vs. defaulted. Used by configured
|
|
@@ -47,6 +47,11 @@ export interface LeafDef {
|
|
|
47
47
|
* 'hidden' keeps an internal leaf out of every listing. */
|
|
48
48
|
tier?: SubTier;
|
|
49
49
|
help: LeafHelp;
|
|
50
|
+
/** Invocation-local immutable help view. Present only on hook-eligible core
|
|
51
|
+
* leaves; static discovery may change Effects but never executes hooks. */
|
|
52
|
+
effectiveHelp?: () => Promise<EffectiveLeafHelp>;
|
|
53
|
+
/** Opt this conditionally-void leaf out of core command hooks. */
|
|
54
|
+
hookEligible?: false;
|
|
50
55
|
/** Opt into editor slash-command exposure (see SlashSpec). */
|
|
51
56
|
slash?: SlashSpec;
|
|
52
57
|
run: (input: Record<string, unknown>, context?: LeafRunContext) => Promise<Record<string, unknown> | void>;
|
|
@@ -100,6 +105,8 @@ export declare function defineLeaf(opts: {
|
|
|
100
105
|
tier?: SubTier;
|
|
101
106
|
help: LeafHelp;
|
|
102
107
|
slash?: SlashSpec;
|
|
108
|
+
/** Opt this conditionally-void leaf out of core command hooks. */
|
|
109
|
+
hookEligible?: false;
|
|
103
110
|
run: (input: Record<string, unknown>, context?: LeafRunContext) => Promise<Record<string, unknown> | void>;
|
|
104
111
|
render?: (result: Record<string, unknown>) => string;
|
|
105
112
|
}): LeafDef;
|
package/dist/core/command.js
CHANGED
|
@@ -34,6 +34,7 @@ export function defineLeaf(opts) {
|
|
|
34
34
|
tier: opts.tier,
|
|
35
35
|
help: freezeCommandMetadata(opts.help),
|
|
36
36
|
slash: opts.slash === undefined ? undefined : freezeCommandMetadata(opts.slash),
|
|
37
|
+
hookEligible: opts.hookEligible,
|
|
37
38
|
run: opts.run,
|
|
38
39
|
render: opts.render,
|
|
39
40
|
});
|
|
@@ -187,19 +188,20 @@ function renderNode(node) {
|
|
|
187
188
|
return renderBranch(node.help);
|
|
188
189
|
return renderLeafArgv(node.help);
|
|
189
190
|
}
|
|
190
|
-
/** Render
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* lookup receives the install gate's exact strict-validation inputs (reserved
|
|
196
|
-
* core names + the full core command path set, both from build-root), so a
|
|
197
|
-
* manifest rejected at ingress contributes nothing here either — loading
|
|
198
|
-
* every subtree for that path set is a help-path-only cost. */
|
|
191
|
+
/** Render effective Effects for a hook-eligible core leaf, then append any
|
|
192
|
+
* commands.json helpAddenda beneath the contract. Both lookups read stored
|
|
193
|
+
* manifest bytes only. Addenda validation receives the install gate's exact
|
|
194
|
+
* reserved core names and full core path set, so a manifest rejected at
|
|
195
|
+
* ingress contributes nothing here either. */
|
|
199
196
|
async function renderNodeWithAddenda(node, path) {
|
|
200
|
-
const body =
|
|
197
|
+
const body = node.kind === 'leaf' && node.effectiveHelp !== undefined
|
|
198
|
+
? renderLeafArgv(await node.effectiveHelp())
|
|
199
|
+
: renderNode(node);
|
|
201
200
|
if (path.length === 0)
|
|
202
201
|
return body;
|
|
202
|
+
const { hasEffectiveCommandPlugins } = await import('./command-plugins/presence.js');
|
|
203
|
+
if (!hasEffectiveCommandPlugins())
|
|
204
|
+
return body;
|
|
203
205
|
const [{ collectHelpAddenda }, { SUBTREE_NAMES, coreCommandPaths }] = await Promise.all([
|
|
204
206
|
import('./command-plugins/help-addenda.js'),
|
|
205
207
|
import('../build-root.js'),
|