@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.
- package/.claude-plugin/plugin.json +59 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/agents/.gitkeep +0 -0
- package/agents/build-graph.md +99 -0
- package/agents/milestone-reviewer.md +50 -0
- package/agents/pr-worker.md +172 -0
- package/agents/ticket-worker.md +97 -0
- package/bin/dispatch +101 -0
- package/bin/dispatch-mcp +19 -0
- package/bin/pr-status +931 -0
- package/commands/.gitkeep +0 -0
- package/commands/orchestrate.md +6 -0
- package/hooks/.gitkeep +0 -0
- package/hooks/claim-guard.mts +98 -0
- package/hooks/hooks.json +15 -0
- package/package.json +46 -0
- package/skills/.gitkeep +0 -0
- package/skills/land/SKILL.md +238 -0
- package/skills/land/credentials-dedicated.md +33 -0
- package/skills/land/credentials-shared.md +76 -0
- package/skills/land/mode-solo.md +76 -0
- package/skills/land/mode-team.md +103 -0
- package/skills/land/reference.md +152 -0
- package/skills/land/ticket.md +94 -0
- package/skills/orchestrate/SKILL.md +87 -0
- package/skills/tracker-adapter-linear/SKILL.md +142 -0
- package/src/commands/CLAUDE.md +12 -0
- package/src/commands/claim/check.mts +88 -0
- package/src/commands/claim/guard.mts +95 -0
- package/src/commands/claim/status.mts +49 -0
- package/src/commands/edge/add.mts +44 -0
- package/src/commands/edge/rm.mts +44 -0
- package/src/commands/edge/set.mts +56 -0
- package/src/commands/greet.mts +34 -0
- package/src/commands/mcp/ack.mts +43 -0
- package/src/commands/mcp/ping.mts +61 -0
- package/src/commands/mcp/status.mts +89 -0
- package/src/commands/mcp.mts +155 -0
- package/src/commands/milestone/rm.mts +35 -0
- package/src/commands/milestone/set.mts +49 -0
- package/src/commands/outcome/rm.mts +36 -0
- package/src/commands/outcome/set.mts +86 -0
- package/src/commands/pr/rm.mts +33 -0
- package/src/commands/pr/set.mts +110 -0
- package/src/commands/pr/yield.mts +114 -0
- package/src/commands/project/rm.mts +33 -0
- package/src/commands/project/set.mts +50 -0
- package/src/commands/queue.mts +41 -0
- package/src/commands/refresh/done.mts +42 -0
- package/src/commands/refresh/status.mts +40 -0
- package/src/commands/refresh.mts +56 -0
- package/src/commands/review/record.mts +46 -0
- package/src/commands/review/release.mts +49 -0
- package/src/commands/status.mts +85 -0
- package/src/commands/ticket/missing.mts +31 -0
- package/src/commands/ticket/rm.mts +33 -0
- package/src/commands/ticket/set.mts +134 -0
- package/src/commands/worker/rm.mts +46 -0
- package/src/commands/worker/set.mts +63 -0
- package/src/lib/cli/CLAUDE.md +13 -0
- package/src/lib/cli/cli.mts +226 -0
- package/src/lib/cli/index.mts +1 -0
- package/src/lib/command/CLAUDE.md +26 -0
- package/src/lib/command/__fixtures__/bad-export/oops.mts +1 -0
- package/src/lib/command/__fixtures__/bad-name/mismatch.mts +19 -0
- package/src/lib/command/__fixtures__/commands/cli-only.mts +20 -0
- package/src/lib/command/__fixtures__/commands/greet.mts +39 -0
- package/src/lib/command/__fixtures__/commands/math/add.mts +32 -0
- package/src/lib/command/__fixtures__/commands/mcp-only.mts +20 -0
- package/src/lib/command/__fixtures__/commands/needs-token.mts +19 -0
- package/src/lib/command/__fixtures__/commands/store/get.mts +26 -0
- package/src/lib/command/__fixtures__/commands/store.mts +26 -0
- package/src/lib/command/abstract-command.mts +104 -0
- package/src/lib/command/discovery.mts +100 -0
- package/src/lib/command/env.mts +19 -0
- package/src/lib/command/index.mts +6 -0
- package/src/lib/command/parse.mts +64 -0
- package/src/lib/command/test-support.mts +81 -0
- package/src/lib/command/transports.mts +17 -0
- package/src/lib/command/types.mts +53 -0
- package/src/lib/db/CLAUDE.md +13 -0
- package/src/lib/db/database.mts +160 -0
- package/src/lib/db/index.mts +4 -0
- package/src/lib/db/schema.mts +195 -0
- package/src/lib/db/time.mts +24 -0
- package/src/lib/db/with-database.mts +56 -0
- package/src/lib/errors/CLAUDE.md +18 -0
- package/src/lib/errors/command-error.mts +13 -0
- package/src/lib/errors/data-error.mts +12 -0
- package/src/lib/errors/definition-error.mts +6 -0
- package/src/lib/errors/dispatch-error.mts +27 -0
- package/src/lib/errors/ensure.mts +22 -0
- package/src/lib/errors/environment-error.mts +7 -0
- package/src/lib/errors/index.mts +8 -0
- package/src/lib/errors/json-rpc-error.mts +18 -0
- package/src/lib/errors/usage-error.mts +7 -0
- package/src/lib/graph/CLAUDE.md +17 -0
- package/src/lib/graph/anomalies.mts +110 -0
- package/src/lib/graph/derive.mts +96 -0
- package/src/lib/graph/index.mts +26 -0
- package/src/lib/graph/pipeline.mts +410 -0
- package/src/lib/graph/queries.mts +207 -0
- package/src/lib/graph/rows.mts +99 -0
- package/src/lib/graph/types.mts +137 -0
- package/src/lib/liveness/CLAUDE.md +14 -0
- package/src/lib/liveness/index.mts +10 -0
- package/src/lib/liveness/liveness.mts +147 -0
- package/src/lib/liveness/retire.mts +63 -0
- package/src/lib/logger/CLAUDE.md +12 -0
- package/src/lib/logger/index.mts +2 -0
- package/src/lib/logger/logger.mts +58 -0
- package/src/lib/logger/stream-sink.mts +23 -0
- package/src/lib/mcp/CLAUDE.md +21 -0
- package/src/lib/mcp/channel.mts +41 -0
- package/src/lib/mcp/dispatch.mts +60 -0
- package/src/lib/mcp/drain.mts +83 -0
- package/src/lib/mcp/index.mts +5 -0
- package/src/lib/mcp/mcp.mts +267 -0
- package/src/lib/mcp/tools.mts +77 -0
- package/src/lib/model/CLAUDE.md +8 -0
- package/src/lib/model/index.mts +3 -0
- package/src/lib/model/repo-caps.mts +95 -0
- package/src/lib/model/status.mts +91 -0
- package/src/lib/model/types.mts +83 -0
- package/src/lib/refresh/index.mts +2 -0
- package/src/lib/refresh/placeholders.mts +43 -0
- package/src/lib/refresh/refresh-service.mts +203 -0
- package/src/lib/schedule/CLAUDE.md +18 -0
- package/src/lib/schedule/caps.mts +113 -0
- package/src/lib/schedule/correlate.mts +69 -0
- package/src/lib/schedule/index.mts +7 -0
- package/src/lib/schedule/scheduler.mts +355 -0
- package/src/lib/schedule/tick.mts +266 -0
- package/src/lib/stores/CLAUDE.md +24 -0
- package/src/lib/stores/coordination.mts +359 -0
- package/src/lib/stores/cursor.mts +41 -0
- package/src/lib/stores/edge.mts +138 -0
- package/src/lib/stores/fetch-request.mts +346 -0
- package/src/lib/stores/index.mts +19 -0
- package/src/lib/stores/materialize.mts +69 -0
- package/src/lib/stores/milestone.mts +74 -0
- package/src/lib/stores/notice.mts +57 -0
- package/src/lib/stores/policy.mts +48 -0
- package/src/lib/stores/pr-event.mts +94 -0
- package/src/lib/stores/pr.mts +167 -0
- package/src/lib/stores/project.mts +79 -0
- package/src/lib/stores/refresh.mts +197 -0
- package/src/lib/stores/review.mts +113 -0
- package/src/lib/stores/session.mts +170 -0
- package/src/lib/stores/ticket.mts +246 -0
- package/src/lib/stores/watch.mts +360 -0
- package/src/lib/stores/worker.mts +121 -0
- package/src/lib/watch/adopt.mts +151 -0
- package/src/lib/watch/arm.mts +48 -0
- package/src/lib/watch/cadence.mts +45 -0
- package/src/lib/watch/diff.mts +274 -0
- package/src/lib/watch/index.mts +11 -0
- package/src/lib/watch/marker.mts +24 -0
- package/src/lib/watch/payload.mts +56 -0
- package/src/lib/watch/poll.mts +87 -0
- package/src/lib/watch/render.mts +61 -0
- package/src/lib/watch/snapshot.mts +312 -0
- 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
|
+
}
|