@bridge_gpt/mcp-server 0.2.0 → 0.2.2
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 +56 -54
- package/build/agent-launchers/claude.js +25 -17
- package/build/agent-launchers/cursor.js +65 -0
- package/build/agent-launchers/index.js +23 -8
- package/build/agent-registry.js +68 -0
- package/build/command-catalog.js +376 -0
- package/build/commands.generated.js +8 -5
- package/build/index.js +406 -120
- package/build/mcp-provisioning.js +94 -1
- package/build/pipeline-utils.js +0 -33
- package/build/pipelines.generated.js +2 -31
- package/build/readme.generated.js +3 -0
- package/build/schedule-run.js +436 -88
- package/build/schedule-store.js +41 -1
- package/build/scheduled-prompt.js +109 -0
- package/build/scheduler-backends/at-fallback.js +5 -10
- package/build/scheduler-backends/escaping.js +40 -10
- package/build/scheduler-backends/launchd.js +23 -14
- package/build/scheduler-backends/systemd-user.js +32 -19
- package/build/scheduler-backends/task-scheduler.js +8 -13
- package/build/start-tickets.js +459 -30
- package/build/version.generated.js +1 -1
- package/package.json +4 -3
- package/pipelines/implement-ticket.json +2 -28
- package/smoke-test/SMOKE-TEST.md +61 -18
|
@@ -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
|
+
}
|