@north-light/crouter 0.3.259 → 0.3.261

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.
@@ -290,6 +290,16 @@ An exec manifest accepts only `schemaVersion` and `mounts`. Its leaves declare `
290
290
 
291
291
  Every branch child is a branch (without `rootEntry`) or leaf. A leaf declares `params`, `output` (array of `{ name, type, required, constraint }`), and a non-empty `effects` array. Params use crtr's public vocabulary — one positional max, long-form `flag`s (types `string|int|bool|path|enum`, `choices` for enum), `stdin`, `context-file`; kebab-case names, no aliases. Two client-side affordances ride on a `positional` or `flag`: `encoding: "text"|"base64"` on a `type: "path"` param sends the named local FILE's content instead of the path string, and `defaultFromEnv: "UPPER_SNAKE"` fills an omitted `string`/`path` param from that environment variable on the calling machine, counting as supplied (so it satisfies `required` and is sent) — unlike a static `default`, which is a parse convenience only and never ships. `defaultFromEnv` is rejected alongside `default` or `repeatable`. The declaration mirrors crtr's stable help descriptors, not its internal TypeScript defs — no closures, dynamic state, or renderers.
292
292
 
293
+ ### Extensible branches and repository fragments
294
+
295
+ Use `"extensible": true` only on a plugin branch mounted at `parent: []` when repositories should add native children beneath that branch. It is mutually exclusive with `passthrough`: a passthrough branch owns all remaining tokens and cannot have contributed children. The marker does not change the plugin's transport or its own children; those children remain first, and a repository root that reuses one of their names rejects the entire fragment.
296
+
297
+ A repository CLI framework generates one fragment per opted-in branch at `<project-root>/.crouter/commands/<effective-branch>.json`; the repository developer commits the generated file, never hand-writing it. The effective branch is the composed command name, so a cross-plugin collision uses its origin-qualified identity (for example, `first:demo.json` extends only `first:demo`). On every invocation crtr walks the caller's existing project-root chain nearest first and reads only the first matching fragment; a corrected file is live on the next invocation without installation, caching, or restart.
298
+
299
+ A fragment accepts only `schemaVersion: 1`, its required `exec` `transport`, and non-empty `mounts`; it shares the `commands.json` node, parameter, output, and effects grammar documented above rather than defining another command language. Its executable is project-root-relative, contained in that root, a regular executable file, and is direct-spawned only for an explicit leaf invocation. Fragment roots attach beneath the extensible branch and may be branches or direct leaves, so `parent: []` never declares `rootEntry`; non-empty parents resolve only within the fragment's own forest, and `passthrough` is forbidden everywhere in a fragment. HTTP transport is not supported for fragments.
300
+
301
+ A contributed exec leaf receives the same one-request/one-envelope protocol described in [Exec protocol](#exec-protocol), with the full walked command path and `context.extension { branch, root }` instead of `context.plugin`; it runs with the caller's cwd and full environment. It is trusted repository code running with the caller's authority, not sandboxed code. Native help renders contributed commands without provenance; when a present fragment is rejected, only that extensible branch's help shows an `<extension-issue>` pointing to the file, while `crtr sys doctor` reports the full `command_extension_invalid` remediation and also catches fragments for branches that are not extensible. Recovery is to fix or regenerate the committed fragment.
302
+
293
303
  ### Execution and trust boundaries
294
304
 
295
305
  An exec leaf direct-spawns its executable (no shell) only on explicit invocation — never on install, help, or discovery — with `--crtr-command-protocol 1`, the caller's cwd, and the full environment. It is **trusted local code running with the caller's authority**: crtr does not sandbox it, filter the environment, mint a credential, or interpret its backend authentication. This is an execution trust boundary, not a sandbox.
@@ -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 top-level command names), issues: object[] ({code, path?, message, received, expected, next})}. Validated statically; command execution never occurs.' },
109
+ { name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest (manifest.commands). {manifestPath: string, mounts: string[] (accepted top-level command names), extensibleBranches: string[] (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}[], issues: object[] ({code, path?, message, received, expected, next}), trust: string}. Static only: no hook executable runs.' },
111
111
  ],
112
112
  outputKind: 'object',
@@ -146,6 +146,7 @@ export const pluginShow = defineLeaf({
146
146
  commands = {
147
147
  manifestPath: v.manifestPath,
148
148
  mounts: v.contributions.map((c) => c.node.name),
149
+ extensibleBranches: v.contributions.filter((c) => c.node.extensible === true).map((c) => c.node.name),
149
150
  issues: v.issues,
150
151
  };
151
152
  }
@@ -1 +1,36 @@
1
+ import { type Scope } from '../../types.js';
2
+ type CheckStatus = 'pass' | 'fail';
3
+ type CheckScope = Scope | 'canvas';
4
+ /**
5
+ * Structured fix action for a failing check. Surfaced on each
6
+ * non-pass result so an agent reading doctor output can apply it directly
7
+ * when `--fix` is not used (or when --fix did not auto-apply). Every
8
+ * remediation includes the exact action and payload — no inference required.
9
+ */
10
+ interface Remediation {
11
+ kind: 'remove_config_key' | 'rm_path' | 'human_action';
12
+ description: string;
13
+ scope?: Scope;
14
+ configKey?: string;
15
+ path?: string;
16
+ command?: string;
17
+ }
18
+ interface CheckResult {
19
+ scope: CheckScope;
20
+ name: string;
21
+ status: CheckStatus;
22
+ message: string;
23
+ fixed?: boolean;
24
+ remediation?: Remediation;
25
+ }
26
+ /**
27
+ * Validate the command manifest + executable path of every effective command
28
+ * plugin (the deduped, enabled set that actually composes into the tree).
29
+ * Purely static — the plugin binary is NEVER executed, and no remediation ever
30
+ * chmods or rewrites third-party plugin content (every fix is a human_action:
31
+ * disable/update/remove the plugin). Only plugins whose scope is in `scopes`
32
+ * are reported, so `--scope` narrows these checks too.
33
+ */
34
+ export declare function runCommandPluginChecks(scopes: Scope[]): Promise<CheckResult[]>;
1
35
  export declare const sysDoctorLeaf: import("../../core/command.js").LeafDef;
36
+ export {};
@@ -14,6 +14,9 @@ import { readFault } from '../../core/runtime/fault.js';
14
14
  import { detectNerdFont, nerdFontInstallCommand, NERD_FONT_SELECT_HINT } from '../../core/runtime/nerd-font.js';
15
15
  import { detectPackageManager } from './setup-core.js';
16
16
  import { validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
17
+ import { adaptPluginContributions } from '../../core/command-plugins/compose.js';
18
+ import { resolveCommandRegistry } from '../../core/command-manifests/registry.js';
19
+ import { applyCommandExtensions, orphanCommandExtensionValidations } from '../../core/command-plugins/extensions.js';
17
20
  import { discoverHookRegistry } from '../../core/command-hooks/discovery.js';
18
21
  import { resolveBinContributions } from '../../core/runtime/bin-contributions.js';
19
22
  import { inspectHumanActions } from '../../core/human-actions.js';
@@ -116,7 +119,7 @@ function faultRemediation(fault, nodeId) {
116
119
  * disable/update/remove the plugin). Only plugins whose scope is in `scopes`
117
120
  * are reported, so `--scope` narrows these checks too.
118
121
  */
119
- async function runCommandPluginChecks(scopes) {
122
+ export async function runCommandPluginChecks(scopes) {
120
123
  const results = [];
121
124
  const inScope = new Set(scopes);
122
125
  const reserved = new Set(SUBTREE_NAMES);
@@ -124,7 +127,8 @@ async function runCommandPluginChecks(scopes) {
124
127
  // collisions surface here. A collision no longer costs anyone their command:
125
128
  // each claimant mounts under an origin-qualified name, so it is reported as a
126
129
  // healthy mount rather than a failure demanding the user remove a plugin.
127
- for (const v of validateEffectiveCommandPlugins(reserved, undefined, undefined, await coreCommandPaths())) {
130
+ const validations = validateEffectiveCommandPlugins(reserved, undefined, undefined, await coreCommandPaths());
131
+ for (const v of validations) {
128
132
  const plugin = v.plugin;
129
133
  if (!inScope.has(plugin.scope))
130
134
  continue;
@@ -143,6 +147,22 @@ async function runCommandPluginChecks(scopes) {
143
147
  results.push(failCheck(plugin.scope, `plugin:${plugin.name}:commands[${i}]`, `${issue.code}${detail}: ${issue.message} (received ${issue.received}, expected ${issue.expected})`, remediation));
144
148
  });
145
149
  }
150
+ if (inScope.has('project')) {
151
+ const registry = resolveCommandRegistry(adaptPluginContributions(validations.flatMap((validation) => validation.contributions)).contributions, reserved);
152
+ const extensions = applyCommandExtensions(registry.contributions);
153
+ const allExtensionChecks = [...extensions.validations, ...orphanCommandExtensionValidations(registry.contributions)];
154
+ for (const extension of allExtensionChecks) {
155
+ if (extension.issue === undefined) {
156
+ results.push(pass('project', `extension:${extension.branch}`, `repository fragment valid: ${extension.fragmentPath}`));
157
+ continue;
158
+ }
159
+ const issue = extension.issue;
160
+ results.push(failCheck('project', `extension:${extension.branch}`, `${issue.code} at ${issue.path ?? extension.fragmentPath}: ${issue.message} (received ${issue.received}, expected ${issue.expected})`, {
161
+ kind: 'human_action',
162
+ description: issue.next,
163
+ }));
164
+ }
165
+ }
146
166
  return results;
147
167
  }
148
168
  /** Statically inspect the complete effective hook registry. This shares the
@@ -29,7 +29,7 @@ import { execFileSync } from 'node:child_process';
29
29
  import { tmpdir } from 'node:os';
30
30
  import { join, dirname } from 'node:path';
31
31
  import { fileURLToPath } from 'node:url';
32
- import { discoverCommandContributions, effectiveCommandPlugins, validatePluginCommands, } from '../../command-plugins/discovery.js';
32
+ import { discoverCommandContributions, effectiveCommandPlugins, validatePluginCommands, buildExternalCommandSnapshot, } from '../../command-plugins/discovery.js';
33
33
  import { composeExternalSubtrees, adaptPluginContributions } from '../../command-plugins/compose.js';
34
34
  import { collectHelpAddenda } from '../../command-plugins/help-addenda.js';
35
35
  import { resolveCommandRegistry } from '../../command-manifests/registry.js';
@@ -38,9 +38,11 @@ import { renderRoot, renderBranch, renderLeafArgv } from '../../help.js';
38
38
  import { renderResult } from '../../render.js';
39
39
  import { CrtrError } from '../../errors.js';
40
40
  import { resetScopeCache } from '../../scope.js';
41
+ import { createProfile } from '../../profiles/manifest.js';
41
42
  import { listInstalledPluginsInRoot } from '../../resolver.js';
42
43
  import { pluginShow } from '../../../commands/pkg/plugin-inspect.js';
43
- import { pluginDisable, pluginEnable, pluginInstall } from '../../../commands/pkg/plugin-manage.js';
44
+ import { pluginDisable, pluginEnable, pluginInstall, pluginUpdate } from '../../../commands/pkg/plugin-manage.js';
45
+ import { runCommandPluginChecks } from '../../../commands/sys/doctor.js';
44
46
  // The set of reserved core names discovery must fail closed against. We don't
45
47
  // import the real SUBTREE_NAMES (build-root.ts pulls the whole tree); a small
46
48
  // explicit set is enough for the collision contract and keeps the test cheap.
@@ -112,6 +114,45 @@ function commandsJson(topName = 'app') {
112
114
  ],
113
115
  };
114
116
  }
117
+ function extensibleDemoCommandsJson(extensible = true) {
118
+ return {
119
+ schemaVersion: 1,
120
+ mounts: [{
121
+ parent: [],
122
+ node: {
123
+ kind: 'branch',
124
+ name: 'demo',
125
+ description: 'fixture extensible branch',
126
+ whenToUse: 'testing repository command contributions',
127
+ ...(extensible ? { extensible: true } : {}),
128
+ rootEntry: { concept: 'fixture command contributions', description: 'fixture extensible branch', whenToUse: 'testing repository command contributions' },
129
+ summary: 'fixture extensible branch',
130
+ children: [{ kind: 'branch', name: 'built-in', description: 'plugin-owned branch', whenToUse: 'using the plugin-owned command', summary: 'plugin-owned branch', children: [] }],
131
+ },
132
+ }],
133
+ };
134
+ }
135
+ function extensionFragment(rootName = 'repo-branch') {
136
+ return {
137
+ schemaVersion: 1,
138
+ transport: { kind: 'exec', executable: 'scripts/extension.js' },
139
+ mounts: [
140
+ { parent: [], node: { kind: 'branch', name: rootName, description: 'repository branch', whenToUse: 'using repository-defined commands', summary: 'repository branch', children: [] } },
141
+ { parent: [rootName], node: { kind: 'leaf', name: 'run', description: 'run repository operation', whenToUse: 'testing extension execution', summary: 'run repository operation', params: [{ kind: 'flag', name: 'thing', type: 'string', required: true, constraint: 'fixture input' }], output: [{ name: 'value', type: 'string', required: true, constraint: 'echoed fixture input' }], outputKind: 'object', effects: ['None. Read-only.'] } },
142
+ ],
143
+ };
144
+ }
145
+ const EXTENSION_EXEC = `#!/usr/bin/env node
146
+ const fs = require('fs');
147
+ let data = '';
148
+ process.stdin.on('data', (chunk) => data += chunk);
149
+ process.stdin.on('end', () => {
150
+ const request = JSON.parse(data);
151
+ if (process.env.EXTENSION_REQUEST_LOG) fs.writeFileSync(process.env.EXTENSION_REQUEST_LOG, JSON.stringify(request));
152
+ if (process.env.EXTENSION_SENTINEL) fs.writeFileSync(process.env.EXTENSION_SENTINEL, 'executed');
153
+ process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true, result: { value: request.input.thing } }));
154
+ });
155
+ `;
115
156
  function passthroughCommandsJson() {
116
157
  return {
117
158
  schemaVersion: 1,
@@ -210,6 +251,26 @@ after(() => {
210
251
  }
211
252
  resetScopeCache();
212
253
  });
254
+ function installExtensionFixture(manifest = extensionFragment()) {
255
+ installPlugin(userRoot, 'extension-fixture', { manifest: extensibleDemoCommandsJson() });
256
+ const commandsDir = join(projectRoot, 'commands');
257
+ const scriptsDir = join(projectDir, 'scripts');
258
+ mkdirSync(commandsDir, { recursive: true });
259
+ mkdirSync(scriptsDir, { recursive: true });
260
+ const fragmentPath = join(commandsDir, 'demo.json');
261
+ const executable = join(scriptsDir, 'extension.js');
262
+ writeFileSync(fragmentPath, JSON.stringify(manifest));
263
+ writeFileSync(executable, EXTENSION_EXEC);
264
+ chmodSync(executable, 0o755);
265
+ return { fragmentPath, executable };
266
+ }
267
+ function extensionBranch(startDir = projectDir, name = 'demo') {
268
+ resetScopeCache();
269
+ const snapshot = buildExternalCommandSnapshot(RESERVED, startDir);
270
+ const branch = composeExternalSubtrees(snapshot).find((node) => node.name === name);
271
+ assert.ok(branch, `expected the extensible ${name} branch`);
272
+ return branch;
273
+ }
213
274
  function leafOf(branch, name) {
214
275
  const c = branch.children.find((x) => x.name === name);
215
276
  assert.ok(c !== undefined && c.kind === 'leaf', `expected leaf ${name}`);
@@ -384,6 +445,260 @@ describe('end-to-end CLI invocation', () => {
384
445
  assert.equal(obj.status, 'running');
385
446
  });
386
447
  });
448
+ // Extensible plugin command branches — repository fragments attach to a marked
449
+ // top-level branch as one native forest and stay entirely inside the existing
450
+ // parser/help/exec contract.
451
+ describe('extensible plugin command branches', () => {
452
+ test('merges native help, executes with extension context, and mirrors JSON', async () => {
453
+ const { fragmentPath } = installExtensionFixture();
454
+ const branch = extensionBranch();
455
+ const help = renderBranch(branch.help);
456
+ assert.match(help, /<command name="demo"/);
457
+ assert.match(help, /<subcommand name="built-in"/);
458
+ assert.match(help, /<subcommand name="repo-branch"[^>]*subcommands="1"/);
459
+ assert.ok(help.indexOf('name="built-in"') < help.indexOf('name="repo-branch"'));
460
+ assert.equal((help.match(/<command name=/g) ?? []).length, 1);
461
+ const repoBranch = branch.children.find((child) => child.name === 'repo-branch');
462
+ assert.ok(repoBranch !== undefined && repoBranch.kind === 'branch');
463
+ const leaf = leafOf(repoBranch, 'run');
464
+ const leafHelp = renderLeafArgv(leaf.help);
465
+ assert.match(leafHelp, /^demo repo-branch run: run repository operation\./);
466
+ assert.match(leafHelp, /Input/);
467
+ assert.match(leafHelp, /Output \(fields carried in the rendered result\)/);
468
+ assert.match(leafHelp, /Effects/);
469
+ const requestLog = join(projectDir, 'extension-request.json');
470
+ process.env['EXTENSION_REQUEST_LOG'] = requestLog;
471
+ const priorCwd = process.cwd();
472
+ process.chdir(projectDir);
473
+ try {
474
+ assert.deepEqual(await leaf.run({ thing: 'x' }), { value: 'x' });
475
+ }
476
+ finally {
477
+ process.chdir(priorCwd);
478
+ delete process.env['EXTENSION_REQUEST_LOG'];
479
+ }
480
+ const request = JSON.parse(readFileSync(requestLog, 'utf8'));
481
+ assert.deepEqual(request.command, ['demo', 'repo-branch', 'run']);
482
+ assert.deepEqual(request.input, { thing: 'x' });
483
+ assert.deepEqual(request.context.extension, { branch: 'demo', root: projectDir });
484
+ assert.equal(request.context.plugin, undefined);
485
+ const root = defineRoot({ tagline: 'test', globals: [], subtrees: [branch] });
486
+ assert.match(renderRoot(root.help), /\[\+2 subcommands — `crtr demo -h`\]/);
487
+ assert.equal(existsSync(join(projectDir, 'EXECUTED')), false);
488
+ assert.ok(existsSync(fragmentPath));
489
+ });
490
+ test('uses a collision-qualified branch name for fragment discovery, doctor, and exec context', async () => {
491
+ installPlugin(userRoot, 'first', { manifest: extensibleDemoCommandsJson() });
492
+ installPlugin(userRoot, 'second', { manifest: extensibleDemoCommandsJson() });
493
+ const commandsDir = join(projectRoot, 'commands');
494
+ const scriptsDir = join(projectDir, 'scripts');
495
+ mkdirSync(commandsDir, { recursive: true });
496
+ mkdirSync(scriptsDir, { recursive: true });
497
+ writeFileSync(join(commandsDir, 'first:demo.json'), JSON.stringify(extensionFragment()));
498
+ writeFileSync(join(scriptsDir, 'extension.js'), EXTENSION_EXEC);
499
+ chmodSync(join(scriptsDir, 'extension.js'), 0o755);
500
+ const first = extensionBranch(projectDir, 'first:demo');
501
+ const second = extensionBranch(projectDir, 'second:demo');
502
+ assert.ok(first.children.some((child) => child.name === 'repo-branch'));
503
+ assert.ok(!second.children.some((child) => child.name === 'repo-branch'));
504
+ const repoBranch = first.children.find((child) => child.name === 'repo-branch');
505
+ assert.ok(repoBranch !== undefined && repoBranch.kind === 'branch');
506
+ const requestLog = join(projectDir, 'collision-extension-request.json');
507
+ process.env['EXTENSION_REQUEST_LOG'] = requestLog;
508
+ const priorCwd = process.cwd();
509
+ process.chdir(projectDir);
510
+ try {
511
+ assert.deepEqual(await leafOf(repoBranch, 'run').run({ thing: 'x' }), { value: 'x' });
512
+ const checks = await runCommandPluginChecks(['project']);
513
+ const extension = checks.find((check) => check.name === 'extension:first:demo');
514
+ assert.ok(extension, JSON.stringify(checks));
515
+ assert.equal(extension.status, 'pass');
516
+ }
517
+ finally {
518
+ process.chdir(priorCwd);
519
+ delete process.env['EXTENSION_REQUEST_LOG'];
520
+ }
521
+ const request = JSON.parse(readFileSync(requestLog, 'utf8'));
522
+ assert.deepEqual(request.command, ['first:demo', 'repo-branch', 'run']);
523
+ assert.deepEqual(request.context.extension, { branch: 'first:demo', root: projectDir });
524
+ assert.equal(request.context.plugin, undefined);
525
+ });
526
+ test('runs through the real CLI with native help and --json output', { timeout: 40000 }, () => {
527
+ installExtensionFixture();
528
+ const cli = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', 'dist', 'cli.js');
529
+ const run = (args) => execFileSync(process.execPath, [cli, ...args], {
530
+ cwd: projectDir,
531
+ env: { ...process.env, HOME: home, CRTR_FRONT_DOOR: '' },
532
+ encoding: 'utf8',
533
+ });
534
+ const branchHelp = run(['demo', '-h']);
535
+ assert.match(branchHelp, /<command name="demo"/);
536
+ assert.match(branchHelp, /<subcommand name="built-in"/);
537
+ assert.match(branchHelp, /<subcommand name="repo-branch"/);
538
+ assert.deepEqual(JSON.parse(run(['demo', 'repo-branch', 'run', '--thing', 'x', '--json'])), { value: 'x' });
539
+ });
540
+ test('discovers per invocation, scopes by cwd, and nearest root wins', () => {
541
+ const { fragmentPath } = installExtensionFixture();
542
+ assert.ok(renderBranch(extensionBranch().help).includes('repo-branch'));
543
+ writeFileSync(fragmentPath, JSON.stringify(extensionFragment('changed')));
544
+ assert.ok(renderBranch(extensionBranch().help).includes('name="changed"'));
545
+ rmSync(fragmentPath);
546
+ assert.ok(!renderBranch(extensionBranch().help).includes('repo-branch'));
547
+ writeFileSync(fragmentPath, JSON.stringify(extensionFragment('outer')));
548
+ const inner = join(projectDir, 'nested');
549
+ const innerScope = join(inner, '.crouter', 'commands');
550
+ mkdirSync(innerScope, { recursive: true });
551
+ mkdirSync(join(inner, 'scripts'), { recursive: true });
552
+ writeFileSync(join(inner, 'scripts', 'extension.js'), EXTENSION_EXEC);
553
+ chmodSync(join(inner, 'scripts', 'extension.js'), 0o755);
554
+ writeFileSync(join(innerScope, 'demo.json'), JSON.stringify(extensionFragment('inner')));
555
+ assert.ok(renderBranch(extensionBranch(inner).help).includes('name="inner"'));
556
+ assert.ok(!renderBranch(extensionBranch(inner).help).includes('name="outer"'));
557
+ assert.ok(!renderBranch(extensionBranch(emptyStart).help).includes('name="outer"'));
558
+ });
559
+ test('does not discover fragments from selected-profile projects outside the cwd chain', () => {
560
+ installPlugin(userRoot, 'extension-fixture', { manifest: extensibleDemoCommandsJson() });
561
+ const profileProject = mintDir('crtr-cmdplugin-profile-project-');
562
+ const fragmentDir = join(profileProject, '.crouter', 'commands');
563
+ const scriptDir = join(profileProject, 'scripts');
564
+ mkdirSync(fragmentDir, { recursive: true });
565
+ mkdirSync(scriptDir, { recursive: true });
566
+ writeFileSync(join(fragmentDir, 'demo.json'), JSON.stringify(extensionFragment()));
567
+ writeFileSync(join(scriptDir, 'extension.js'), EXTENSION_EXEC);
568
+ chmodSync(join(scriptDir, 'extension.js'), 0o755);
569
+ process.env['CRTR_PROFILE_ID'] = createProfile('extension-fragment-scope', [{ path: profileProject, memory: 'content' }]).profileId;
570
+ resetScopeCache();
571
+ assert.ok(!renderBranch(extensionBranch(emptyStart).help).includes('repo-branch'));
572
+ });
573
+ test('rejects absolute executable paths and identifies the offending fragment node', async () => {
574
+ const { fragmentPath, executable } = installExtensionFixture();
575
+ const fragment = extensionFragment();
576
+ fragment.transport.executable = executable;
577
+ writeFileSync(fragmentPath, JSON.stringify(fragment));
578
+ const help = renderBranch(extensionBranch().help);
579
+ assert.match(help, /<extension-issue/);
580
+ assert.ok(!help.includes('repo-branch'));
581
+ const priorCwd = process.cwd();
582
+ process.chdir(projectDir);
583
+ try {
584
+ const checks = await runCommandPluginChecks(['project']);
585
+ const check = checks.find((candidate) => candidate.name === 'extension:demo');
586
+ assert.ok(check, JSON.stringify(checks));
587
+ assert.equal(check.status, 'fail');
588
+ assert.match(check.message, new RegExp(`${fragmentPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:transport\\.executable`));
589
+ }
590
+ finally {
591
+ process.chdir(priorCwd);
592
+ }
593
+ });
594
+ test('rejects the complete fragment with scoped help notice and doctor remediation', async () => {
595
+ const { fragmentPath } = installExtensionFixture();
596
+ const bad = extensionFragment();
597
+ bad['unknown'] = true;
598
+ writeFileSync(fragmentPath, JSON.stringify(bad));
599
+ const help = renderBranch(extensionBranch().help);
600
+ assert.match(help, /name="built-in"/);
601
+ assert.ok(!help.includes('repo-branch'));
602
+ assert.match(help, new RegExp(`<extension-issue fragment="${fragmentPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
603
+ assert.match(help, /unknown top-level keys/);
604
+ const sentinel = join(projectDir, 'extension-executed');
605
+ process.env['EXTENSION_SENTINEL'] = sentinel;
606
+ const priorCwd = process.cwd();
607
+ process.chdir(projectDir);
608
+ try {
609
+ const checks = await runCommandPluginChecks(['project']);
610
+ const check = checks.find((candidate) => candidate.name === 'extension:demo');
611
+ assert.ok(check, JSON.stringify(checks));
612
+ assert.equal(check.status, 'fail');
613
+ assert.match(check.message, /command_extension_invalid/);
614
+ assert.match(check.message, new RegExp(fragmentPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
615
+ assert.match(check.remediation.description, /regenerate/i);
616
+ }
617
+ finally {
618
+ process.chdir(priorCwd);
619
+ delete process.env['EXTENSION_SENTINEL'];
620
+ }
621
+ assert.equal(existsSync(sentinel), false);
622
+ });
623
+ test('requires the marker, rejects passthrough/rootEntry/conflicts, and leaves unmarked passthrough inert', async () => {
624
+ const { fragmentPath } = installExtensionFixture();
625
+ writeFileSync(join(userRoot, 'plugins', 'extension-fixture', 'commands.json'), JSON.stringify(extensibleDemoCommandsJson(false)));
626
+ assert.ok(!renderBranch(extensionBranch().help).includes('repo-branch'));
627
+ const priorCwd = process.cwd();
628
+ process.chdir(projectDir);
629
+ try {
630
+ const checks = await runCommandPluginChecks(['project']);
631
+ const orphan = checks.find((candidate) => candidate.name === 'extension:demo');
632
+ assert.ok(orphan, JSON.stringify(checks));
633
+ assert.equal(orphan.status, 'fail');
634
+ assert.match(orphan.message, /no effective plugin branch is extensible/);
635
+ }
636
+ finally {
637
+ process.chdir(priorCwd);
638
+ }
639
+ writeFileSync(join(userRoot, 'plugins', 'extension-fixture', 'commands.json'), JSON.stringify(extensibleDemoCommandsJson()));
640
+ const variants = [
641
+ ['passthrough', (fragment) => { fragment['mounts'][0]['node']['passthrough'] = { bin: 'x', installHint: 'x' }; }],
642
+ ['rootEntry', (fragment) => { fragment['mounts'][0]['node']['rootEntry'] = { concept: 'x', description: 'x', whenToUse: 'x' }; }],
643
+ ];
644
+ for (const [name, mutate] of variants) {
645
+ const fragment = extensionFragment();
646
+ mutate(fragment);
647
+ writeFileSync(fragmentPath, JSON.stringify(fragment));
648
+ const help = renderBranch(extensionBranch().help);
649
+ assert.match(help, /<extension-issue/);
650
+ assert.ok(!help.includes('repo-branch'), name);
651
+ }
652
+ writeFileSync(fragmentPath, JSON.stringify(extensionFragment('built-in')));
653
+ const conflictHelp = renderBranch(extensionBranch().help);
654
+ assert.match(conflictHelp, /<extension-issue/);
655
+ assert.match(conflictHelp, /conflicts with a child/);
656
+ installPlugin(userRoot, 'passthrough-fixture', { manifest: passthroughCommandsJson() });
657
+ writeFileSync(join(projectRoot, 'commands', 'capture.json'), JSON.stringify(extensionFragment()));
658
+ resetScopeCache();
659
+ const snapshot = buildExternalCommandSnapshot(RESERVED, projectDir);
660
+ const capture = composeExternalSubtrees(snapshot).find((branch) => branch.name === 'capture');
661
+ assert.ok(capture?.passthrough);
662
+ assert.equal(capture.children.length, 0);
663
+ });
664
+ test('install and update validate the marker but never inspect repository fragments', async () => {
665
+ const source = join(mintDir('crtr-extensible-source-'), 'fixture');
666
+ mkdirSync(join(source, '.crouter-plugin'), { recursive: true });
667
+ mkdirSync(join(source, 'bin'), { recursive: true });
668
+ const manifest = extensibleDemoCommandsJson();
669
+ manifest.mounts[0].node['name'] = 'install-demo';
670
+ const rootEntry = manifest.mounts[0].node['rootEntry'];
671
+ rootEntry['concept'] = 'install fixture';
672
+ writeFileSync(join(source, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: 'install-extension-fixture', version: '0.1.0', description: 'fixture', commands: 'commands.json', transport: { kind: 'exec', executable: 'bin/cmd.js' } }));
673
+ writeFileSync(join(source, 'commands.json'), JSON.stringify(manifest));
674
+ writeFileSync(join(source, 'bin', 'cmd.js'), EXTENSION_EXEC);
675
+ chmodSync(join(source, 'bin', 'cmd.js'), 0o755);
676
+ mkdirSync(join(projectRoot, 'commands'), { recursive: true });
677
+ writeFileSync(join(projectRoot, 'commands', 'install-demo.json'), JSON.stringify({ unknown: true }));
678
+ await pluginInstall.run({ installRef: source, scope: 'user' });
679
+ (manifest.mounts[0].node['children'][0]['extensible'] = true);
680
+ writeFileSync(join(source, 'commands.json'), JSON.stringify(manifest));
681
+ await assert.rejects(pluginUpdate.run({ name: 'install-extension-fixture' }), /extensible is valid only on a top-level branch mount/);
682
+ });
683
+ test('plugin inspection identifies extensible branches without executing the fragment executable', async () => {
684
+ const { executable } = installExtensionFixture();
685
+ const sentinel = join(projectDir, 'inspect-executed');
686
+ process.env['EXTENSION_SENTINEL'] = sentinel;
687
+ const priorCwd = process.cwd();
688
+ process.chdir(projectDir);
689
+ try {
690
+ const shown = await pluginShow.run({ name: 'extension-fixture' });
691
+ const commands = shown['commands'];
692
+ assert.deepEqual(commands.extensibleBranches, ['demo']);
693
+ }
694
+ finally {
695
+ process.chdir(priorCwd);
696
+ delete process.env['EXTENSION_SENTINEL'];
697
+ }
698
+ assert.equal(existsSync(sentinel), false);
699
+ assert.ok(existsSync(executable));
700
+ });
701
+ });
387
702
  // 5. Lifecycle without restart (per-invocation discovery)
388
703
  describe('lifecycle without restart', () => {
389
704
  test('install → appears; disable → gone; edit → visible; remove → gone', () => {
@@ -1,10 +1,10 @@
1
- import type { DeclLeaf, TransportKind, DeclBranch, ManifestTimeouts, CommandManifestIssue } from './schema.js';
1
+ import type { DeclLeaf, DeclNode, TransportKind, ManifestTimeouts, CommandManifestIssue } from './schema.js';
2
2
  export interface ValidatedCommandManifest {
3
3
  schemaVersion: 1;
4
4
  baseUrl?: string;
5
5
  timeouts?: ManifestTimeouts;
6
- /** Top-level branches, each ready to mount at parent []. */
7
- roots: DeclBranch<DeclLeaf>[];
6
+ /** Top-level plugin branches or repository-fragment nodes, ready to mount. */
7
+ roots: DeclNode<DeclLeaf>[];
8
8
  /** Core command path (space-joined, e.g. "cron add") → attributed addendum
9
9
  * text appended beneath that core command's help. Append-only product
10
10
  * guidance — a plugin can never alter core contract text. */
@@ -32,4 +32,6 @@ export declare function validateCommandManifest(raw: unknown, options: {
32
32
  * and ingress report can never disagree. Omitted only on the tolerant
33
33
  * compose path, where mounting ignores addenda entirely. */
34
34
  coreCommandPaths?: ReadonlySet<string>;
35
+ /** A repository fragment attaches roots below an existing extensible branch. */
36
+ extensionFragment?: boolean;
35
37
  }): CommandManifestValidation;
@@ -33,17 +33,19 @@ export function validateCommandManifest(raw, options) {
33
33
  };
34
34
  // Top-level structure check
35
35
  if (!isRecord(raw)) {
36
- issue('command_manifest_invalid', 'manifest must be an object', typeName(raw), options.transport === 'http' ? '{ schemaVersion, baseUrl?, timeouts?, mounts, helpAddenda? }' : '{ schemaVersion, mounts, helpAddenda? }', 'Provide a valid JSON manifest object.');
36
+ issue('command_manifest_invalid', 'manifest must be an object', typeName(raw), options.extensionFragment === true ? '{ schemaVersion, transport, mounts }' : options.transport === 'http' ? '{ schemaVersion, baseUrl?, timeouts?, mounts, helpAddenda? }' : '{ schemaVersion, mounts, helpAddenda? }', 'Provide a valid JSON manifest object.');
37
37
  return { issues };
38
38
  }
39
39
  // Check for unknown top-level keys
40
40
  const topKeys = Object.keys(raw);
41
- const allowedTopKeys = options.transport === 'http'
42
- ? new Set(['schemaVersion', 'baseUrl', 'timeouts', 'mounts', 'helpAddenda'])
43
- : new Set(['schemaVersion', 'mounts', 'helpAddenda']);
41
+ const allowedTopKeys = options.extensionFragment === true
42
+ ? new Set(['schemaVersion', 'transport', 'mounts'])
43
+ : options.transport === 'http'
44
+ ? new Set(['schemaVersion', 'baseUrl', 'timeouts', 'mounts', 'helpAddenda'])
45
+ : new Set(['schemaVersion', 'mounts', 'helpAddenda']);
44
46
  const unknownKeys = topKeys.filter((k) => !allowedTopKeys.has(k));
45
47
  if (unknownKeys.length > 0) {
46
- issue('command_manifest_invalid', `unknown top-level keys`, unknownKeys.join(', '), options.transport === 'http' ? 'only: schemaVersion, baseUrl, timeouts, mounts, helpAddenda' : 'only: schemaVersion, mounts, helpAddenda', 'Remove the unknown keys.');
48
+ issue('command_manifest_invalid', `unknown top-level keys`, unknownKeys.join(', '), options.extensionFragment === true ? 'only: schemaVersion, transport, mounts' : options.transport === 'http' ? 'only: schemaVersion, baseUrl, timeouts, mounts, helpAddenda' : 'only: schemaVersion, mounts, helpAddenda', 'Remove the unknown keys.');
47
49
  return { issues };
48
50
  }
49
51
  // Validate schemaVersion
@@ -101,7 +103,7 @@ export function validateCommandManifest(raw, options) {
101
103
  // Validate and collect all mounts
102
104
  const validatedMounts = [];
103
105
  for (let i = 0; i < mounts.length; i++) {
104
- const m = validateMount(mounts[i], i, options.transport, issue);
106
+ const m = validateMount(mounts[i], i, options.transport, issue, options.extensionFragment === true ? { topLevelRootEntry: 'forbidden', allowPassthrough: false, allowExtensible: false } : undefined);
105
107
  if (m === null)
106
108
  return { issues };
107
109
  validatedMounts.push(m);
@@ -179,7 +181,7 @@ function validateHelpAddenda(raw, coreCommandPaths, issue) {
179
181
  }
180
182
  return out;
181
183
  }
182
- function validateMount(raw, index, transport, issue) {
184
+ function validateMount(raw, index, transport, issue, nodeOptions) {
183
185
  const path = `mounts[${index}]`;
184
186
  if (!isRecord(raw)) {
185
187
  issue('command_manifest_invalid', 'mount must be an object', typeName(raw), '{ parent, node }', 'Fix the mount.', path);
@@ -208,17 +210,10 @@ function validateMount(raw, index, transport, issue) {
208
210
  // Validate node
209
211
  const nodeRaw = raw['node'];
210
212
  const topLevel = parent.length === 0;
211
- const node = validateCommandNode(nodeRaw, [`${path}.node`], topLevel, transport, issue);
213
+ const node = validateCommandNode(nodeRaw, [`${path}.node`], topLevel, transport, issue, nodeOptions);
212
214
  if (node === null)
213
215
  return null;
214
- // Top-level mount must be a branch with rootEntry
215
- if (topLevel && node.kind !== 'branch') {
216
- issue('command_node_invalid', 'top-level mount node must be a branch', node.kind, 'a branch with a rootEntry', 'Wrap the command in a top-level branch.', `${path}.node.kind`);
217
- return null;
218
- }
219
- // Ensure the node is properly typed (should only be DeclLeaf at this point)
220
- const typedNode = node;
221
- return { parent, node: typedNode };
216
+ return { parent, node };
222
217
  }
223
218
  // Forest materialization (order-independent)
224
219
  /**
@@ -236,19 +231,10 @@ function materializeForest(mounts, reservedCoreNames, issue) {
236
231
  return null;
237
232
  }
238
233
  }
239
- // Build the forest: all top-level nodes with inline children, then attach nested mounts
240
- const roots = topLevel.map((m) => ({
241
- kind: 'branch',
242
- name: m.node.name,
243
- description: m.node.description,
244
- whenToUse: m.node.whenToUse,
245
- ...(m.node.tier !== undefined ? { tier: m.node.tier } : {}),
246
- ...(m.node.rootEntry !== undefined ? { rootEntry: m.node.rootEntry } : {}),
247
- summary: m.node.summary,
248
- ...(m.node.model !== undefined ? { model: m.node.model } : {}),
249
- ...(m.node.passthrough !== undefined ? { passthrough: m.node.passthrough } : {}),
250
- children: [...m.node.children],
251
- }));
234
+ // Build the forest: all top-level nodes with inline children, then attach nested mounts.
235
+ const roots = topLevel.map((m) => m.node.kind === 'branch'
236
+ ? { ...m.node, children: [...m.node.children] }
237
+ : { ...m.node });
252
238
  // Attach nested mounts
253
239
  for (const mount of nested) {
254
240
  const resolved = resolveAndAttachMount(roots, mount, issue);
@@ -270,8 +256,7 @@ function materializeForest(mounts, reservedCoreNames, issue) {
270
256
  return true;
271
257
  };
272
258
  for (const root of roots) {
273
- const child = root;
274
- if (!indexPaths(child, [root.name]))
259
+ if (!indexPaths(root, [root.name]))
275
260
  return null;
276
261
  }
277
262
  return roots;
@@ -287,6 +272,10 @@ function resolveAndAttachMount(roots, mount, issue) {
287
272
  issue('command_parent_invalid', `parent path starts with unknown branch`, first, 'a branch this manifest contributes', 'Fix the parent path or add the parent branch.', mount.parent.join('.'));
288
273
  return false;
289
274
  }
275
+ if (root.kind === 'leaf') {
276
+ issue('command_parent_invalid', 'parent path resolves to a leaf', first, 'a branch', 'Change the parent to point to a branch.', mount.parent.join('.'));
277
+ return false;
278
+ }
290
279
  let current = root;
291
280
  for (const token of rest) {
292
281
  const child = current.children.find((c) => c.name === token);
@@ -1,5 +1,6 @@
1
1
  import type { LeafDef } from '../command.js';
2
2
  import type { DeclLeafBase, DeclBranch, CommandManifestIssue } from './schema.js';
3
+ import type { ExtensionIssueNotice } from '../command-plugins/extensions.js';
3
4
  /** Reference to an external contributor (plugin or HTTP-transport plugin). */
4
5
  export type CommandContributorRef = {
5
6
  name: string;
@@ -27,6 +28,8 @@ export interface CommandContribution {
27
28
  /** Root name from the manifest, before collision qualification. */
28
29
  manifestName: string;
29
30
  adaptLeaf: LeafAdapter;
31
+ /** Present only on an extensible branch whose nearest repository fragment was rejected. */
32
+ extensionIssue?: ExtensionIssueNotice;
30
33
  }
31
34
  /** Per-contributor collision issue. */
32
35
  export interface ContributorIssue extends CommandManifestIssue {
@@ -12,6 +12,8 @@ export interface DeclBranch<L = DeclLeafBase> {
12
12
  tier?: 'normal' | 'common' | 'important';
13
13
  /** Required on a top-level branch, forbidden on a nested one. */
14
14
  rootEntry?: DeclRootEntry;
15
+ /** Allows the nearest repository fragment to contribute children below this top-level branch. */
16
+ extensible?: true;
15
17
  summary: string;
16
18
  model?: string;
17
19
  /** Exec transport only: forward every argv token after this branch to an
@@ -78,8 +80,16 @@ export interface CommandManifestIssue {
78
80
  expected: string;
79
81
  next: string;
80
82
  }
81
- export type CommandIssueCode = 'command_manifest_unreadable' | 'command_manifest_invalid' | 'command_schema_version' | 'command_path_unsafe' | 'command_not_executable' | 'command_parent_invalid' | 'command_node_invalid' | 'command_collision' | 'command_help_addendum_invalid' | 'command_rest_invalid';
83
+ export type CommandIssueCode = 'command_manifest_unreadable' | 'command_manifest_invalid' | 'command_schema_version' | 'command_path_unsafe' | 'command_not_executable' | 'command_parent_invalid' | 'command_node_invalid' | 'command_collision' | 'command_help_addendum_invalid' | 'command_rest_invalid' | 'command_extension_invalid';
82
84
  type IssueFn = (code: CommandIssueCode, message: string, received: string, expected: string, next: string, path?: string) => void;
83
85
  export type TransportKind = 'exec' | 'http';
84
- export declare function validateCommandNode(raw: unknown, path: string[], topLevel: boolean, transport: TransportKind, issue: IssueFn): DeclBranch<DeclLeaf> | DeclLeaf | null;
86
+ export interface CommandNodeValidationOptions {
87
+ /** Plugin roots need rootEntry; repository-fragment roots attach under an existing branch and forbid it. */
88
+ topLevelRootEntry?: 'required' | 'forbidden';
89
+ /** Repository fragments have no passthrough escape hatch. */
90
+ allowPassthrough?: boolean;
91
+ /** Only plugin manifests may mark their own top-level branch extensible. */
92
+ allowExtensible?: boolean;
93
+ }
94
+ export declare function validateCommandNode(raw: unknown, path: string[], topLevel: boolean, transport: TransportKind, issue: IssueFn, options?: CommandNodeValidationOptions): DeclBranch<DeclLeaf> | DeclLeaf | null;
85
95
  export {};
@@ -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.261",
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.261",
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.261",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {