burgee 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
  <p align="center">
15
15
  <a href="https://www.npmjs.com/package/burgee"><img src="https://img.shields.io/npm/v/burgee?style=flat-square&color=0a6b47" alt="npm version" /></a>
16
16
  <a href="https://www.npmjs.com/package/burgee"><img src="https://img.shields.io/npm/dm/burgee?style=flat-square" alt="npm downloads" /></a>
17
- <img src="https://img.shields.io/badge/runtime%20dependencies-0-0a6b47?style=flat-square" alt="Zero runtime dependencies" />
17
+ <img src="https://img.shields.io/badge/dependencies-5%20in--family-0a6b47?style=flat-square" alt="Five dependencies, all in this repository: bellpull, closeout, linegauge, roundel, seniority" />
18
18
  <img src="https://img.shields.io/badge/Node.js-24+-green.svg?style=flat-square" alt="Node.js 24+" />
19
19
  <img src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square" alt="License: MIT" />
20
20
  </p>
@@ -62,7 +62,7 @@ that on every commit.
62
62
  ```text
63
63
  defineCommand() ──▶ manifest ──┬──▶ human help
64
64
  ├──▶ --json one stable envelope
65
- ├──▶ --schema versioned, JSON-Schema validated
65
+ ├──▶ --schema versioned, one document per surface
66
66
  ├──▶ --mcp an MCP server, generated
67
67
  ├──▶ completions bash · zsh · fish · pwsh
68
68
  ├──▶ TypeScript types
@@ -92,6 +92,25 @@ host's own suite passes 100%; below that the rate is published instead of claime
92
92
  | `burgee/commander` | The commander API, graded by commander's suite. |
93
93
  | `burgee/testing` | Run a command in-process and assert on its result — no spawning. |
94
94
  | `burgee/brand` | One brand declaration → flag, favicon, OG card, cover, lockup. The logo above is its own output. |
95
+ | `burgee/plugin` | `definePlugin()`, `validate()`, `CONTRACT` and `PluginError` — the host, at the subpath every package in the family publishes its host at. |
96
+
97
+ ## Writing a plugin
98
+
99
+ ```ts
100
+ import { definePlugin } from 'burgee/plugin';
101
+
102
+ export default definePlugin({
103
+ name: 'acme',
104
+ commands: [{ path: ['audit'], description: 'Audit the tree', options: {}, effects: 'read_only', run: () => ({ findings: 0 }) }],
105
+ });
106
+ ```
107
+
108
+ A plugin's command is read by exactly the code a first-party one is read by, so the same
109
+ refusals apply: reserved option names, duplicate flags, a contributed path that is already
110
+ declared — and `effects`, which every runnable command declares. `definePlugin` stamps the
111
+ `contract` this burgee was compiled against; an object that reaches `use()` without one is
112
+ refused rather than accepted on trust, because burgee's extension point shipped before it
113
+ validated anything.
95
114
 
96
115
  ## Status
97
116
 
@@ -112,3 +131,19 @@ what it is, [roundel](https://www.npmjs.com/package/roundel) carries its colours
112
131
  none requires the others.
113
132
 
114
133
  MIT © Ofri Peretz — see [LICENSE](./LICENSE).
134
+
135
+ ## Benchmarks
136
+
137
+ Every number here is produced by `npm run bench` and published at [/docs/benchmarks](/docs/benchmarks).
138
+
139
+ Graded by the incumbent's own test suite:
140
+
141
+ | suite | passing |
142
+ | :-- | --: |
143
+ | `commander` | 1360 / 1360 |
144
+ | `yargs` | 804 / 804 |
145
+ ## Where it sits
146
+
147
+ Plugins register under the `commands` and `hooks` keys, against the one schema the whole family shares.
148
+
149
+ Nothing in this family builds on it yet, and it builds on `bellpull`, `closeout`, `linegauge`, `roundel`, `seniority`.
package/dist/cli.js CHANGED
@@ -21,6 +21,7 @@ function surfaces(brand, tagline) {
21
21
  export const brandCommand = defineCommand({
22
22
  name: 'brand',
23
23
  description: 'Generate a burgee — flag, favicon, social card, cover and lockup — from two colours',
24
+ effects: 'non_idempotent',
24
25
  options: {
25
26
  lead: {
26
27
  type: 'string',
@@ -104,6 +105,7 @@ export const devCommand = defineCommand({
104
105
  options: {
105
106
  'no-watch': { type: 'boolean', description: 'load once and serve; do not watch for changes' },
106
107
  },
108
+ effects: 'withheld',
107
109
  run: async ({ positionals, options }) => {
108
110
  const [entry] = positionals;
109
111
  if (entry === undefined)
@@ -1,3 +1,4 @@
1
+ import { EventEmitter } from 'node:events';
1
2
  /**
2
3
  * commander's `Command`, ported method for method from commander 15 and graded by
3
4
  * commander's own suite through `compat-oracle`. The parse pipeline, the option
@@ -12,9 +13,8 @@
12
13
  * - `parse(argv, { stdout, stderr, exit })` injects the streams and the exit, and
13
14
  * then reports through the E1 taxonomy — the harness's seam (T1).
14
15
  */
15
- import childProcess from 'node:child_process';
16
- import { EventEmitter } from 'node:events';
17
- import { type Effects, Manifest, type OptionSpec, type Plugin } from '../manifest.js';
16
+ import * as crossSpawn from 'bellpull/cross-spawn';
17
+ import { type DeclaredEffects, Manifest, type OptionSpec, type Plugin } from '../manifest.js';
18
18
  import { Argument, type ParseArg } from './argument.js';
19
19
  import { CommanderError } from './error.js';
20
20
  import { Help } from './help.js';
@@ -85,7 +85,7 @@ export declare class Command extends EventEmitter {
85
85
  rawArgs: string[];
86
86
  /** like .args but after custom processing and collecting variadic */
87
87
  processedArgs: unknown[];
88
- runningCommand: childProcess.ChildProcess | undefined;
88
+ runningCommand: crossSpawn.ChildProcess | undefined;
89
89
  _allowUnknownOption: boolean;
90
90
  _allowExcessArguments: boolean;
91
91
  _scriptPath: string | null;
@@ -126,7 +126,7 @@ export declare class Command extends EventEmitter {
126
126
  /** burgee: the root's projection, created on first use. */
127
127
  _manifest: Manifest | undefined;
128
128
  /** burgee: what this command does to the world (N6); declaring it exposes the command as an MCP tool. */
129
- _effects: Effects | undefined;
129
+ _effects: DeclaredEffects | undefined;
130
130
  /** burgee: `true`, or the replacement's name (M5). Shown in help, schema and a one-line warning on use. */
131
131
  _deprecated: boolean | string | undefined;
132
132
  _deprecationWarned: boolean;
@@ -315,7 +315,7 @@ export declare class Command extends EventEmitter {
315
315
  /** Options as the manifest describes them, on a null-prototype record. */
316
316
  _optionSpecs(): Record<string, OptionSpec>;
317
317
  /** burgee: declare what the command does to the world (N6). This is what exposes it as an MCP tool (N2). */
318
- effects(value: Effects): this;
318
+ effects(value: DeclaredEffects): this;
319
319
  /**
320
320
  * burgee: mark the command deprecated (M5). Help and `--schema` show it; running it prints
321
321
  * `warning: 'old' is deprecated, use 'new'` on stderr once and goes on, exit unchanged.
@@ -1,3 +1,7 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { stripVTControlCharacters } from 'node:util';
1
5
  /**
2
6
  * commander's `Command`, ported method for method from commander 15 and graded by
3
7
  * commander's own suite through `compat-oracle`. The parse pipeline, the option
@@ -12,15 +16,12 @@
12
16
  * - `parse(argv, { stdout, stderr, exit })` injects the streams and the exit, and
13
17
  * then reports through the E1 taxonomy — the harness's seam (T1).
14
18
  */
15
- import childProcess from 'node:child_process';
16
- import { EventEmitter } from 'node:events';
17
- import fs from 'node:fs';
18
- import path from 'node:path';
19
- import process from 'node:process';
20
- import { stripVTControlCharacters } from 'node:util';
19
+ // eslint-disable-next-line import-next/no-namespace -- `spawn` is read off the namespace at the call site and never captured into a local. commander's own suite mocks `childProcess.spawn` in roughly 23 `executableSubcommand` cases, and a binding captured at import never re-syncs; bellpull's `cross-spawn.ts` reads `spawn` off its default import for this exact consumer, so a named import here would undo that and take the row from 1360 / 1360 to ungradeable.
20
+ import * as crossSpawn from 'bellpull/cross-spawn';
21
21
  import { ExitCode } from '../exit-code.js';
22
22
  import { Manifest } from '../manifest.js';
23
23
  import { serveMcp } from '../mcp.js';
24
+ import { host } from '../runtime.js';
24
25
  import { machineJson, schemaOf } from '../schema.js';
25
26
  import { suggestSimilar } from '../suggest.js';
26
27
  import { Argument, humanReadableArgName } from './argument.js';
@@ -132,13 +133,13 @@ export class Command extends EventEmitter {
132
133
  this._args = this.registeredArguments;
133
134
  this._name = name || '';
134
135
  this._outputConfiguration = {
135
- writeOut: (str) => process.stdout.write(str),
136
- writeErr: (str) => process.stderr.write(str),
136
+ writeOut: (str) => host.stdout.write(str),
137
+ writeErr: (str) => host.stderr.write(str),
137
138
  outputError: (str, write) => write(str),
138
- getOutHelpWidth: () => (process.stdout.isTTY ? process.stdout.columns : undefined),
139
- getErrHelpWidth: () => (process.stderr.isTTY ? process.stderr.columns : undefined),
140
- getOutHasColors: () => useColor() ?? (process.stdout.isTTY && process.stdout.hasColors?.()),
141
- getErrHasColors: () => useColor() ?? (process.stderr.isTTY && process.stderr.hasColors?.()),
139
+ getOutHelpWidth: () => (host.stdout.isTTY ? host.stdout.columns : undefined),
140
+ getErrHelpWidth: () => (host.stderr.isTTY ? host.stderr.columns : undefined),
141
+ getOutHasColors: () => useColor() ?? (host.stdout.isTTY && host.stdout.hasColors?.()),
142
+ getErrHasColors: () => useColor() ?? (host.stderr.isTTY && host.stderr.hasColors?.()),
142
143
  stripColor: (str) => stripVTControlCharacters(str),
143
144
  };
144
145
  }
@@ -335,7 +336,7 @@ Expecting one of '${HOOK_EVENTS.join("', '")}'`);
335
336
  this._exitCallback(new CommanderError(exitCode, code, message));
336
337
  // Expecting this line is not reached.
337
338
  }
338
- process.exit(exitCode);
339
+ return host.exit(exitCode);
339
340
  }
340
341
  // commander's contract: the positional args, then the options, then the command itself.
341
342
  action(fn) {
@@ -532,15 +533,15 @@ Expecting one of '${HOOK_EVENTS.join("', '")}'`);
532
533
  throw new Error('first parameter to parse must be array or undefined');
533
534
  parseOptions = parseOptions ?? {};
534
535
  if (argv === undefined && parseOptions.from === undefined) {
535
- if (process.versions['electron'])
536
+ if (host.versions['electron'])
536
537
  parseOptions.from = 'electron';
537
- const execArgv = process.execArgv ?? [];
538
+ const execArgv = host.execArgv ?? [];
538
539
  if (execArgv.includes('-e') || execArgv.includes('--eval') || execArgv.includes('-p') || execArgv.includes('--print')) {
539
540
  parseOptions.from = 'eval'; // internal usage, not documented
540
541
  }
541
542
  }
542
543
  if (argv === undefined)
543
- argv = process.argv;
544
+ argv = host.argv;
544
545
  this.rawArgs = argv.slice();
545
546
  let userArgs;
546
547
  switch (parseOptions.from) {
@@ -693,27 +694,37 @@ Expecting one of '${HOOK_EVENTS.join("', '")}'`);
693
694
  executableFile = localFile || executableFile;
694
695
  }
695
696
  const launchWithNode = SOURCE_EXT.includes(path.extname(executableFile));
697
+ // Through `bellpull`, not `node:child_process`: it resolves the executable properly on
698
+ // Windows, where upstream sends every spawn through `node` to dodge `PATHEXT`. The
699
+ // reasoning, the measurement and the mock constraint are in `weight.test.ts`'s
700
+ // `./commander` entry — they are prose, and prose in this file ships.
696
701
  let proc;
697
- if (process.platform !== 'win32') {
702
+ if (host.platform !== 'win32') {
698
703
  if (launchWithNode) {
699
704
  args.unshift(executableFile);
700
- args = incrementNodeInspectorPort(process.execArgv).concat(args);
701
- proc = childProcess.spawn(process.argv[0] ?? process.execPath, args, { stdio: 'inherit' });
705
+ args = incrementNodeInspectorPort(host.execArgv).concat(args);
706
+ proc = crossSpawn.spawn(host.argv[0] ?? host.execPath, args, { stdio: 'inherit' });
702
707
  }
703
708
  else {
704
- proc = childProcess.spawn(executableFile, args, { stdio: 'inherit' });
709
+ proc = crossSpawn.spawn(executableFile, args, { stdio: 'inherit' });
705
710
  }
706
711
  }
707
712
  else {
708
713
  this._checkForMissingExecutable(executableFile, executableDir, subcommand._name);
709
- args.unshift(executableFile);
710
- args = incrementNodeInspectorPort(process.execArgv).concat(args);
711
- proc = childProcess.spawn(process.execPath, args, { stdio: 'inherit' });
714
+ if (launchWithNode) {
715
+ args.unshift(executableFile);
716
+ args = incrementNodeInspectorPort(host.execArgv).concat(args);
717
+ proc = crossSpawn.spawn(host.execPath, args, { stdio: 'inherit' });
718
+ }
719
+ else {
720
+ // The case upstream cannot reach: a `.cmd`, a `.bat`, or a shebang that is not node.
721
+ proc = crossSpawn.spawn(executableFile, args, { stdio: 'inherit' });
722
+ }
712
723
  }
713
724
  if (!proc.killed) {
714
725
  // Testing mainly to avoid leak warnings during unit tests with mocked spawn.
715
726
  for (const signal of FORWARDED_SIGNALS) {
716
- process.on(signal, () => {
727
+ host.on(signal, () => {
717
728
  if (proc.killed === false && proc.exitCode === null)
718
729
  proc.kill(signal);
719
730
  });
@@ -723,7 +734,7 @@ Expecting one of '${HOOK_EVENTS.join("', '")}'`);
723
734
  proc.on('close', (code) => {
724
735
  code = code ?? 1; // null when the spawned process terminated due to a signal
725
736
  if (!exitCallback)
726
- process.exit(code);
737
+ host.exit(code);
727
738
  else
728
739
  exitCallback(new CommanderError(code, 'commander.executeSubCommandAsync', '(close)'));
729
740
  });
@@ -735,7 +746,7 @@ Expecting one of '${HOOK_EVENTS.join("', '")}'`);
735
746
  throw new Error(`'${executableFile}' not executable`);
736
747
  }
737
748
  if (!exitCallback) {
738
- process.exit(1);
749
+ host.exit(1);
739
750
  }
740
751
  else {
741
752
  const wrappedError = new CommanderError(1, 'commander.executeSubCommandAsync', '(error)');
@@ -1113,12 +1124,12 @@ Expecting one of '${HOOK_EVENTS.join("', '")}'`);
1113
1124
  /** Apply environment variables to options that have no value from the cli or client code. */
1114
1125
  _parseOptionsEnv() {
1115
1126
  for (const option of this.options) {
1116
- if (option.envVar && option.envVar in process.env) {
1127
+ if (option.envVar && option.envVar in host.env) {
1117
1128
  const optionKey = option.attributeName();
1118
1129
  // Do not overwrite cli values or values from an unknown (client-code) source.
1119
1130
  if (this.getOptionValue(optionKey) === undefined || ENV_SOURCES.includes(this.getOptionValueSource(optionKey) ?? '')) {
1120
1131
  if (option.required || option.optional)
1121
- this.emit(`optionEnv:${option.name()}`, process.env[option.envVar]);
1132
+ this.emit(`optionEnv:${option.name()}`, host.env[option.envVar]);
1122
1133
  else
1123
1134
  this.emit(`optionEnv:${option.name()}`);
1124
1135
  }
@@ -1428,7 +1439,7 @@ Expecting one of '${HOOK_EVENTS.join("', '")}'`);
1428
1439
  /** Output help and exit. */
1429
1440
  help(contextOptions) {
1430
1441
  this.outputHelp(contextOptions);
1431
- let exitCode = Number(process.exitCode ?? 0);
1442
+ let exitCode = Number(host.exitCode ?? 0);
1432
1443
  if (exitCode === 0 && contextOptions && typeof contextOptions !== 'function' && contextOptions.error)
1433
1444
  exitCode = 1;
1434
1445
  // message: not all displayed text is available, so only a placeholder is passed.
@@ -1574,7 +1585,7 @@ Expecting one of '${HELP_POSITIONS.join("', '")}'`);
1574
1585
  await root.parseAsync(args, { from: 'user', stdout: { write: (s) => out.push(s) }, stderr: { write: (s) => err.push(s) }, exit: (c) => void (code = c) });
1575
1586
  return { stdout: out.join(''), stderr: err.join(''), code };
1576
1587
  };
1577
- return serveMcp(this.manifest, { input: process.stdin, output: { write: (s) => root._outputConfiguration.writeOut(s) }, invoke }).then(() => true);
1588
+ return serveMcp(this.manifest, { input: host.stdin, output: { write: (s) => root._outputConfiguration.writeOut(s) }, invoke }).then(() => true);
1578
1589
  }
1579
1590
  return false;
1580
1591
  }
@@ -1749,9 +1760,9 @@ function incrementNodeInspectorPort(args) {
1749
1760
  * and CLICOLOR_FORCE enable, otherwise undecided (the stream's TTY-ness decides).
1750
1761
  */
1751
1762
  export function useColor() {
1752
- if (process.env['NO_COLOR'] || process.env['FORCE_COLOR'] === '0' || process.env['FORCE_COLOR'] === 'false')
1763
+ if (host.env['NO_COLOR'] || host.env['FORCE_COLOR'] === '0' || host.env['FORCE_COLOR'] === 'false')
1753
1764
  return false;
1754
- if (process.env['FORCE_COLOR'] || process.env['CLICOLOR_FORCE'] !== undefined)
1765
+ if (host.env['FORCE_COLOR'] || host.env['CLICOLOR_FORCE'] !== undefined)
1755
1766
  return true;
1756
1767
  return undefined;
1757
1768
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `burgee/commander` — commander's public surface, implemented over burgee (J9: no
3
3
  * dependency on commander itself). Graded by commander's own suite; see
4
- * `.sdlc/intents/commander-compat/design.md` and `npm run compat`.
4
+ * `.sdlc/intents/commander-compat/spec.md` and `npm run compat`.
5
5
  */
6
6
  import { Argument } from './commander/argument.js';
7
7
  import { Command } from './commander/command.js';
@@ -26,6 +26,14 @@ interface FigOption {
26
26
  name: string;
27
27
  suggestions?: string[];
28
28
  };
29
+ /**
30
+ * Fig's own two keys for option relationships, spelled as Fig spells them:
31
+ * `dependsOn` keeps our name, `exclusive` becomes `exclusiveOn`. Both take the option's
32
+ * *spelling*, which is what Fig matches against on the command line — `fig-schema.test.ts`
33
+ * pins that these keys are ones `@withfig/autocomplete-types` declares on `Option`.
34
+ */
35
+ dependsOn?: string[];
36
+ exclusiveOn?: string[];
29
37
  }
30
38
  interface FigSubcommand {
31
39
  name: string;
@@ -1,4 +1,4 @@
1
- import { kebab } from './names.js';
1
+ import { flagsOf, kebab } from './names.js';
2
2
  export const SHELLS = ['bash', 'zsh', 'fish', 'pwsh'];
3
3
  const GLOBAL = {
4
4
  json: { type: 'boolean', description: 'machine-readable output' },
@@ -201,6 +201,10 @@ function figOptions(node) {
201
201
  o.description = s.description;
202
202
  if (takesValue(s))
203
203
  o.args = { name: s.placeholder ?? 'value', ...(s.choices === undefined ? {} : { suggestions: [...s.choices] }) };
204
+ if (s.dependsOn !== undefined && s.dependsOn.length > 0)
205
+ o.dependsOn = flagsOf(s.dependsOn);
206
+ if (s.exclusive !== undefined && s.exclusive.length > 0)
207
+ o.exclusiveOn = flagsOf(s.exclusive);
204
208
  return o;
205
209
  });
206
210
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * What must be true of a command's declaration before it is added — whoever declared it.
3
+ *
4
+ * Split out of `validate.ts` on 2026-09-16, and the weight lock is what asked for it. That
5
+ * file holds two unrelated jobs: these checks, which run once when a program is built, and
6
+ * the run-time half — relations, numbers, choices, Standard Schema — which runs on every
7
+ * invocation and is four fifths of the bytes. Nothing minded while the only caller was
8
+ * `defineCommand`, because the engine carries both anyway.
9
+ *
10
+ * The plugin host is what made it matter. `Manifest.use()` has to run these checks, so
11
+ * `plugin.ts` imports them, so **every** graph that reaches the manifest now reaches them —
12
+ * and that includes the commander and yargs front-ends, which reach the manifest and nothing
13
+ * else of the engine. Importing `validate.js` for `checkDefinition` put 6,409 bytes of
14
+ * run-time coercion into both front-ends to get 1,600 bytes of definition checking, and took
15
+ * `./commander` over 128,000 — the budget that exists to prove the front-end is no heavier
16
+ * than commander's own `lib/`. One file per job, and the front-ends pay for the job they use.
17
+ */
18
+ import { type OptionSpec } from './manifest.js';
19
+ /**
20
+ * The fourth answer, which is not about the world: *this command is not offered to agents*.
21
+ *
22
+ * Exported because `mcp.ts` filters on it and a second spelling of a magic string is a
23
+ * second thing to keep in step. See `DeclaredEffects` in `manifest.ts` for why the word is
24
+ * `withheld` and not `none`.
25
+ */
26
+ export declare const WITHHELD = "withheld";
27
+ /**
28
+ * What must be true of a declaration before anything runs (yargs #1198, #887, #1679):
29
+ * a known type, one short alias per command, no two keys that meet on the command line.
30
+ */
31
+ export declare function checkDefinition(name: string, options: Record<string, OptionSpec>): void;
32
+ /**
33
+ * The whole door: the reserved names of V5, {@link checkDefinition}, and {@link checkEffects}.
34
+ *
35
+ * This exists as one function because it was two. `defineCommand` ran both; `Manifest.use()`
36
+ * ran neither, so a plugin's command was admitted unread — and a plugin option named `json`
37
+ * did not clash with the envelope flag, it replaced it in the parse config. The fix is not a
38
+ * second copy of the guard beside `use()`; it is that there is one guard and both callers
39
+ * reach it, which is the only arrangement a reader can check by looking.
40
+ *
41
+ * `effects` and `runs` are required rather than optional for exactly that reason. An optional
42
+ * third argument would be a check a caller can decline by writing nothing, which is the shape
43
+ * of the defect `checkEffects` exists to remove, one level up.
44
+ */
45
+ export declare function checkCommand(name: string, options: Record<string, OptionSpec>, effects: unknown, runs: boolean): void;
@@ -0,0 +1,57 @@
1
+ import { kebab } from './names.js';
2
+ const TYPES = new Set(['string', 'boolean', 'number']);
3
+ const EFFECTS = ['read_only', 'idempotent', 'non_idempotent'];
4
+ export const WITHHELD = 'withheld';
5
+ const DECLARED = [...EFFECTS, WITHHELD];
6
+ const RESERVED = new Set(['json', 'help', 'schema', 'mcp', 'version', 'explain']);
7
+ function checkRelationNames(name, key, spec, options) {
8
+ for (const field of ['dependsOn', 'exclusive']) {
9
+ for (const other of spec[field] ?? []) {
10
+ if (other !== key && other in options)
11
+ continue;
12
+ const why = other === key ? 'itself' : `not an option of "${name}"`;
13
+ throw new Error(`burgee: option "${key}" of "${name}" declares ${field} "${other}", which is ${why}`);
14
+ }
15
+ }
16
+ }
17
+ export function checkDefinition(name, options) {
18
+ const shorts = new Map();
19
+ const flags = new Map();
20
+ for (const [key, spec] of Object.entries(options)) {
21
+ if (!TYPES.has(spec.type))
22
+ throw new Error(`burgee: option "${key}" of "${name}" has unknown type "${String(spec.type)}"`);
23
+ if (spec.short !== undefined) {
24
+ const owner = shorts.get(spec.short);
25
+ if (owner !== undefined)
26
+ throw new Error(`burgee: options "${owner}" and "${key}" of "${name}" both use -${spec.short}`);
27
+ shorts.set(spec.short, key);
28
+ }
29
+ const flag = kebab(key);
30
+ const clash = flags.get(flag);
31
+ if (clash !== undefined)
32
+ throw new Error(`burgee: options "${clash}" and "${key}" of "${name}" are both --${flag}`);
33
+ flags.set(flag, key);
34
+ if ((spec.minimum !== undefined || spec.maximum !== undefined || spec.integer !== undefined) && spec.type !== 'number') {
35
+ throw new Error(`burgee: option "${key}" of "${name}" declares a numeric bound but is not a number`);
36
+ }
37
+ checkRelationNames(name, key, spec, options);
38
+ }
39
+ }
40
+ function checkEffects(name, effects, runs) {
41
+ if (effects === undefined) {
42
+ if (!runs)
43
+ return;
44
+ throw new Error(`burgee: command "${name}" is runnable and declares no effects; declare ${EFFECTS.join(', ')} — or ${WITHHELD}, which serves it to people and keeps it out of the MCP tool list`);
45
+ }
46
+ if (!DECLARED.includes(effects)) {
47
+ throw new Error(`burgee: command "${name}" declares effects ${JSON.stringify(effects)}, which is not an effects; use ${DECLARED.join(', ')}`);
48
+ }
49
+ }
50
+ export function checkCommand(name, options, effects, runs) {
51
+ for (const key of Object.keys(options)) {
52
+ if (RESERVED.has(key) || RESERVED.has(kebab(key)))
53
+ throw new Error(`burgee: option "${key}" is reserved and cannot be redefined`);
54
+ }
55
+ checkDefinition(name, options);
56
+ checkEffects(name, effects, runs);
57
+ }
package/dist/execute.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ArgumentSpec, type CommandNode, type Effects, type Example, type LazyModule, Manifest, type OptionSpec, type Relation, type RunContext } from './manifest.js';
1
+ import { type ArgumentSpec, type CommandNode, type DeclaredEffects, type Example, type LazyModule, Manifest, type OptionSpec, type Relation, type RunContext } from './manifest.js';
2
2
  export interface CommandContext<O> extends Omit<RunContext, 'options'> {
3
3
  options: O;
4
4
  }
@@ -33,8 +33,17 @@ export interface Command<S extends OptionSpecs = OptionSpecs> {
33
33
  epilogue?: string;
34
34
  hidden?: boolean;
35
35
  deprecated?: boolean | string;
36
- /** What running it does to the world (N6). Declaring it is what exposes the command as an MCP tool (N2). */
37
- effects?: Effects;
36
+ /**
37
+ * What running it does to the world (N6). Declaring one of the three is what exposes the
38
+ * command as an MCP tool (N2); `'withheld'` declares that it is not offered to agents.
39
+ *
40
+ * Optional on the type and **required at definition time** on a command that runs:
41
+ * `defineCommand` refuses one that omits it. It stays optional here because a group that
42
+ * only holds subcommands declares none, and TypeScript cannot make a field's presence
43
+ * depend on a sibling's without splitting `Command` into a union that would cost the
44
+ * option-spec inference every caller of this type relies on.
45
+ */
46
+ effects?: DeclaredEffects;
38
47
  /** Relationships between options, validated before choices and the handler (S2, S6). */
39
48
  relations?: readonly Relation[];
40
49
  /** Absent on a group that only holds subcommands. `NoInfer`: the spec fixes S, the handler only reads it. */