@ianwremmel/dispatch 0.32.1-bootstrap.0

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.
Files changed (165) hide show
  1. package/.claude-plugin/plugin.json +59 -0
  2. package/.mcp.json +8 -0
  3. package/LICENSE +21 -0
  4. package/README.md +93 -0
  5. package/agents/.gitkeep +0 -0
  6. package/agents/build-graph.md +99 -0
  7. package/agents/milestone-reviewer.md +50 -0
  8. package/agents/pr-worker.md +172 -0
  9. package/agents/ticket-worker.md +97 -0
  10. package/bin/dispatch +101 -0
  11. package/bin/dispatch-mcp +19 -0
  12. package/bin/pr-status +931 -0
  13. package/commands/.gitkeep +0 -0
  14. package/commands/orchestrate.md +6 -0
  15. package/hooks/.gitkeep +0 -0
  16. package/hooks/claim-guard.mts +98 -0
  17. package/hooks/hooks.json +15 -0
  18. package/package.json +46 -0
  19. package/skills/.gitkeep +0 -0
  20. package/skills/land/SKILL.md +238 -0
  21. package/skills/land/credentials-dedicated.md +33 -0
  22. package/skills/land/credentials-shared.md +76 -0
  23. package/skills/land/mode-solo.md +76 -0
  24. package/skills/land/mode-team.md +103 -0
  25. package/skills/land/reference.md +152 -0
  26. package/skills/land/ticket.md +94 -0
  27. package/skills/orchestrate/SKILL.md +87 -0
  28. package/skills/tracker-adapter-linear/SKILL.md +142 -0
  29. package/src/commands/CLAUDE.md +12 -0
  30. package/src/commands/claim/check.mts +88 -0
  31. package/src/commands/claim/guard.mts +95 -0
  32. package/src/commands/claim/status.mts +49 -0
  33. package/src/commands/edge/add.mts +44 -0
  34. package/src/commands/edge/rm.mts +44 -0
  35. package/src/commands/edge/set.mts +56 -0
  36. package/src/commands/greet.mts +34 -0
  37. package/src/commands/mcp/ack.mts +43 -0
  38. package/src/commands/mcp/ping.mts +61 -0
  39. package/src/commands/mcp/status.mts +89 -0
  40. package/src/commands/mcp.mts +155 -0
  41. package/src/commands/milestone/rm.mts +35 -0
  42. package/src/commands/milestone/set.mts +49 -0
  43. package/src/commands/outcome/rm.mts +36 -0
  44. package/src/commands/outcome/set.mts +86 -0
  45. package/src/commands/pr/rm.mts +33 -0
  46. package/src/commands/pr/set.mts +110 -0
  47. package/src/commands/pr/yield.mts +114 -0
  48. package/src/commands/project/rm.mts +33 -0
  49. package/src/commands/project/set.mts +50 -0
  50. package/src/commands/queue.mts +41 -0
  51. package/src/commands/refresh/done.mts +42 -0
  52. package/src/commands/refresh/status.mts +40 -0
  53. package/src/commands/refresh.mts +56 -0
  54. package/src/commands/review/record.mts +46 -0
  55. package/src/commands/review/release.mts +49 -0
  56. package/src/commands/status.mts +85 -0
  57. package/src/commands/ticket/missing.mts +31 -0
  58. package/src/commands/ticket/rm.mts +33 -0
  59. package/src/commands/ticket/set.mts +134 -0
  60. package/src/commands/worker/rm.mts +46 -0
  61. package/src/commands/worker/set.mts +63 -0
  62. package/src/lib/cli/CLAUDE.md +13 -0
  63. package/src/lib/cli/cli.mts +226 -0
  64. package/src/lib/cli/index.mts +1 -0
  65. package/src/lib/command/CLAUDE.md +26 -0
  66. package/src/lib/command/__fixtures__/bad-export/oops.mts +1 -0
  67. package/src/lib/command/__fixtures__/bad-name/mismatch.mts +19 -0
  68. package/src/lib/command/__fixtures__/commands/cli-only.mts +20 -0
  69. package/src/lib/command/__fixtures__/commands/greet.mts +39 -0
  70. package/src/lib/command/__fixtures__/commands/math/add.mts +32 -0
  71. package/src/lib/command/__fixtures__/commands/mcp-only.mts +20 -0
  72. package/src/lib/command/__fixtures__/commands/needs-token.mts +19 -0
  73. package/src/lib/command/__fixtures__/commands/store/get.mts +26 -0
  74. package/src/lib/command/__fixtures__/commands/store.mts +26 -0
  75. package/src/lib/command/abstract-command.mts +104 -0
  76. package/src/lib/command/discovery.mts +100 -0
  77. package/src/lib/command/env.mts +19 -0
  78. package/src/lib/command/index.mts +6 -0
  79. package/src/lib/command/parse.mts +64 -0
  80. package/src/lib/command/test-support.mts +81 -0
  81. package/src/lib/command/transports.mts +17 -0
  82. package/src/lib/command/types.mts +53 -0
  83. package/src/lib/db/CLAUDE.md +13 -0
  84. package/src/lib/db/database.mts +160 -0
  85. package/src/lib/db/index.mts +4 -0
  86. package/src/lib/db/schema.mts +195 -0
  87. package/src/lib/db/time.mts +24 -0
  88. package/src/lib/db/with-database.mts +56 -0
  89. package/src/lib/errors/CLAUDE.md +18 -0
  90. package/src/lib/errors/command-error.mts +13 -0
  91. package/src/lib/errors/data-error.mts +12 -0
  92. package/src/lib/errors/definition-error.mts +6 -0
  93. package/src/lib/errors/dispatch-error.mts +27 -0
  94. package/src/lib/errors/ensure.mts +22 -0
  95. package/src/lib/errors/environment-error.mts +7 -0
  96. package/src/lib/errors/index.mts +8 -0
  97. package/src/lib/errors/json-rpc-error.mts +18 -0
  98. package/src/lib/errors/usage-error.mts +7 -0
  99. package/src/lib/graph/CLAUDE.md +17 -0
  100. package/src/lib/graph/anomalies.mts +110 -0
  101. package/src/lib/graph/derive.mts +96 -0
  102. package/src/lib/graph/index.mts +26 -0
  103. package/src/lib/graph/pipeline.mts +410 -0
  104. package/src/lib/graph/queries.mts +207 -0
  105. package/src/lib/graph/rows.mts +99 -0
  106. package/src/lib/graph/types.mts +137 -0
  107. package/src/lib/liveness/CLAUDE.md +14 -0
  108. package/src/lib/liveness/index.mts +10 -0
  109. package/src/lib/liveness/liveness.mts +147 -0
  110. package/src/lib/liveness/retire.mts +63 -0
  111. package/src/lib/logger/CLAUDE.md +12 -0
  112. package/src/lib/logger/index.mts +2 -0
  113. package/src/lib/logger/logger.mts +58 -0
  114. package/src/lib/logger/stream-sink.mts +23 -0
  115. package/src/lib/mcp/CLAUDE.md +21 -0
  116. package/src/lib/mcp/channel.mts +41 -0
  117. package/src/lib/mcp/dispatch.mts +60 -0
  118. package/src/lib/mcp/drain.mts +83 -0
  119. package/src/lib/mcp/index.mts +5 -0
  120. package/src/lib/mcp/mcp.mts +267 -0
  121. package/src/lib/mcp/tools.mts +77 -0
  122. package/src/lib/model/CLAUDE.md +8 -0
  123. package/src/lib/model/index.mts +3 -0
  124. package/src/lib/model/repo-caps.mts +95 -0
  125. package/src/lib/model/status.mts +91 -0
  126. package/src/lib/model/types.mts +83 -0
  127. package/src/lib/refresh/index.mts +2 -0
  128. package/src/lib/refresh/placeholders.mts +43 -0
  129. package/src/lib/refresh/refresh-service.mts +203 -0
  130. package/src/lib/schedule/CLAUDE.md +18 -0
  131. package/src/lib/schedule/caps.mts +113 -0
  132. package/src/lib/schedule/correlate.mts +69 -0
  133. package/src/lib/schedule/index.mts +7 -0
  134. package/src/lib/schedule/scheduler.mts +355 -0
  135. package/src/lib/schedule/tick.mts +266 -0
  136. package/src/lib/stores/CLAUDE.md +24 -0
  137. package/src/lib/stores/coordination.mts +359 -0
  138. package/src/lib/stores/cursor.mts +41 -0
  139. package/src/lib/stores/edge.mts +138 -0
  140. package/src/lib/stores/fetch-request.mts +346 -0
  141. package/src/lib/stores/index.mts +19 -0
  142. package/src/lib/stores/materialize.mts +69 -0
  143. package/src/lib/stores/milestone.mts +74 -0
  144. package/src/lib/stores/notice.mts +57 -0
  145. package/src/lib/stores/policy.mts +48 -0
  146. package/src/lib/stores/pr-event.mts +94 -0
  147. package/src/lib/stores/pr.mts +167 -0
  148. package/src/lib/stores/project.mts +79 -0
  149. package/src/lib/stores/refresh.mts +197 -0
  150. package/src/lib/stores/review.mts +113 -0
  151. package/src/lib/stores/session.mts +170 -0
  152. package/src/lib/stores/ticket.mts +246 -0
  153. package/src/lib/stores/watch.mts +360 -0
  154. package/src/lib/stores/worker.mts +121 -0
  155. package/src/lib/watch/adopt.mts +151 -0
  156. package/src/lib/watch/arm.mts +48 -0
  157. package/src/lib/watch/cadence.mts +45 -0
  158. package/src/lib/watch/diff.mts +274 -0
  159. package/src/lib/watch/index.mts +11 -0
  160. package/src/lib/watch/marker.mts +24 -0
  161. package/src/lib/watch/payload.mts +56 -0
  162. package/src/lib/watch/poll.mts +87 -0
  163. package/src/lib/watch/render.mts +61 -0
  164. package/src/lib/watch/snapshot.mts +312 -0
  165. package/src/main.mts +18 -0
@@ -0,0 +1,63 @@
1
+ import {AbstractCommand} from '../../lib/command/index.mts';
2
+ import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
3
+ import {DB_OPTION, nowIso, withDatabase} from '../../lib/db/index.mts';
4
+ import {DataError, ensure} from '../../lib/errors/index.mts';
5
+ import {correlateSession} from '../../lib/schedule/index.mts';
6
+ import {WorkerStore} from '../../lib/stores/index.mts';
7
+
8
+ const options = {
9
+ node: {
10
+ type: 'string',
11
+ description: 'The node the launched worker is working.',
12
+ positional: false,
13
+ required: true,
14
+ },
15
+ agent: {
16
+ type: 'string',
17
+ description:
18
+ 'The agent ref the launch returned — the address a relayed event reaches the worker at.',
19
+ positional: false,
20
+ required: true,
21
+ },
22
+ db: DB_OPTION,
23
+ } as const;
24
+
25
+ /**
26
+ * Record where a node's worker can be reached. The orchestrate session runs
27
+ * this right after a launch, with the ref the launch returned; from then on
28
+ * events for the node carry that ref, and the session relays instead of
29
+ * letting the item cold-start a resume pass.
30
+ *
31
+ * Identity comes from the environment: the row belongs to the launching
32
+ * session, because only the launcher holds a ref that can actually reach the
33
+ * agent.
34
+ */
35
+ export class Command extends AbstractCommand {
36
+ readonly name = 'set';
37
+ readonly summary = "Record a launched worker's address for event routing.";
38
+ readonly env = [];
39
+ readonly options = options;
40
+
41
+ async run(
42
+ parsed: ParsedOptions<typeof options>,
43
+ ctx: CommandContext
44
+ ): Promise<void> {
45
+ await withDatabase(parsed.db, ctx.env, async (db) => {
46
+ const session = await correlateSession(db, ctx.env, undefined);
47
+ ensure(
48
+ session !== null,
49
+ () =>
50
+ new DataError('no live server correlates to this session', {
51
+ hint: 'only the session that launched the worker can record its address.',
52
+ })
53
+ );
54
+ await new WorkerStore(db).set({
55
+ node: parsed.node,
56
+ session,
57
+ agentRef: parsed.agent,
58
+ at: nowIso(),
59
+ });
60
+ ctx.io.write(`worker ${parsed.node} ${parsed.agent}\n`);
61
+ });
62
+ }
63
+ }
@@ -0,0 +1,13 @@
1
+ # CLI
2
+
3
+ `runCli({argv, tree, log, env, stdout, stderr})` in `cli.mts` drives a discovered
4
+ command tree and returns an exit code. This is the only layer that knows about
5
+ argv, `--help`, exit codes, and usage text — the `lib/command` contract stays
6
+ transport-neutral. `index.mts` is the barrel.
7
+
8
+ Read `cli.mts` for the walk / help / parse / error-mapping details. Usage text is
9
+ generated from a command's `name` + `options`, so commands never author one.
10
+
11
+ `runCli` also supplies each command an `io` bound to `stdout` (its response
12
+ channel, separate from `log`) and hides/refuses any command whose `cli`
13
+ transport is off (`resolveTransports`).
@@ -0,0 +1,226 @@
1
+ import {parseArgs} from 'node:util';
2
+ import type {Writable} from 'node:stream';
3
+
4
+ import type {Logger} from '../logger/index.mts';
5
+ import {parseOptions, assertEnv, resolveTransports} from '../command/index.mts';
6
+ import type {AbstractCommand, CommandNode, Option} from '../command/index.mts';
7
+ import {
8
+ DispatchError,
9
+ CommandError,
10
+ UsageError,
11
+ assertUsage,
12
+ } from '../errors/index.mts';
13
+
14
+ export interface RunCliOptions {
15
+ readonly argv: readonly string[];
16
+ readonly tree: CommandNode;
17
+ readonly log: Logger;
18
+ readonly env: NodeJS.ProcessEnv;
19
+ readonly stdout: Writable;
20
+ readonly stderr: Writable;
21
+ }
22
+
23
+ interface Walked {
24
+ readonly path: string[];
25
+ readonly node: CommandNode;
26
+ readonly rest: string[];
27
+ }
28
+
29
+ /** Parse argv against the command tree, run the matched command, return an exit code. */
30
+ export async function runCli(options: RunCliOptions): Promise<number> {
31
+ const {argv, tree, log, env, stdout, stderr} = options;
32
+ try {
33
+ const walked = walk(tree, argv);
34
+
35
+ if (wantsHelp(argv)) {
36
+ stdout.write(`${usageText(walked)}\n`);
37
+ return 0;
38
+ }
39
+
40
+ const {node, rest, path} = walked;
41
+ const command = node.command;
42
+
43
+ if (command === undefined || !resolveTransports(command).cli) {
44
+ const label = path.length > 0 ? path.join(' ') : 'dispatch';
45
+ const children = visibleChildNames(node).sort().join(', ');
46
+ throw new UsageError(
47
+ rest.length > 0
48
+ ? `unknown subcommand "${rest[0] ?? ''}" for ${label}`
49
+ : `${label} needs a subcommand`,
50
+ children === ''
51
+ ? {
52
+ hint: 'this command tree has no commands; the installation is broken.',
53
+ }
54
+ : {hint: `run one of: ${children}`}
55
+ );
56
+ }
57
+
58
+ const parsed = parseCommandArgs(command, rest);
59
+ assertEnv(command.env, env);
60
+ const io = {
61
+ write: (chunk: string) => {
62
+ stdout.write(chunk);
63
+ },
64
+ };
65
+ await command.run(parsed, {log, env, io});
66
+ return 0;
67
+ } catch (error) {
68
+ if (error instanceof DispatchError) {
69
+ stderr.write(`error: ${error.toString()}\n`);
70
+ return error instanceof CommandError ? error.exitCode : 1;
71
+ }
72
+ stderr.write(`error: ${String(error)}\n`);
73
+ return 1;
74
+ }
75
+ }
76
+
77
+ /** Descend the tree along leading command-name tokens, ignoring help flags. */
78
+ function walk(root: CommandNode, argv: readonly string[]): Walked {
79
+ let node = root;
80
+ const path: string[] = [];
81
+ const tokens = [...argv];
82
+ let index = 0;
83
+ for (; index < tokens.length; index += 1) {
84
+ const token = tokens[index];
85
+ if (token === undefined || token === '--') break;
86
+ if (token === '--help' || token === '-h') continue;
87
+ if (token.startsWith('-')) break;
88
+ const child = node.children.get(token);
89
+ if (child === undefined) break;
90
+ node = child;
91
+ path.push(token);
92
+ }
93
+ return {path, node, rest: tokens.slice(index)};
94
+ }
95
+
96
+ /**
97
+ * Whether `--help`/`-h` appears anywhere before a `--` terminator. A command
98
+ * that needs a literal `--help` option value must have its caller pass it
99
+ * after `--`.
100
+ */
101
+ function wantsHelp(argv: readonly string[]): boolean {
102
+ for (const token of argv) {
103
+ if (token === '--') return false;
104
+ if (token === '--help' || token === '-h') return true;
105
+ }
106
+ return false;
107
+ }
108
+
109
+ function parseCommandArgs(
110
+ command: AbstractCommand,
111
+ rest: readonly string[]
112
+ ): Record<string, unknown> {
113
+ const flagConfig: Record<string, {type: 'string' | 'boolean'}> = {};
114
+ const positionalNames: string[] = [];
115
+ for (const [key, option] of Object.entries(command.options)) {
116
+ if (option.positional) {
117
+ positionalNames.push(key);
118
+ } else {
119
+ flagConfig[key] = {
120
+ type: option.type === 'boolean' ? 'boolean' : 'string',
121
+ };
122
+ }
123
+ }
124
+
125
+ let values: Record<string, string | boolean | undefined>;
126
+ let positionals: string[];
127
+ try {
128
+ const parsed = parseArgs({
129
+ args: [...rest],
130
+ options: flagConfig,
131
+ allowPositionals: true,
132
+ strict: true,
133
+ });
134
+ values = parsed.values;
135
+ positionals = parsed.positionals;
136
+ } catch (error) {
137
+ throw toUsageError(error);
138
+ }
139
+
140
+ assertUsage(
141
+ positionals.length <= positionalNames.length,
142
+ `unexpected argument: ${positionals[positionalNames.length] ?? ''}`
143
+ );
144
+
145
+ const raw: Record<string, string | boolean> = {};
146
+ for (const [key, value] of Object.entries(values)) {
147
+ if (value !== undefined) raw[key] = value;
148
+ }
149
+ positionalNames.forEach((name, position) => {
150
+ const value = positionals[position];
151
+ if (value !== undefined) raw[name] = value;
152
+ });
153
+
154
+ return parseOptions(command.options, raw);
155
+ }
156
+
157
+ /** Re-tag a `node:util` parse failure as a usage error; rethrow anything else. */
158
+ function toUsageError(error: unknown): UsageError {
159
+ if (
160
+ error instanceof Error &&
161
+ 'code' in error &&
162
+ typeof error.code === 'string' &&
163
+ error.code.startsWith('ERR_PARSE_ARGS_')
164
+ ) {
165
+ return new UsageError(error.message, {cause: error});
166
+ }
167
+ throw error;
168
+ }
169
+
170
+ function usageText(walked: Walked): string {
171
+ const {path, node} = walked;
172
+ const invocation = ['dispatch', ...path].join(' ');
173
+ const lines: string[] = [];
174
+
175
+ if (node.command === undefined) {
176
+ lines.push(`usage: ${invocation} <subcommand>`);
177
+ } else {
178
+ const parts = Object.entries(node.command.options).map(([key, option]) =>
179
+ optionUsage(key, option)
180
+ );
181
+ lines.push(`usage: ${[invocation, ...parts].join(' ')}`);
182
+ lines.push('');
183
+ lines.push(node.command.summary);
184
+ }
185
+
186
+ if (visibleChildNames(node).length > 0) {
187
+ lines.push('');
188
+ lines.push('subcommands:');
189
+ lines.push(...childLines(node));
190
+ }
191
+
192
+ return lines.join('\n');
193
+ }
194
+
195
+ function optionUsage(key: string, option: Option): string {
196
+ if (option.positional) {
197
+ const label = option.choices ? option.choices.join('|') : key;
198
+ return option.required ? `<${label}>` : `[<${label}>]`;
199
+ }
200
+ if (option.type === 'boolean') {
201
+ return `[--${key}]`;
202
+ }
203
+ const value = option.choices ? option.choices.join('|') : option.type;
204
+ return option.required ? `--${key} <${value}>` : `[--${key} <${value}>]`;
205
+ }
206
+
207
+ /** Child names reachable over the cli: pure namespaces, plus commands whose `cli` transport is on. */
208
+ function visibleChildNames(node: CommandNode): string[] {
209
+ return [...node.children.entries()]
210
+ .filter(([, child]) => {
211
+ const cmd = child.command;
212
+ return cmd === undefined || resolveTransports(cmd).cli;
213
+ })
214
+ .map(([name]) => name)
215
+ .sort();
216
+ }
217
+
218
+ function childLines(node: CommandNode): string[] {
219
+ const names = visibleChildNames(node);
220
+ if (names.length === 0) return [];
221
+ const width = Math.max(...names.map((name) => name.length));
222
+ return names.map((name) => {
223
+ const summary = node.children.get(name)?.command?.summary ?? '';
224
+ return ` ${name.padEnd(width)} ${summary}`.trimEnd();
225
+ });
226
+ }
@@ -0,0 +1 @@
1
+ export * from './cli.mts';
@@ -0,0 +1,26 @@
1
+ # Command
2
+
3
+ The transport-neutral command contract plus the validation and discovery built on
4
+ it. Nothing here depends on the CLI — `lib/cli` consumes it, and a future MCP
5
+ server will too. `index.mts` is the barrel.
6
+
7
+ - `abstract-command.mts` — `AbstractCommand`, the `Option` shape, and the
8
+ `ParsedOptions<typeof options>` type a command uses to type its `run`; also
9
+ defines `Io` (the command's response channel, distinct from `log`) and the
10
+ concrete `transports` field, both on `CommandContext`. The docblocks cover
11
+ the bivariance override and how presence/`choices` narrow the parsed type.
12
+ - `transports.mts` — `resolveTransports(command)` fills the `transports` partial
13
+ with `{cli: true, mcp: true}` defaults so gating reads definite booleans.
14
+ - `parse.mts` — `parseOptions` turns a raw values map into a validated record
15
+ (coerce numbers, enforce `required`, check `choices`, apply defaults). The cli
16
+ builds `raw` from argv; an MCP server would from JSON.
17
+ - `env.mts` — `assertEnv` throws for any variable a command declared in `env`
18
+ that the environment lacks.
19
+ - `discovery.mts` — `discover` walks a commands dir into a `CommandNode` tree
20
+ (folder path = invocation path).
21
+ - `test-support.mts` — `runCommand` (runs a command as a transport would and
22
+ returns its `io` output) plus the fixtures every command test needs:
23
+ `tempEnv()` for a throwaway graph database and `ticket()` for a blank ticket.
24
+
25
+ Keep CLI-only concerns (argv, streams, usage strings) out so the contract stays
26
+ reusable.
@@ -0,0 +1 @@
1
+ export const notACommand = 42;
@@ -0,0 +1,19 @@
1
+ import {AbstractCommand} from '../../index.mts';
2
+ import type {ParsedOptions, CommandContext} from '../../index.mts';
3
+
4
+ const options = {} as const;
5
+
6
+ export class Command extends AbstractCommand {
7
+ readonly name = 'wrong';
8
+ readonly summary = 'Name does not match the file.';
9
+ readonly env = [];
10
+ readonly options = options;
11
+
12
+ async run(
13
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
14
+ _parsed: ParsedOptions<typeof options>,
15
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
16
+ _ctx: CommandContext
17
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
18
+ ): Promise<void> {}
19
+ }
@@ -0,0 +1,20 @@
1
+ import {AbstractCommand} from '../../index.mts';
2
+ import type {ParsedOptions, CommandContext} from '../../index.mts';
3
+
4
+ const options = {} as const;
5
+
6
+ export class Command extends AbstractCommand {
7
+ readonly name = 'cli-only';
8
+ readonly summary = 'Reachable over cli only.';
9
+ readonly env = [];
10
+ readonly options = options;
11
+ override readonly transports = {mcp: false};
12
+
13
+ // eslint-disable-next-line @typescript-eslint/require-await
14
+ async run(
15
+ _parsed: ParsedOptions<typeof options>,
16
+ ctx: CommandContext
17
+ ): Promise<void> {
18
+ ctx.io.write('cli-only ran\n');
19
+ }
20
+ }
@@ -0,0 +1,39 @@
1
+ import {AbstractCommand} from '../../index.mts';
2
+ import type {ParsedOptions, CommandContext} from '../../index.mts';
3
+
4
+ const options = {
5
+ who: {
6
+ type: 'string',
7
+ description: 'Who to greet.',
8
+ positional: true,
9
+ required: false,
10
+ default: 'world',
11
+ },
12
+ format: {
13
+ type: 'string',
14
+ description: 'Output shape.',
15
+ positional: false,
16
+ required: false,
17
+ default: 'text',
18
+ choices: ['text', 'json'],
19
+ },
20
+ } as const;
21
+
22
+ export class Command extends AbstractCommand {
23
+ readonly name = 'greet';
24
+ readonly summary = 'Print a greeting.';
25
+ readonly env = [];
26
+ readonly options = options;
27
+
28
+ // eslint-disable-next-line @typescript-eslint/require-await
29
+ async run(
30
+ parsed: ParsedOptions<typeof options>,
31
+ ctx: CommandContext
32
+ ): Promise<void> {
33
+ if (parsed.format === 'json') {
34
+ ctx.io.write(`${JSON.stringify({hello: parsed.who})}\n`);
35
+ } else {
36
+ ctx.io.write(`hello ${parsed.who}\n`);
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,32 @@
1
+ import {AbstractCommand} from '../../../index.mts';
2
+ import type {ParsedOptions, CommandContext} from '../../../index.mts';
3
+
4
+ const options = {
5
+ a: {
6
+ type: 'number',
7
+ description: 'First addend.',
8
+ positional: false,
9
+ required: true,
10
+ },
11
+ b: {
12
+ type: 'number',
13
+ description: 'Second addend.',
14
+ positional: false,
15
+ required: true,
16
+ },
17
+ } as const;
18
+
19
+ export class Command extends AbstractCommand {
20
+ readonly name = 'add';
21
+ readonly summary = 'Add two numbers.';
22
+ readonly env = [];
23
+ readonly options = options;
24
+
25
+ // eslint-disable-next-line @typescript-eslint/require-await
26
+ async run(
27
+ parsed: ParsedOptions<typeof options>,
28
+ ctx: CommandContext
29
+ ): Promise<void> {
30
+ ctx.io.write(`${String(parsed.a + parsed.b)}\n`);
31
+ }
32
+ }
@@ -0,0 +1,20 @@
1
+ import {AbstractCommand} from '../../index.mts';
2
+ import type {ParsedOptions, CommandContext} from '../../index.mts';
3
+
4
+ const options = {} as const;
5
+
6
+ export class Command extends AbstractCommand {
7
+ readonly name = 'mcp-only';
8
+ readonly summary = 'Reachable over MCP only.';
9
+ readonly env = [];
10
+ readonly options = options;
11
+ override readonly transports = {cli: false};
12
+
13
+ // eslint-disable-next-line @typescript-eslint/require-await
14
+ async run(
15
+ _parsed: ParsedOptions<typeof options>,
16
+ ctx: CommandContext
17
+ ): Promise<void> {
18
+ ctx.io.write('mcp-only ran\n');
19
+ }
20
+ }
@@ -0,0 +1,19 @@
1
+ import {AbstractCommand} from '../../index.mts';
2
+ import type {ParsedOptions, CommandContext} from '../../index.mts';
3
+
4
+ const options = {} as const;
5
+
6
+ export class Command extends AbstractCommand {
7
+ readonly name = 'needs-token';
8
+ readonly summary = 'Requires MY_TOKEN.';
9
+ readonly env = ['MY_TOKEN'];
10
+ readonly options = options;
11
+
12
+ // eslint-disable-next-line @typescript-eslint/require-await
13
+ async run(
14
+ _parsed: ParsedOptions<typeof options>,
15
+ ctx: CommandContext
16
+ ): Promise<void> {
17
+ ctx.io.write('ok\n');
18
+ }
19
+ }
@@ -0,0 +1,26 @@
1
+ import {AbstractCommand} from '../../../index.mts';
2
+ import type {ParsedOptions, CommandContext} from '../../../index.mts';
3
+
4
+ const options = {
5
+ key: {
6
+ type: 'string',
7
+ description: 'Key to read.',
8
+ positional: true,
9
+ required: true,
10
+ },
11
+ } as const;
12
+
13
+ export class Command extends AbstractCommand {
14
+ readonly name = 'get';
15
+ readonly summary = 'Read one key.';
16
+ readonly env = [];
17
+ readonly options = options;
18
+
19
+ // eslint-disable-next-line @typescript-eslint/require-await
20
+ async run(
21
+ parsed: ParsedOptions<typeof options>,
22
+ ctx: CommandContext
23
+ ): Promise<void> {
24
+ ctx.io.write(`get ${parsed.key}\n`);
25
+ }
26
+ }
@@ -0,0 +1,26 @@
1
+ import {AbstractCommand} from '../../index.mts';
2
+ import type {ParsedOptions, CommandContext} from '../../index.mts';
3
+
4
+ const options = {
5
+ key: {
6
+ type: 'string',
7
+ description: 'Key to read.',
8
+ positional: true,
9
+ required: false,
10
+ },
11
+ } as const;
12
+
13
+ export class Command extends AbstractCommand {
14
+ readonly name = 'store';
15
+ readonly summary = 'Inspect the store.';
16
+ readonly env = [];
17
+ readonly options = options;
18
+
19
+ // eslint-disable-next-line @typescript-eslint/require-await
20
+ async run(
21
+ parsed: ParsedOptions<typeof options>,
22
+ ctx: CommandContext
23
+ ): Promise<void> {
24
+ ctx.io.write(`store ${parsed.key ?? '(root)'}\n`);
25
+ }
26
+ }
@@ -0,0 +1,104 @@
1
+ import type {Logger} from '../logger/index.mts';
2
+
3
+ export type OptionType = 'string' | 'number' | 'boolean';
4
+
5
+ export interface Option {
6
+ readonly type: OptionType;
7
+ readonly description: string;
8
+ /** Consumes a positional argument instead of a `--flag`. */
9
+ readonly positional: boolean;
10
+ /** Absent at parse time is a usage error. */
11
+ readonly required: boolean;
12
+ /** Ignored for `boolean` options: an absent boolean flag is always `false`. */
13
+ readonly default?: string | number | boolean;
14
+ /**
15
+ * String options only; a value outside the set is a usage error. Ignored
16
+ * (has no effect at the type level or at runtime) for `number`/`boolean`.
17
+ */
18
+ readonly choices?: readonly string[];
19
+ }
20
+
21
+ export type OptionsRecord = Readonly<Record<string, Option>>;
22
+
23
+ export type OptionValue<O extends Option> = O extends {readonly type: 'boolean'}
24
+ ? boolean
25
+ : O extends {readonly type: 'number'}
26
+ ? number
27
+ : O extends {readonly choices: readonly (infer C extends string)[]}
28
+ ? C
29
+ : O extends {readonly type: 'string'}
30
+ ? string
31
+ : never;
32
+
33
+ export type IsPresent<O extends Option> = O extends {readonly type: 'boolean'}
34
+ ? true
35
+ : O extends {readonly required: true}
36
+ ? true
37
+ : O extends {readonly default: string | number | boolean}
38
+ ? true
39
+ : false;
40
+
41
+ export type PresentKeys<O extends OptionsRecord> = {
42
+ [K in keyof O]: IsPresent<O[K]> extends true ? K : never;
43
+ }[keyof O];
44
+
45
+ /** The value a command's `run` receives: present keys required, the rest optional. */
46
+ export type ParsedOptions<O extends OptionsRecord> = {
47
+ [K in PresentKeys<O>]: OptionValue<O[K]>;
48
+ } & {
49
+ [K in Exclude<keyof O, PresentKeys<O>>]?: OptionValue<O[K]>;
50
+ };
51
+
52
+ /**
53
+ * The command's response channel, distinct from `log` (diagnostics). The cli
54
+ * writes it to stdout; the MCP server captures it as the tool result.
55
+ */
56
+ export interface Io {
57
+ write(chunk: string): void;
58
+ }
59
+
60
+ /**
61
+ * What a command is handed at run time. The logger is injected so commands stay
62
+ * callable outside a process; `env` is the source for `assertEnv`.
63
+ */
64
+ export interface CommandContext {
65
+ readonly log: Logger;
66
+ readonly env: NodeJS.ProcessEnv;
67
+ readonly io: Io;
68
+ /**
69
+ * The session channel, present only when the command runs as an MCP tool
70
+ * on a server that has one. A command that pushes must still return a
71
+ * useful result without it: over the CLI there is no session to push to.
72
+ */
73
+ readonly channel?: ChannelSink | undefined;
74
+ }
75
+
76
+ /** What a command may push into the session; see `lib/mcp/channel.mts`. */
77
+ export interface ChannelSink {
78
+ push(
79
+ kind: string,
80
+ meta: Readonly<Record<string, string | null>>,
81
+ content: string
82
+ ): void;
83
+ }
84
+
85
+ /**
86
+ * The transport-neutral command contract. The framework-facing `run` takes an
87
+ * already-validated values record; a subclass overrides it with a signature
88
+ * typed from its own options const (`ParsedOptions<typeof options>`), which
89
+ * method-parameter bivariance accepts.
90
+ */
91
+ export abstract class AbstractCommand {
92
+ abstract readonly name: string;
93
+ abstract readonly summary: string;
94
+ abstract readonly env: readonly string[];
95
+ abstract readonly options: OptionsRecord;
96
+ abstract run(
97
+ parsed: Record<string, unknown>,
98
+ ctx: CommandContext
99
+ ): Promise<void>;
100
+
101
+ /** Transport availability; absent side defaults to available. Read through
102
+ * `resolveTransports`, never directly. */
103
+ readonly transports: {readonly cli?: boolean; readonly mcp?: boolean} = {};
104
+ }