@bridge_gpt/mcp-server 0.2.0 → 0.2.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.
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Local command catalog discovery for the generalized `schedule-run` CLI
3
+ * (BAPI-351). Turns the user's `.claude/commands/*.md` slash-command files into
4
+ * validated, schedulable command payloads.
5
+ *
6
+ * Design constraints (locked in the ticket):
7
+ * - `.claude/commands` is the canonical catalog for BOTH Claude and Cursor; we
8
+ * never scan the Cursor commands directory (a local spike confirmed
9
+ * cursor-agent resolves the same `.claude/commands` catalog).
10
+ * - Commands are schedulable by default; an explicit `schedulable: false`
11
+ * frontmatter marker is the only opt-out.
12
+ * - The per-command `arguments:` frontmatter block must be valid JSON (a strict
13
+ * subset of YAML) parsed with native `JSON.parse()` — NO new YAML/frontmatter
14
+ * parser dependency is added. The parsed shape is validated with Zod, which is
15
+ * already a dependency.
16
+ * - Malformed command files surface as structured errors; discovery never
17
+ * silently falls back to unvalidated freeform scheduling for a broken file.
18
+ */
19
+ import { promises as nodeFs } from "node:fs";
20
+ import path from "node:path";
21
+ import { z } from "zod";
22
+ /** Default fs deps backed by `node:fs/promises`. */
23
+ export function createDefaultCommandCatalogFsDeps() {
24
+ return {
25
+ readdir: (dir) => nodeFs.readdir(dir),
26
+ readFile: (file) => nodeFs.readFile(file, "utf-8"),
27
+ };
28
+ }
29
+ const PositionalZ = z.object({
30
+ name: z.string().min(1),
31
+ type: z.string().default("string"),
32
+ required: z.boolean().default(false),
33
+ variadic: z.boolean().default(false),
34
+ default: z.unknown().optional(),
35
+ });
36
+ const FlagZ = z.object({
37
+ name: z.string().min(1),
38
+ // A flag MUST be written in its long form (e.g. `--auto`); `flag: "auto"` is a
39
+ // common authoring mistake and is rejected so it fails fast at schedule time.
40
+ flag: z.string().regex(/^--[A-Za-z0-9][A-Za-z0-9-]*$/, "flag must start with '--'"),
41
+ type: z.string().default("string"),
42
+ required: z.boolean().default(false),
43
+ repeatable: z.boolean().default(false),
44
+ default: z.unknown().optional(),
45
+ });
46
+ const ArgumentSchemaZ = z.object({
47
+ positionals: z.array(PositionalZ).default([]),
48
+ flags: z.array(FlagZ).default([]),
49
+ allowExtraPositionals: z.boolean().default(false),
50
+ });
51
+ /**
52
+ * Parse a raw `arguments:` value as JSON and validate it with Zod. Non-JSON
53
+ * input (e.g. a YAML block) produces a controlled error — there is no YAML
54
+ * fallback.
55
+ */
56
+ export function parseArgumentsSchema(raw) {
57
+ let parsed;
58
+ try {
59
+ parsed = JSON.parse(raw);
60
+ }
61
+ catch (error) {
62
+ return {
63
+ ok: false,
64
+ error: `invalid JSON in 'arguments:' frontmatter (must be valid JSON, not YAML): ${error instanceof Error ? error.message : String(error)}`,
65
+ };
66
+ }
67
+ const validated = ArgumentSchemaZ.safeParse(parsed);
68
+ if (!validated.success) {
69
+ return {
70
+ ok: false,
71
+ error: `invalid 'arguments:' schema: ${validated.error.issues
72
+ .map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`)
73
+ .join("; ")}`,
74
+ };
75
+ }
76
+ return { ok: true, schema: validated.data };
77
+ }
78
+ /**
79
+ * Split a command markdown file into (optional) leading frontmatter and the body.
80
+ * A file with no leading `---` fence is valid: it has no metadata and the whole
81
+ * file is the body (freeform, schedulable by default). A file that opens a `---`
82
+ * fence but never closes it is malformed and surfaces a structured error.
83
+ */
84
+ function splitFrontmatter(raw) {
85
+ const lines = raw.split(/\r?\n/);
86
+ if (lines.length === 0 || lines[0].trim() !== "---") {
87
+ return { ok: true, frontmatter: null, body: raw };
88
+ }
89
+ let closeIndex = -1;
90
+ for (let i = 1; i < lines.length; i++) {
91
+ if (lines[i].trim() === "---") {
92
+ closeIndex = i;
93
+ break;
94
+ }
95
+ }
96
+ if (closeIndex === -1) {
97
+ return { ok: false, error: "unterminated frontmatter: opening '---' has no closing '---'" };
98
+ }
99
+ const frontmatterText = lines.slice(1, closeIndex).join("\n");
100
+ const body = lines.slice(closeIndex + 1).join("\n");
101
+ const parsed = parseFrontmatterText(frontmatterText);
102
+ if (!parsed.ok)
103
+ return parsed;
104
+ return { ok: true, frontmatter: parsed.frontmatter, body };
105
+ }
106
+ /**
107
+ * Parse the simple `key: value` frontmatter lines we recognize. `schedulable`
108
+ * and `interactive` are scalar booleans read with a narrow line match;
109
+ * `arguments:` is a single-line JSON value validated via {@link parseArgumentsSchema}.
110
+ * Unknown keys are ignored so command files can carry other frontmatter (e.g.
111
+ * Claude Code's own `description:`/`argument-hint:`) without breaking discovery.
112
+ */
113
+ function parseFrontmatterText(text) {
114
+ let schedulable = true;
115
+ let interactive = false;
116
+ let argumentSchema;
117
+ for (const rawLine of text.split("\n")) {
118
+ const line = rawLine.trim();
119
+ if (line === "" || line.startsWith("#"))
120
+ continue;
121
+ const schedulableMatch = line.match(/^schedulable:\s*(true|false)\s*$/);
122
+ if (schedulableMatch) {
123
+ schedulable = schedulableMatch[1] === "true";
124
+ continue;
125
+ }
126
+ const interactiveMatch = line.match(/^interactive:\s*(true|false)\s*$/);
127
+ if (interactiveMatch) {
128
+ interactive = interactiveMatch[1] === "true";
129
+ continue;
130
+ }
131
+ const argsMatch = rawLine.match(/^\s*arguments:\s*(.*)$/);
132
+ if (argsMatch) {
133
+ const value = argsMatch[1].trim();
134
+ if (value === "") {
135
+ return { ok: false, error: "'arguments:' frontmatter key has no JSON value" };
136
+ }
137
+ const schemaResult = parseArgumentsSchema(value);
138
+ if (!schemaResult.ok)
139
+ return schemaResult;
140
+ argumentSchema = schemaResult.schema;
141
+ continue;
142
+ }
143
+ // Unknown frontmatter key: ignore (forward-compatible).
144
+ }
145
+ return { ok: true, frontmatter: { schedulable, interactive, argumentSchema } };
146
+ }
147
+ /** Command names must be a single safe token derived from the filename. */
148
+ const COMMAND_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
149
+ /** Absolute path to the `.claude/commands` directory under a repo. */
150
+ export function commandsDirForRepo(repoPath, platform) {
151
+ const pathApi = platform === "win32" ? path.win32 : path.posix;
152
+ return pathApi.join(repoPath, ".claude", "commands");
153
+ }
154
+ /**
155
+ * Discover every `.claude/commands/*.md` command under `repoPath`. Returns a
156
+ * deterministic (name-sorted) list of valid commands plus a parallel list of
157
+ * structured errors for malformed files. A missing `.claude/commands` directory
158
+ * yields an empty catalog (no error) — the caller decides whether that is fatal.
159
+ *
160
+ * The Cursor commands directory is intentionally never scanned.
161
+ */
162
+ export async function discoverCommandCatalog(repoPath, platform, fsDeps = createDefaultCommandCatalogFsDeps()) {
163
+ const pathApi = platform === "win32" ? path.win32 : path.posix;
164
+ const dir = commandsDirForRepo(repoPath, platform);
165
+ let entries;
166
+ try {
167
+ entries = await fsDeps.readdir(dir);
168
+ }
169
+ catch (error) {
170
+ if (error.code === "ENOENT") {
171
+ return { commands: [], errors: [] };
172
+ }
173
+ throw error;
174
+ }
175
+ const commands = [];
176
+ const errors = [];
177
+ for (const entry of [...entries].sort()) {
178
+ if (!entry.endsWith(".md"))
179
+ continue;
180
+ const name = entry.slice(0, -".md".length);
181
+ const filePath = pathApi.join(dir, entry);
182
+ if (!COMMAND_NAME_PATTERN.test(name)) {
183
+ errors.push({
184
+ name,
185
+ filePath,
186
+ error: `unsafe command name '${name}' (must match ${COMMAND_NAME_PATTERN.source})`,
187
+ });
188
+ continue;
189
+ }
190
+ let raw;
191
+ try {
192
+ raw = await fsDeps.readFile(filePath);
193
+ }
194
+ catch (error) {
195
+ errors.push({
196
+ name,
197
+ filePath,
198
+ error: `could not read command file: ${error instanceof Error ? error.message : String(error)}`,
199
+ });
200
+ continue;
201
+ }
202
+ const split = splitFrontmatter(raw);
203
+ if (!split.ok) {
204
+ errors.push({ name, filePath, error: split.error });
205
+ continue;
206
+ }
207
+ const fm = split.frontmatter;
208
+ commands.push({
209
+ name,
210
+ filePath,
211
+ schedulable: fm?.schedulable ?? true,
212
+ interactive: fm?.interactive ?? false,
213
+ body: split.body,
214
+ argumentSchema: fm?.argumentSchema,
215
+ });
216
+ }
217
+ commands.sort((a, b) => a.name.localeCompare(b.name));
218
+ return { commands, errors };
219
+ }
220
+ /**
221
+ * Resolve a requested command name against a discovered catalog for
222
+ * `schedule-run create`: reject unknown commands, commands that failed to parse
223
+ * (malformed frontmatter / invalid schema), and commands marked
224
+ * `schedulable: false`, each with a clear, actionable message.
225
+ */
226
+ export function resolveSchedulableCommand(catalog, name) {
227
+ const broken = catalog.errors.find((e) => e.name === name);
228
+ if (broken) {
229
+ return {
230
+ ok: false,
231
+ error: `Command '${name}' is not schedulable: ${broken.error}.`,
232
+ };
233
+ }
234
+ const command = catalog.commands.find((c) => c.name === name);
235
+ if (!command) {
236
+ const available = catalog.commands.map((c) => c.name).join(", ") || "(none discovered)";
237
+ return {
238
+ ok: false,
239
+ error: `Unknown command '${name}'. Discovered commands: ${available}.`,
240
+ };
241
+ }
242
+ if (!command.schedulable) {
243
+ return {
244
+ ok: false,
245
+ error: `Command '${name}' is marked 'schedulable: false' and cannot be scheduled.`,
246
+ };
247
+ }
248
+ return { ok: true, command };
249
+ }
250
+ /**
251
+ * Validate and normalize structured post-`--` command argv against an optional
252
+ * argument schema.
253
+ *
254
+ * - No schema → freeform passthrough: tokens are returned verbatim (boundaries
255
+ * preserved) and `parsed.freeform` is true.
256
+ * - With a schema → positional and flag tokens are validated; `--flag=value`
257
+ * forms are normalized to two tokens while preserving the logical value;
258
+ * boolean flags consume no value; repeatable flags collect arrays; unknown
259
+ * flags, missing required args, missing flag values, and disallowed extra
260
+ * positionals each fail with a controlled error.
261
+ *
262
+ * No token is ever concatenated into a shell string; the host shell (or agent)
263
+ * already split argv exactly once before it reached here.
264
+ */
265
+ export function validateCommandArgv(argv, schema) {
266
+ if (!schema) {
267
+ return {
268
+ ok: true,
269
+ normalizedArgv: [...argv],
270
+ parsed: { freeform: true, positionals: {}, extraPositionals: [], flags: {} },
271
+ };
272
+ }
273
+ const flagByLong = new Map();
274
+ for (const f of schema.flags)
275
+ flagByLong.set(f.flag, f);
276
+ const normalizedArgv = [];
277
+ const positionalTokens = [];
278
+ const flags = {};
279
+ for (let i = 0; i < argv.length; i++) {
280
+ const token = argv[i];
281
+ if (token.startsWith("--")) {
282
+ const eqIndex = token.indexOf("=");
283
+ const flagName = eqIndex === -1 ? token : token.slice(0, eqIndex);
284
+ const inlineValue = eqIndex === -1 ? undefined : token.slice(eqIndex + 1);
285
+ const descriptor = flagByLong.get(flagName);
286
+ if (!descriptor) {
287
+ return { ok: false, error: `Unknown flag '${flagName}' for this command.` };
288
+ }
289
+ if (descriptor.type === "boolean") {
290
+ if (inlineValue !== undefined) {
291
+ return { ok: false, error: `Boolean flag '${flagName}' does not take a value.` };
292
+ }
293
+ flags[descriptor.name] = true;
294
+ normalizedArgv.push(flagName);
295
+ continue;
296
+ }
297
+ // string flag — needs a value (inline `=value` or the next token).
298
+ let value;
299
+ if (inlineValue !== undefined) {
300
+ value = inlineValue;
301
+ }
302
+ else {
303
+ if (i + 1 >= argv.length) {
304
+ return { ok: false, error: `Flag '${flagName}' requires a value.` };
305
+ }
306
+ value = argv[++i];
307
+ }
308
+ if (descriptor.repeatable) {
309
+ const existing = flags[descriptor.name];
310
+ if (Array.isArray(existing))
311
+ existing.push(value);
312
+ else
313
+ flags[descriptor.name] = [value];
314
+ }
315
+ else {
316
+ flags[descriptor.name] = value;
317
+ }
318
+ normalizedArgv.push(flagName, value);
319
+ continue;
320
+ }
321
+ positionalTokens.push(token);
322
+ normalizedArgv.push(token);
323
+ }
324
+ // Validate required flags + apply defaults.
325
+ for (const f of schema.flags) {
326
+ if (!(f.name in flags)) {
327
+ if (f.required) {
328
+ return { ok: false, error: `Missing required flag '${f.flag}'.` };
329
+ }
330
+ if (f.default !== undefined) {
331
+ flags[f.name] = f.default;
332
+ }
333
+ }
334
+ }
335
+ // Assign positionals in declared order; a variadic positional consumes the rest.
336
+ const positionals = {};
337
+ let cursor = 0;
338
+ for (const p of schema.positionals) {
339
+ if (p.variadic) {
340
+ const rest = positionalTokens.slice(cursor);
341
+ cursor = positionalTokens.length;
342
+ if (p.required && rest.length === 0) {
343
+ return { ok: false, error: `Missing required positional '${p.name}'.` };
344
+ }
345
+ positionals[p.name] = rest;
346
+ continue;
347
+ }
348
+ if (cursor < positionalTokens.length) {
349
+ positionals[p.name] = positionalTokens[cursor++];
350
+ }
351
+ else if (p.required) {
352
+ return { ok: false, error: `Missing required positional '${p.name}'.` };
353
+ }
354
+ else if (p.default !== undefined) {
355
+ positionals[p.name] = p.default;
356
+ }
357
+ }
358
+ const extraPositionals = positionalTokens.slice(cursor);
359
+ if (extraPositionals.length > 0 && !schema.allowExtraPositionals) {
360
+ return {
361
+ ok: false,
362
+ error: `Unexpected extra positional argument(s): ${extraPositionals.join(", ")}.`,
363
+ };
364
+ }
365
+ return {
366
+ ok: true,
367
+ normalizedArgv,
368
+ parsed: { freeform: false, positionals, extraPositionals, flags },
369
+ };
370
+ }
371
+ /** True when a command schema declares a boolean `--auto` flag (or none/freeform). */
372
+ export function schemaSupportsAutoFlag(schema) {
373
+ if (!schema)
374
+ return false;
375
+ return schema.flags.some((f) => f.flag === "--auto" && f.type === "boolean");
376
+ }