@north-light/crouter 0.3.259 → 0.3.260

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ const TIERS = new Set(['normal', 'common', 'important']);
5
5
  const FLAG_TYPES = new Set(['string', 'int', 'bool', 'path', 'enum']);
6
6
  const KEBAB = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
7
7
  const BRANCH_KEYS = new Set([
8
- 'kind', 'name', 'description', 'whenToUse', 'tier', 'rootEntry', 'summary', 'model', 'children',
8
+ 'kind', 'name', 'description', 'whenToUse', 'tier', 'rootEntry', 'extensible', 'summary', 'model', 'children',
9
9
  ]);
10
10
  /** Only exec transport may declare `passthrough`; HTTP manifests keep
11
11
  * BRANCH_KEYS, so a declared passthrough fails as an unknown key. */
@@ -331,7 +331,7 @@ function checkCommon(raw, path, issue) {
331
331
  }
332
332
  return true;
333
333
  }
334
- export function validateCommandNode(raw, path, topLevel, transport, issue) {
334
+ export function validateCommandNode(raw, path, topLevel, transport, issue, options = {}) {
335
335
  const pathString = path.join('.');
336
336
  if (!isRecord(raw)) {
337
337
  issue('command_node_invalid', 'node must be an object', typeName(raw), 'a branch or leaf node object', 'Fix the node.', pathString);
@@ -339,9 +339,9 @@ export function validateCommandNode(raw, path, topLevel, transport, issue) {
339
339
  }
340
340
  const kind = raw['kind'];
341
341
  if (kind === 'branch')
342
- return validateBranch(raw, pathString, topLevel, transport, issue);
342
+ return validateBranch(raw, pathString, topLevel, transport, issue, options);
343
343
  if (kind === 'leaf') {
344
- if (topLevel) {
344
+ if (topLevel && options.topLevelRootEntry !== 'forbidden') {
345
345
  issue('command_node_invalid', 'top-level node must be a branch', 'leaf', 'a branch node with a rootEntry', 'Wrap the command in a top-level branch (noun) with a rootEntry.', `${pathString}.kind`);
346
346
  return null;
347
347
  }
@@ -350,18 +350,26 @@ export function validateCommandNode(raw, path, topLevel, transport, issue) {
350
350
  issue('command_node_invalid', 'node kind must be "branch" or "leaf"', String(kind), 'branch | leaf', 'Set kind to branch or leaf.', `${pathString}.kind`);
351
351
  return null;
352
352
  }
353
- function validateBranch(raw, path, topLevel, transport, issue) {
354
- if (!checkKeys(raw, transport === 'exec' ? EXEC_BRANCH_KEYS : BRANCH_KEYS, path, issue))
353
+ function validateBranch(raw, path, topLevel, transport, issue, options) {
354
+ const allowsPassthrough = options.allowPassthrough !== false;
355
+ const allowedKeys = transport === 'exec' && allowsPassthrough ? EXEC_BRANCH_KEYS : BRANCH_KEYS;
356
+ if (!checkKeys(raw, allowedKeys, path, issue))
355
357
  return null;
356
358
  if (!checkCommon(raw, path, issue))
357
359
  return null;
358
360
  const hasRoot = raw['rootEntry'] !== undefined;
359
- if (topLevel && !hasRoot) {
361
+ const rootEntryPolicy = options.topLevelRootEntry ?? 'required';
362
+ if (topLevel && rootEntryPolicy === 'required' && !hasRoot) {
360
363
  issue('command_node_invalid', 'top-level branch requires a rootEntry', '(missing)', 'a rootEntry { concept, description, whenToUse }', 'Add a rootEntry to the top-level branch.', `${path}.rootEntry`);
361
364
  return null;
362
365
  }
363
- if (!topLevel && hasRoot) {
364
- issue('command_node_invalid', 'nested branch must not declare a rootEntry', 'rootEntry', 'no rootEntry (only top-level branches carry one)', 'Remove rootEntry from the nested branch.', `${path}.rootEntry`);
366
+ if ((!topLevel || rootEntryPolicy === 'forbidden') && hasRoot) {
367
+ issue('command_node_invalid', topLevel ? 'repository fragment root must not declare a rootEntry' : 'nested branch must not declare a rootEntry', 'rootEntry', topLevel ? 'no rootEntry (the owning plugin branch already has one)' : 'no rootEntry (only top-level branches carry one)', topLevel ? 'Remove rootEntry from the repository fragment root.' : 'Remove rootEntry from the nested branch.', `${path}.rootEntry`);
368
+ return null;
369
+ }
370
+ const hasExtensible = raw['extensible'] !== undefined;
371
+ if (hasExtensible && (!topLevel || options.allowExtensible === false || raw['extensible'] !== true)) {
372
+ issue('command_node_invalid', !topLevel ? 'extensible is valid only on a top-level branch mount' : 'extensible must be exactly true on a plugin top-level branch', String(raw['extensible']), !topLevel ? 'no extensible marker on nested branches' : 'extensible: true', !topLevel ? 'Move the marker to the plugin top-level branch or remove it.' : 'Set extensible to true or remove it.', `${path}.extensible`);
365
373
  return null;
366
374
  }
367
375
  let rootEntry;
@@ -386,6 +394,10 @@ function validateBranch(raw, path, topLevel, transport, issue) {
386
394
  return null;
387
395
  passthrough = validated;
388
396
  }
397
+ if (hasExtensible && passthrough !== undefined) {
398
+ issue('command_node_invalid', 'an extensible branch cannot declare passthrough', 'extensible and passthrough', 'one branch mode: extensible or passthrough', 'Remove passthrough or remove extensible.', path);
399
+ return null;
400
+ }
389
401
  const children = raw['children'];
390
402
  if (passthrough !== undefined) {
391
403
  if (!Array.isArray(children) || children.length !== 0) {
@@ -400,7 +412,7 @@ function validateBranch(raw, path, topLevel, transport, issue) {
400
412
  const validated = [];
401
413
  const childNames = new Set();
402
414
  for (let i = 0; i < children.length; i++) {
403
- const child = validateCommandNode(children[i], [...path.split('.'), `children[${i}]`], false, transport, issue);
415
+ const child = validateCommandNode(children[i], [...path.split('.'), `children[${i}]`], false, transport, issue, options);
404
416
  if (child === null)
405
417
  return null;
406
418
  if (childNames.has(child.name)) {
@@ -413,7 +425,8 @@ function validateBranch(raw, path, topLevel, transport, issue) {
413
425
  return {
414
426
  kind: 'branch', name: raw['name'], description: raw['description'], whenToUse: raw['whenToUse'],
415
427
  ...(raw['tier'] !== undefined ? { tier: raw['tier'] } : {}),
416
- ...(rootEntry !== undefined ? { rootEntry } : {}), summary: raw['summary'],
428
+ ...(rootEntry !== undefined ? { rootEntry } : {}),
429
+ ...(hasExtensible ? { extensible: true } : {}), summary: raw['summary'],
417
430
  ...(raw['model'] !== undefined ? { model: raw['model'] } : {}),
418
431
  ...(passthrough !== undefined ? { passthrough } : {}), children: validated,
419
432
  };
@@ -39,7 +39,7 @@ export function composeExternalSubtrees(snapshot) {
39
39
  }
40
40
  function buildBranch(contribution, node, path) {
41
41
  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 } : {}) }, ...(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])) });
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])) });
43
43
  }
44
44
  function buildLeaf(contribution, node, path) {
45
45
  const adapted = contribution.adaptLeaf(node, path);
@@ -34,4 +34,4 @@ export declare function discoverCommandContributions(reservedNames: ReadonlySet<
34
34
  contributions: ValidatedContribution[];
35
35
  issues: CommandDiscoveryIssue[];
36
36
  };
37
- export declare function buildExternalCommandSnapshot(reservedCoreNames: ReadonlySet<string>): CommandRegistrySnapshot;
37
+ export declare function buildExternalCommandSnapshot(reservedCoreNames: ReadonlySet<string>, startDir?: string, profileId?: string | null): CommandRegistrySnapshot;
@@ -6,6 +6,7 @@ import { validateCommandManifest } from '../command-manifests/manifest.js';
6
6
  import { qualifiedTopLevelCommandName, resolveCommandRegistry } from '../command-manifests/registry.js';
7
7
  import { adaptPluginContributions } from './compose.js';
8
8
  import { validateHttpPluginTransport } from './endpoint.js';
9
+ import { applyCommandExtensions } from './extensions.js';
9
10
  export function effectiveCommandPlugins(startDir = process.cwd(), profileId) {
10
11
  const seen = new Set();
11
12
  const result = [];
@@ -130,7 +131,11 @@ export function validatePluginCommands(plugin, reservedNames = new Set(), coreCo
130
131
  : transport;
131
132
  if (resolvedTransport === undefined)
132
133
  return { plugin, manifestPath, transport, contributions: [], issues };
133
- return { plugin, manifestPath, transport: resolvedTransport, manifest: validation.manifest, contributions: validation.manifest.roots.map((node) => ({ plugin, transport: resolvedTransport, node, manifest: validation.manifest, manifestName: node.name })), issues };
134
+ const roots = validation.manifest.roots;
135
+ if (!roots.every((node) => node.kind === 'branch')) {
136
+ throw new Error('plugin manifest validation returned a top-level leaf');
137
+ }
138
+ return { plugin, manifestPath, transport: resolvedTransport, manifest: validation.manifest, contributions: roots.map((node) => ({ plugin, transport: resolvedTransport, node, manifest: validation.manifest, manifestName: node.name })), issues };
134
139
  }
135
140
  export function discoverPluginCommandCandidates(startDir = process.cwd(), profileId) {
136
141
  return effectiveCommandPlugins(startDir, profileId).map((plugin) => validatePluginCommands(plugin));
@@ -164,9 +169,10 @@ export function discoverCommandContributions(reservedNames, startDir = process.c
164
169
  const validations = validateEffectiveCommandPlugins(reservedNames, startDir, profileId);
165
170
  return { contributions: validations.flatMap((validation) => validation.contributions), issues: validations.flatMap((validation) => validation.issues) };
166
171
  }
167
- export function buildExternalCommandSnapshot(reservedCoreNames) {
168
- const validations = discoverPluginCommandCandidates();
172
+ export function buildExternalCommandSnapshot(reservedCoreNames, startDir = process.cwd(), profileId) {
173
+ const validations = discoverPluginCommandCandidates(startDir, profileId);
169
174
  const candidates = adaptPluginContributions(validations.flatMap((validation) => validation.contributions)).contributions;
170
175
  const resolved = resolveCommandRegistry(candidates, reservedCoreNames);
171
- return { contributions: resolved.contributions, issues: [...validations.flatMap((validation) => validation.issues.map((issue) => contributorIssue(validation.plugin, issue))), ...resolved.issues] };
176
+ const extensions = applyCommandExtensions(resolved.contributions, startDir);
177
+ return { contributions: extensions.contributions, issues: [...validations.flatMap((validation) => validation.issues.map((issue) => contributorIssue(validation.plugin, issue))), ...resolved.issues] };
172
178
  }
@@ -0,0 +1,20 @@
1
+ import type { CommandManifestIssue } from '../command-manifests/schema.js';
2
+ import type { CommandContribution } from '../command-manifests/registry.js';
3
+ export interface ExtensionIssueNotice {
4
+ fragment: string;
5
+ message: string;
6
+ }
7
+ export interface CommandExtensionValidation {
8
+ branch: string;
9
+ fragmentPath: string;
10
+ projectRoot: string;
11
+ issue?: CommandManifestIssue;
12
+ }
13
+ /** Attach the nearest repository-owned fragment to each marked plugin branch.
14
+ * Discovery reads only; execution remains in the leaf adapter. */
15
+ export declare function applyCommandExtensions(contributions: readonly CommandContribution[], startDir?: string): {
16
+ contributions: CommandContribution[];
17
+ validations: CommandExtensionValidation[];
18
+ };
19
+ /** Doctor's only directory scan: fragments with no matching effective marked branch are otherwise inert by design. */
20
+ export declare function orphanCommandExtensionValidations(contributions: readonly CommandContribution[], startDir?: string): CommandExtensionValidation[];
@@ -0,0 +1,166 @@
1
+ import { existsSync, realpathSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
3
+ import { projectScopeRoots } from '../scope.js';
4
+ import { validateCommandManifest } from '../command-manifests/manifest.js';
5
+ import { executeExternalLeaf } from './transport/exec-invoke.js';
6
+ import { isRecord } from '../../shared/predicates.js';
7
+ function issue(fragmentPath, message, received, expected, next, nodePath) {
8
+ return {
9
+ code: 'command_extension_invalid',
10
+ message,
11
+ received,
12
+ expected,
13
+ next,
14
+ path: nodePath === undefined ? fragmentPath : `${fragmentPath}:${nodePath}`,
15
+ };
16
+ }
17
+ function regenerate(fragmentPath) {
18
+ return `Fix the repository CLI definition and regenerate ${fragmentPath}.`;
19
+ }
20
+ function safeExecutable(fragmentPath, root, executable) {
21
+ if (isAbsolute(executable)) {
22
+ return { issue: issue(fragmentPath, 'fragment transport executable must be project-relative', executable, 'a project-root-relative regular file', 'Fix transport.executable in the repository CLI definition and regenerate the fragment.', 'transport.executable') };
23
+ }
24
+ try {
25
+ const rootReal = realpathSync(root);
26
+ const resolved = realpathSync(resolve(root, executable));
27
+ if ((resolved !== rootReal && !resolved.startsWith(rootReal + sep)) || !statSync(resolved).isFile()) {
28
+ return { issue: issue(fragmentPath, 'fragment transport executable escapes the project root or is not a regular file', executable, 'a project-root-relative regular file', 'Fix transport.executable in the repository CLI definition and regenerate the fragment.', 'transport.executable') };
29
+ }
30
+ if ((statSync(resolved).mode & 0o111) === 0) {
31
+ return { issue: issue(fragmentPath, 'fragment transport executable lacks the POSIX exec bit', executable, 'a file with an executable permission bit', 'chmod +x the repository command executable, then regenerate the fragment.', 'transport.executable') };
32
+ }
33
+ return { executable: resolved };
34
+ }
35
+ catch {
36
+ return { issue: issue(fragmentPath, 'fragment transport executable escapes the project root or is not a regular file', executable, 'a project-root-relative regular file', 'Fix transport.executable in the repository CLI definition and regenerate the fragment.', 'transport.executable') };
37
+ }
38
+ }
39
+ function validateFragment(fragmentPath, projectRoot, branch, ownChildren) {
40
+ let raw;
41
+ try {
42
+ raw = JSON.parse(readFileSync(fragmentPath, 'utf8'));
43
+ }
44
+ catch {
45
+ return { issue: issue(fragmentPath, 'extension fragment is missing, unreadable, or not valid JSON', fragmentPath, 'a readable generated JSON fragment', `Regenerate ${fragmentPath}.`) };
46
+ }
47
+ if (!isRecord(raw)) {
48
+ return { issue: issue(fragmentPath, 'extension fragment must be an object', raw === null ? 'null' : Array.isArray(raw) ? 'array' : typeof raw, '{ schemaVersion, transport, mounts }', `Regenerate ${fragmentPath}.`) };
49
+ }
50
+ const unknown = Object.keys(raw).filter((key) => !['schemaVersion', 'transport', 'mounts'].includes(key));
51
+ if (unknown.length > 0) {
52
+ return { issue: issue(fragmentPath, 'extension fragment has unknown top-level keys', unknown.join(', '), 'only: schemaVersion, transport, mounts', `Regenerate ${fragmentPath}.`) };
53
+ }
54
+ const transport = raw['transport'];
55
+ if (!isRecord(transport) || Object.keys(transport).some((key) => !['kind', 'executable'].includes(key)) || transport['kind'] !== 'exec' || typeof transport['executable'] !== 'string' || transport['executable'].length === 0) {
56
+ return { issue: issue(fragmentPath, 'extension fragment transport must be { kind: "exec", executable: "project-relative path" }', JSON.stringify(transport), 'an exec transport with an executable', `Regenerate ${fragmentPath}.`, 'transport') };
57
+ }
58
+ const executableResult = safeExecutable(fragmentPath, projectRoot, transport['executable']);
59
+ if (executableResult.issue !== undefined)
60
+ return { issue: executableResult.issue };
61
+ const validation = validateCommandManifest(raw, {
62
+ transport: 'exec',
63
+ reservedCoreNames: new Set(),
64
+ extensionFragment: true,
65
+ });
66
+ if (validation.manifest === undefined) {
67
+ const source = validation.issues[0];
68
+ return {
69
+ issue: issue(fragmentPath, source.message, source.received, source.expected, regenerate(fragmentPath), source.path),
70
+ };
71
+ }
72
+ const conflict = validation.manifest.roots.find((root) => ownChildren.includes(root.name));
73
+ if (conflict !== undefined) {
74
+ return {
75
+ issue: issue(fragmentPath, `contributed command "${conflict.name}" conflicts with a child the extensible branch already owns`, conflict.name, 'a name not already owned by the plugin branch', `Rename "${conflict.name}" in the repository CLI tree, then regenerate ${fragmentPath}.`, conflict.name),
76
+ };
77
+ }
78
+ return { fragment: { roots: validation.manifest.roots, executable: executableResult.executable } };
79
+ }
80
+ function nearestFragment(branch, startDir) {
81
+ for (const scopeRoot of projectScopeRoots(startDir, null)) {
82
+ const projectRoot = dirname(scopeRoot);
83
+ const fragmentPath = join(scopeRoot, 'commands', `${branch}.json`);
84
+ if (existsSync(fragmentPath))
85
+ return { fragmentPath, projectRoot };
86
+ }
87
+ return undefined;
88
+ }
89
+ function markLeaves(node, leaves) {
90
+ if (node.kind === 'leaf') {
91
+ leaves.add(node);
92
+ return;
93
+ }
94
+ node.children.forEach((child) => markLeaves(child, leaves));
95
+ }
96
+ /** Attach the nearest repository-owned fragment to each marked plugin branch.
97
+ * Discovery reads only; execution remains in the leaf adapter. */
98
+ export function applyCommandExtensions(contributions, startDir = process.cwd()) {
99
+ const validations = [];
100
+ const attached = contributions.map((contribution) => {
101
+ if (contribution.node.extensible !== true)
102
+ return contribution;
103
+ const branch = contribution.node.name;
104
+ const located = nearestFragment(branch, startDir);
105
+ if (located === undefined)
106
+ return contribution;
107
+ const result = validateFragment(located.fragmentPath, located.projectRoot, branch, contribution.node.children.map((child) => child.name));
108
+ if (result.issue !== undefined) {
109
+ validations.push({ branch, fragmentPath: located.fragmentPath, projectRoot: located.projectRoot, issue: result.issue });
110
+ return {
111
+ ...contribution,
112
+ extensionIssue: { fragment: located.fragmentPath, message: result.issue.message },
113
+ };
114
+ }
115
+ const fragment = result.fragment;
116
+ validations.push({ branch, fragmentPath: located.fragmentPath, projectRoot: located.projectRoot });
117
+ const leaves = new WeakSet();
118
+ fragment.roots.forEach((root) => markLeaves(root, leaves));
119
+ return {
120
+ ...contribution,
121
+ node: { ...contribution.node, children: [...contribution.node.children, ...fragment.roots] },
122
+ adaptLeaf: (leaf, path) => {
123
+ if (!leaves.has(leaf))
124
+ return contribution.adaptLeaf(leaf, path);
125
+ const commandPath = [...path];
126
+ return {
127
+ outputKind: 'object',
128
+ run: async (input) => executeExternalLeaf({
129
+ extension: { branch, root: located.projectRoot },
130
+ executable: fragment.executable,
131
+ commandPath,
132
+ output: leaf.output,
133
+ }, input),
134
+ };
135
+ },
136
+ };
137
+ });
138
+ return { contributions: attached, validations };
139
+ }
140
+ /** Doctor's only directory scan: fragments with no matching effective marked branch are otherwise inert by design. */
141
+ export function orphanCommandExtensionValidations(contributions, startDir = process.cwd()) {
142
+ const scopeRoot = projectScopeRoots(startDir, null)[0];
143
+ if (scopeRoot === undefined)
144
+ return [];
145
+ const commandsDir = join(scopeRoot, 'commands');
146
+ let names;
147
+ try {
148
+ names = readdirSync(commandsDir);
149
+ }
150
+ catch {
151
+ return [];
152
+ }
153
+ const extensible = new Set(contributions.filter((contribution) => contribution.node.extensible === true).map((contribution) => contribution.node.name));
154
+ return names.filter((name) => name.endsWith('.json')).flatMap((name) => {
155
+ const branch = name.slice(0, -'.json'.length);
156
+ if (extensible.has(branch))
157
+ return [];
158
+ const fragmentPath = join(commandsDir, name);
159
+ return [{
160
+ branch,
161
+ fragmentPath,
162
+ projectRoot: dirname(scopeRoot),
163
+ issue: issue(fragmentPath, `fragment names branch "${branch}" but no effective plugin branch is extensible`, branch, 'the name of an effective extensible plugin branch', `Mark the plugin branch extensible or rename/remove ${fragmentPath}.`),
164
+ }];
165
+ });
166
+ }
@@ -1,14 +1,27 @@
1
1
  import type { InstalledPlugin } from '../../../types.js';
2
2
  import type { Field } from '../../help.js';
3
- export interface ExternalLeafSpec {
3
+ export type ExternalLeafSpec = {
4
4
  plugin: InstalledPlugin;
5
+ extension?: never;
5
6
  /** Absolute, resolved executable path. */
6
7
  executable: string;
7
8
  /** Full canonical command path, e.g. ['app', 'show']. */
8
9
  commandPath: string[];
9
10
  /** Declared leaf output fields — validated against the result before render. */
10
11
  output: Field[];
11
- }
12
+ } | {
13
+ plugin?: never;
14
+ extension: {
15
+ branch: string;
16
+ root: string;
17
+ };
18
+ /** Absolute, resolved executable path. */
19
+ executable: string;
20
+ /** Full canonical command path, e.g. ['app', 'show']. */
21
+ commandPath: string[];
22
+ /** Declared leaf output fields — validated against the result before render. */
23
+ output: Field[];
24
+ };
12
25
  /** Validates a result object against declared output field descriptors.
13
26
  * Returns null if valid; otherwise returns a { field, receivedType } error.
14
27
  * Transport-neutral: used by exec and HTTP transport adapters. */
@@ -52,12 +52,14 @@ export function executeExternalLeaf(spec, input) {
52
52
  input,
53
53
  context: {
54
54
  cwd: process.cwd(),
55
- plugin: {
56
- name: spec.plugin.name,
57
- version: spec.plugin.version ?? spec.plugin.manifest.version ?? null,
58
- scope: spec.plugin.scope,
59
- root: spec.plugin.root,
60
- },
55
+ ...(spec.plugin !== undefined
56
+ ? { plugin: {
57
+ name: spec.plugin.name,
58
+ version: spec.plugin.version ?? spec.plugin.manifest.version ?? null,
59
+ scope: spec.plugin.scope,
60
+ root: spec.plugin.root,
61
+ } }
62
+ : { extension: spec.extension }),
61
63
  },
62
64
  };
63
65
  const res = spawnSync(spec.executable, ['--crtr-command-protocol', '1'], {
@@ -207,5 +209,7 @@ function protocolError(spec, received, expected) {
207
209
  return new CrtrError('plugin_protocol_error', `${label(spec)}: ${received}. Expected ${expected}.`, ExitCode.GENERAL, { received, next: NEXT });
208
210
  }
209
211
  function label(spec) {
210
- return `plugin "${spec.plugin.name}" command \`${spec.commandPath.join(' ')}\``;
212
+ return spec.plugin !== undefined
213
+ ? `plugin "${spec.plugin.name}" command \`${spec.commandPath.join(' ')}\``
214
+ : `extension branch "${spec.extension.branch}" command \`${spec.commandPath.join(' ')}\``;
211
215
  }
@@ -198,6 +198,11 @@ export interface BranchHelp {
198
198
  /** Parent-level listing assembled by defineBranch from the actual child defs.
199
199
  * renderBranch reads this; never author it by hand. */
200
200
  listing?: ListingChild[];
201
+ /** A rejected repository fragment is visible only on its owning extensible branch. */
202
+ extensionIssue?: {
203
+ fragment: string;
204
+ message: string;
205
+ };
201
206
  }
202
207
  /** Viewer-only hint about how a leaf's result should preview in the attach
203
208
  * viewer's chat — never part of `-h` output and never reaches agent-facing
package/dist/core/help.js CHANGED
@@ -161,6 +161,9 @@ export function renderBranch(h) {
161
161
  lines.push('');
162
162
  lines.push(`<subcommand name="${c.name}" description="${attr(c.description)}" whenToUse="${attr(c.whenToUse)}"${subs}/>`);
163
163
  }
164
+ if (h.extensionIssue !== undefined) {
165
+ 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>`);
166
+ }
164
167
  lines.push('</command>');
165
168
  return lines.join('\n');
166
169
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.259",
3
+ "version": "0.3.260",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.259",
3
+ "version": "0.3.260",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.259",
9
+ "version": "0.3.260",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {