@procedures/mark 0.0.0-stage → 0.2.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 (91) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +21 -0
  3. package/README.md +799 -2
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/args.d.ts +77 -0
  6. package/dist/args.d.ts.map +1 -0
  7. package/dist/args.js +351 -0
  8. package/dist/args.js.map +1 -0
  9. package/dist/ask.d.ts +48 -0
  10. package/dist/ask.d.ts.map +1 -0
  11. package/dist/ask.js +91 -0
  12. package/dist/ask.js.map +1 -0
  13. package/dist/bin.d.ts +9 -0
  14. package/dist/bin.d.ts.map +1 -0
  15. package/dist/bin.js +10 -0
  16. package/dist/bin.js.map +1 -0
  17. package/dist/cli.d.ts +61 -0
  18. package/dist/cli.d.ts.map +1 -0
  19. package/dist/cli.js +535 -0
  20. package/dist/cli.js.map +1 -0
  21. package/dist/client-mode.d.ts +21 -0
  22. package/dist/client-mode.d.ts.map +1 -0
  23. package/dist/client-mode.js +75 -0
  24. package/dist/client-mode.js.map +1 -0
  25. package/dist/ecosystem.d.ts +67 -0
  26. package/dist/ecosystem.d.ts.map +1 -0
  27. package/dist/ecosystem.js +212 -0
  28. package/dist/ecosystem.js.map +1 -0
  29. package/dist/failure.d.ts +16 -0
  30. package/dist/failure.d.ts.map +1 -0
  31. package/dist/failure.js +47 -0
  32. package/dist/failure.js.map +1 -0
  33. package/dist/format.d.ts +30 -0
  34. package/dist/format.d.ts.map +1 -0
  35. package/dist/format.js +125 -0
  36. package/dist/format.js.map +1 -0
  37. package/dist/index.d.ts +13 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +13 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/lockfile.d.ts +26 -0
  42. package/dist/lockfile.d.ts.map +1 -0
  43. package/dist/lockfile.js +68 -0
  44. package/dist/lockfile.js.map +1 -0
  45. package/dist/manifest-build.d.ts +7 -0
  46. package/dist/manifest-build.d.ts.map +1 -0
  47. package/dist/manifest-build.js +11 -0
  48. package/dist/manifest-build.js.map +1 -0
  49. package/dist/manifest.d.ts +57 -0
  50. package/dist/manifest.d.ts.map +1 -0
  51. package/dist/manifest.js +140 -0
  52. package/dist/manifest.js.map +1 -0
  53. package/dist/manifest.json +20960 -0
  54. package/dist/parse.d.ts +143 -0
  55. package/dist/parse.d.ts.map +1 -0
  56. package/dist/parse.js +509 -0
  57. package/dist/parse.js.map +1 -0
  58. package/dist/print.d.ts +42 -0
  59. package/dist/print.d.ts.map +1 -0
  60. package/dist/print.js +80 -0
  61. package/dist/print.js.map +1 -0
  62. package/dist/prompts.d.ts +58 -0
  63. package/dist/prompts.d.ts.map +1 -0
  64. package/dist/prompts.js +124 -0
  65. package/dist/prompts.js.map +1 -0
  66. package/dist/repl.d.ts +34 -0
  67. package/dist/repl.d.ts.map +1 -0
  68. package/dist/repl.js +101 -0
  69. package/dist/repl.js.map +1 -0
  70. package/dist/server-mode.d.ts +37 -0
  71. package/dist/server-mode.d.ts.map +1 -0
  72. package/dist/server-mode.js +167 -0
  73. package/dist/server-mode.js.map +1 -0
  74. package/package.json +97 -3
  75. package/src/args.ts +440 -0
  76. package/src/ask.ts +119 -0
  77. package/src/bin.ts +11 -0
  78. package/src/cli.ts +600 -0
  79. package/src/client-mode.ts +90 -0
  80. package/src/ecosystem.ts +260 -0
  81. package/src/failure.ts +47 -0
  82. package/src/format.ts +177 -0
  83. package/src/index.ts +31 -0
  84. package/src/lockfile.ts +85 -0
  85. package/src/manifest-build.ts +12 -0
  86. package/src/manifest.ts +162 -0
  87. package/src/parse.ts +670 -0
  88. package/src/print.ts +108 -0
  89. package/src/prompts.ts +181 -0
  90. package/src/repl.ts +121 -0
  91. package/src/server-mode.ts +205 -0
package/src/cli.ts ADDED
@@ -0,0 +1,600 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Mark CLI
4
+ *
5
+ * A generic CLI that reflects registered procedures.
6
+ * Uses custom procedure-based routing with modern terminal utilities.
7
+ */
8
+
9
+ import { print } from "./print.js";
10
+ import type { Method, AnyProcedure } from "@procedures/client";
11
+ import { readFileSync, realpathSync } from "node:fs";
12
+ import { dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { fieldsOf, generateHelp, resultFieldsOf, type CLIMeta, type CommandLike } from "./parse.js";
15
+ import { readFreshManifest, writeManifest, type ManifestCommand } from "./manifest.js";
16
+ import { askMissing, canAsk, missingFields, processTerminal, terminalAsker, type Asker, type Terminal } from "./ask.js";
17
+ import { formatOutput, type OutputFormat, type Print } from "./format.js";
18
+ import { loadEcosystemRegistry } from "./ecosystem.js";
19
+ import { startServerMode, parsePort, parseTransport } from "./server-mode.js";
20
+ import { tryClientMode } from "./client-mode.js";
21
+ import { startRepl } from "./repl.js";
22
+ import { findChildren, findProcedure, parseCommandLine, parseLeadingGlobals } from "./args.js";
23
+ import { failureOf } from "./failure.js";
24
+
25
+ /** The version of this package. This file is `dist/cli.js` (or `src/cli.ts` in the tests): the package.json is one folder up. */
26
+ const VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
27
+
28
+ /**
29
+ * Convert procedure path to transport method. The core mapping is the one of every host: the
30
+ * last segment is the operation (deep dive architecture review 3.3). The core loads only for a
31
+ * run in this process: the help and a call to the CLI server do not need it.
32
+ */
33
+ async function pathToMethod(path: string[]): Promise<Method> {
34
+ const { pathToMethod: corePathToMethod } = await import("@procedures/client");
35
+ return corePathToMethod(path);
36
+ }
37
+
38
+ const OUTPUT_FORMATS: readonly OutputFormat[] = ["text", "json", "table", "streaming"];
39
+
40
+ /**
41
+ * Report a problem of the command line or of the command: print it and set exit code 1
42
+ */
43
+ function fail(message: string, hint?: string): void {
44
+ print.error(message);
45
+ if (hint) {
46
+ print.info(hint);
47
+ }
48
+ process.exitCode = 1;
49
+ }
50
+
51
+ /**
52
+ * The message of a validation error: one line for each issue of a Zod error
53
+ */
54
+ function validationMessage(error: unknown): string {
55
+ const issues = (error as { issues?: Array<{ path?: unknown[]; message?: string }> }).issues;
56
+ if (Array.isArray(issues) && issues.length > 0) {
57
+ return issues
58
+ .map((issue) => `${(issue.path ?? []).join(".") || "input"}: ${issue.message ?? "invalid"}`)
59
+ .join("\n ");
60
+ }
61
+ return error instanceof Error ? error.message : String(error);
62
+ }
63
+
64
+ /**
65
+ * Show help for a path (either a command or a group). Returns false for an unknown path.
66
+ */
67
+ function showHelp(procedures: readonly CommandLike[], path: string[]): boolean {
68
+ const proc = findProcedure(procedures, path);
69
+
70
+ if (proc) {
71
+ // Show help for specific command with schema introspection (or the fields of the manifest)
72
+ const meta = (proc.metadata ?? {}) as CLIMeta;
73
+ print.info(generateHelp(path, meta, fieldsOf(proc), resultFieldsOf(proc)));
74
+ return true;
75
+ }
76
+
77
+ // Show help for group
78
+ const children = findChildren(procedures, path);
79
+ if (children.length > 0) {
80
+ const groupName = path.join(" ");
81
+ print.info(`${groupName} commands:\n`);
82
+
83
+ // Direct children only
84
+ const directChildren = children.filter((p) => p.path.length === path.length + 1);
85
+ for (const child of directChildren) {
86
+ const meta = (child.metadata ?? {}) as CLIMeta;
87
+ const cmdName = child.path[child.path.length - 1] ?? "";
88
+ const desc = meta.description ?? "";
89
+ print.info(` mark ${groupName} ${cmdName} ${desc}`);
90
+ }
91
+
92
+ // Child groups
93
+ const childGroups = new Set<string>();
94
+ for (const child of children) {
95
+ if (child.path.length > path.length + 1) {
96
+ const groupSeg = child.path[path.length];
97
+ if (groupSeg) childGroups.add(groupSeg);
98
+ }
99
+ }
100
+ for (const group of childGroups) {
101
+ print.info(` mark ${groupName} ${group} ${group} commands`);
102
+ }
103
+
104
+ print.info(`\nRun 'mark ${groupName} <command> --help' for more info.`);
105
+ return true;
106
+ }
107
+
108
+ // Unknown command
109
+ fail(`Unknown command: mark ${path.join(" ")}`, "Run 'mark --help' for available commands.");
110
+ return false;
111
+ }
112
+
113
+ /**
114
+ * Show root help
115
+ */
116
+ function showRootHelp(procedures: readonly CommandLike[]): void {
117
+ print.info(`mark v${VERSION} - Development workflow automation\n`);
118
+ print.info("Commands:\n");
119
+
120
+ // Group by first path segment
121
+ const groups = new Map<string, CommandLike[]>();
122
+ for (const proc of procedures) {
123
+ const group = proc.path[0] ?? "other";
124
+ if (!groups.has(group)) {
125
+ groups.set(group, []);
126
+ }
127
+ groups.get(group)!.push(proc);
128
+ }
129
+
130
+ for (const [group, procs] of groups) {
131
+ print.info(` ${group}`);
132
+ for (const proc of procs) {
133
+ const meta = (proc.metadata ?? {}) as CLIMeta;
134
+ const cmdPath = proc.path.join(" ");
135
+ const desc = meta.description ?? "";
136
+ print.info(` mark ${cmdPath} ${desc}`);
137
+ }
138
+ print.info("");
139
+ }
140
+
141
+ print.info("Run 'mark <command> --help' for more information.");
142
+ print.info("Hosts: 'mark serve [--procedures pkgs] [--port N] [--expose patterns]' (the procedure server),");
143
+ print.info(" 'mark mcp' (the dev-tools MCP server over stdio).");
144
+ print.info("Modes: 'mark --server [--port N] [--host H] [--transport http|websocket|both]' and 'mark -i' (the REPL).");
145
+ }
146
+
147
+ /**
148
+ * CLI execution context, created once and reused
149
+ */
150
+ export interface CliContext {
151
+ client: InstanceType<typeof import("@procedures/client").Client>;
152
+ procedures: AnyProcedure[];
153
+ verbose: boolean;
154
+ }
155
+
156
+ /**
157
+ * The context of a run that starts from the manifest: the commands are known, the procedures
158
+ * are not loaded. `load()` loads them when the command runs in this process.
159
+ */
160
+ export interface ManifestContext {
161
+ commands: readonly ManifestCommand[];
162
+ verbose: boolean;
163
+ load: () => Promise<CliContext>;
164
+ }
165
+
166
+ /** The terminal and the asker of the questions for missing arguments (tests give fakes) */
167
+ export interface ExecuteOptions {
168
+ terminal?: Terminal;
169
+ ask?: Asker;
170
+ }
171
+
172
+ /**
173
+ * Initialize the CLI context (load ecosystem, create client)
174
+ * This is the expensive part (~4s). Call once, reuse many times.
175
+ */
176
+ export async function initCli(verbose = false): Promise<CliContext> {
177
+ const clientModule = await import("@procedures/client");
178
+ const { Client, LocalTransport } = clientModule;
179
+
180
+ // A registry of its own, from the module of each package (roadmap 3.1): not the global one
181
+ const { registry } = await loadEcosystemRegistry(verbose);
182
+
183
+ // The transport runs each procedure of the registry through invokeProcedure() (ARCHITECTURE-PROPOSALS P1).
184
+ // useRegistry(): exec() of a program (--json) uses the same registry.
185
+ const transport = new LocalTransport({ registry });
186
+ const client = new Client({ transport }).useRegistry(registry);
187
+
188
+ const procedures = registry.getAll();
189
+
190
+ return { client, procedures, verbose };
191
+ }
192
+
193
+ /**
194
+ * Execute a single command given argv tokens.
195
+ * Reusable by both CLI entry point and REPL.
196
+ *
197
+ * The exit code is 1 for an unknown command, a problem of the command line, invalid input, a
198
+ * thrown error, and a result that reports a failure (`success: false`, or an `exitCode` that is
199
+ * not 0: see failure.ts). Before, all of these exited with 0 (deep dive CLI-8).
200
+ *
201
+ * With a ManifestContext, the help and a call to the CLI server need no procedures: they load
202
+ * only when the command runs in this process (roadmap 1.3).
203
+ */
204
+ export async function executeArgs(
205
+ argv: string[],
206
+ ctx: CliContext | ManifestContext,
207
+ options: ExecuteOptions = {}
208
+ ): Promise<void> {
209
+ const procedures: readonly CommandLike[] = "procedures" in ctx ? ctx.procedures : ctx.commands;
210
+ let loaded: CliContext | undefined = "procedures" in ctx ? ctx : undefined;
211
+ const local = async (): Promise<CliContext> => (loaded ??= await (ctx as ManifestContext).load());
212
+
213
+ // Global flags, path and procedure input, converted by the field types (deep dive CLI-7, 9, 10, 11)
214
+ const parsed = parseCommandLine(argv, procedures);
215
+ const { path, procedure, globals } = parsed;
216
+
217
+ // An unknown command is the first problem to report
218
+ if (path.length > 0 && !procedure) {
219
+ const isGroup = findChildren(procedures, path).length > 0;
220
+ if (!isGroup || parsed.extraArguments.length > 0) {
221
+ const words = [...path, ...parsed.extraArguments.slice(0, 1)];
222
+ fail(`Unknown command: mark ${words.join(" ")}`, "Run 'mark --help' for available commands.");
223
+ return;
224
+ }
225
+ }
226
+
227
+ if (parsed.errors.length > 0) {
228
+ for (const error of parsed.errors) {
229
+ print.error(error);
230
+ }
231
+ const helpPath = path.length > 0 ? `mark ${path.join(" ")} --help` : "mark --help";
232
+ print.info(`Run '${helpPath}' for the options.`);
233
+ process.exitCode = 1;
234
+ return;
235
+ }
236
+
237
+ // The modes start before the procedures load: here they are misplaced
238
+ if (globals.server || globals.interactive) {
239
+ fail("--server and -i start a mode of mark: give them alone, before any command.");
240
+ return;
241
+ }
242
+ if (globals.port !== undefined || globals.host !== undefined || globals.transport !== undefined) {
243
+ fail("--port, --host and --transport are options of --server.");
244
+ return;
245
+ }
246
+
247
+ if (globals.version) {
248
+ print.info(`mark v${VERSION}`);
249
+ return;
250
+ }
251
+
252
+ // Output format: --format/-f, unless the procedure declares a flag of that name
253
+ const formatOverride = globals.format === undefined ? undefined : String(globals.format);
254
+ if (formatOverride !== undefined && !OUTPUT_FORMATS.includes(formatOverride as OutputFormat)) {
255
+ fail(`Invalid format: ${formatOverride}. Valid formats: ${OUTPUT_FORMATS.join(", ")}`);
256
+ return;
257
+ }
258
+
259
+ // Handle --json flag for raw procedure reference execution
260
+ if (globals.json !== undefined) {
261
+ let parsedJson: unknown;
262
+ try {
263
+ parsedJson = JSON.parse(String(globals.json));
264
+ } catch (e) {
265
+ fail(`Failed to parse --json input: ${e instanceof Error ? e.message : String(e)}`);
266
+ return;
267
+ }
268
+ if (!parsedJson || typeof parsedJson !== "object" || !("$proc" in parsedJson)) {
269
+ fail('--json needs a procedure reference: an object with "$proc".');
270
+ return;
271
+ }
272
+ try {
273
+ const { client } = await local();
274
+ const result = await client.exec(parsedJson as Parameters<typeof client.exec>[0]);
275
+ formatOutput(print as unknown as Print, result, "json");
276
+ if (failureOf(result)) {
277
+ process.exitCode = 1;
278
+ }
279
+ } catch (error) {
280
+ fail(`Error: ${error instanceof Error ? error.message : String(error)}`);
281
+ }
282
+ return;
283
+ }
284
+
285
+ // Handle root help
286
+ if (path.length === 0) {
287
+ showRootHelp(procedures);
288
+ return;
289
+ }
290
+
291
+ // Help for a command or a group (a group without a command shows its help too)
292
+ if (globals.help || !procedure) {
293
+ showHelp(procedures, path);
294
+ return;
295
+ }
296
+
297
+ const meta = (procedure.metadata ?? {}) as CLIMeta;
298
+ const outputFormat = (formatOverride ?? meta.output ?? "text") as OutputFormat;
299
+
300
+ // A required argument that is missing: in a terminal, mark asks for it (see ask.ts)
301
+ let input = parsed.input;
302
+ const missing = missingFields(fieldsOf(procedure), input);
303
+ if (missing.length > 0 && canAsk(globals["no-input"] === true, meta, options.terminal ?? processTerminal())) {
304
+ const asked = await askMissing(missing, input, options.ask ?? terminalAsker);
305
+ if (!asked.ok) {
306
+ fail(asked.cancelled ? "Cancelled" : `Invalid input for mark ${path.join(" ")}:\n ${asked.error}`);
307
+ return;
308
+ }
309
+ input = asked.input;
310
+ }
311
+
312
+ // A procedure of the registry validates the input here. A command of the manifest has no
313
+ // schema: the CLI server validates, or this process after the procedures load.
314
+ let validated: unknown = input;
315
+ let checked = false;
316
+ const validate = (target: CommandLike): boolean => {
317
+ const schema = target.input as { parse?: (data: unknown) => unknown } | undefined;
318
+ try {
319
+ if (typeof schema?.parse === "function") {
320
+ validated = schema.parse(input);
321
+ }
322
+ } catch (error) {
323
+ fail(`Invalid input for mark ${path.join(" ")}:\n ${validationMessage(error)}`, `Run 'mark ${path.join(" ")} --help' for the options.`);
324
+ return false;
325
+ }
326
+ checked = true;
327
+ return true;
328
+ };
329
+ if (loaded) {
330
+ if (!validate(procedure)) {
331
+ return;
332
+ }
333
+ } else {
334
+ const stillMissing = missingFields(fieldsOf(procedure), input);
335
+ if (stillMissing.length > 0) {
336
+ const names = stillMissing.map((field) => `${field.name}: Required`).join("\n ");
337
+ fail(`Invalid input for mark ${path.join(" ")}:\n ${names}`, `Run 'mark ${path.join(" ")} --help' for the options.`);
338
+ return;
339
+ }
340
+ }
341
+
342
+ // Each item prints when it arrives. A failed item makes the command fail.
343
+ let failure: string | undefined;
344
+ const printItem = (item: unknown): void => {
345
+ formatOutput(print as unknown as Print, item, outputFormat);
346
+ failure ??= failureOf(item);
347
+ };
348
+ const reportFailure = (): void => {
349
+ if (failure !== undefined) {
350
+ fail(`mark ${path.join(" ")} failed: ${failure}`);
351
+ }
352
+ };
353
+
354
+ // Try client mode: connect to running server if available (unless --local)
355
+ if (!globals.local) {
356
+ const clientResult = await tryClientMode(path, validated, printItem);
357
+ if (clientResult !== null) {
358
+ if (clientResult.success) {
359
+ if (!clientResult.printed) {
360
+ printItem(clientResult.result);
361
+ }
362
+ reportFailure();
363
+ return;
364
+ }
365
+ // The server ran (or started to run) the command and it failed. Running it again
366
+ // locally could repeat its side effects, so report the error instead.
367
+ fail(clientResult.error ?? "The command failed on the CLI server.");
368
+ return;
369
+ }
370
+ }
371
+
372
+ // The command runs in this process: the procedures load now (a ManifestContext)
373
+ let runtime: CliContext;
374
+ try {
375
+ runtime = await local();
376
+ } catch (error) {
377
+ fail(`Error: ${error instanceof Error ? error.message : String(error)}`);
378
+ return;
379
+ }
380
+ const target = findProcedure(runtime.procedures, path);
381
+ if (!target) {
382
+ fail(`Unknown command: mark ${path.join(" ")}`, "Run 'mark --help' for available commands.");
383
+ return;
384
+ }
385
+ if (!checked && !validate(target)) {
386
+ return;
387
+ }
388
+
389
+ try {
390
+ let spinner: ReturnType<typeof print.spin> | undefined;
391
+
392
+ if (outputFormat === "streaming") {
393
+ spinner = print.spin(`Running ${path.join(" ")}...`);
394
+ }
395
+
396
+ const method = await pathToMethod(path);
397
+ const { client } = runtime;
398
+
399
+ // A streaming procedure gives many items: print each one when it arrives. A
400
+ // request/response procedure gives one item, so its output does not change.
401
+ let items = 0;
402
+ for await (const item of client.stream(method, validated)) {
403
+ if (spinner && items === 0) spinner.stop();
404
+ items++;
405
+ printItem(item);
406
+ }
407
+
408
+ if (spinner) {
409
+ if (failure === undefined) {
410
+ spinner.succeed(`${path.join(" ")} complete`);
411
+ } else {
412
+ spinner.fail(`${path.join(" ")} failed`);
413
+ }
414
+ }
415
+ reportFailure();
416
+ } catch (error) {
417
+ fail(`Error: ${error instanceof Error ? error.message : String(error)}`);
418
+ }
419
+ }
420
+
421
+ const MCP_HELP = `Usage: mark mcp [options]
422
+
423
+ Start the dev-tools MCP server over stdio (the server of Claude Code's MCP configuration).
424
+ It has a registry of its own, from the modules of its bundles, and the host policy of an MCP
425
+ server: only the tools of the bundles, strict nested calls, and a time limit for each call.
426
+
427
+ Options:
428
+ --bundles <list> Comma-separated bundles (default: MCP_BUNDLES, or @procedures/bundle-mcp)
429
+ --tool-timeout <ms> The time limit of each tool call (default: MCP_TOOL_TIMEOUT_MS, or 600000)
430
+ --read-only Only the tools that change nothing (default: MCP_READ_ONLY)
431
+ --debug Write debug lines to stderr
432
+ -h, --help Show this help
433
+
434
+ \`mark mcp <procedure>\` runs an mcp.* procedure instead (mark mcp serve, mark mcp listTools).`;
435
+
436
+ /**
437
+ * Start the dev-tools MCP server (`mark mcp`). stdout carries the protocol: this function
438
+ * writes nothing to stdout, and the server sends console.log to stderr.
439
+ */
440
+ async function startMcp(args: string[]): Promise<void> {
441
+ const { loadConfig, startDevToolsServer } = await import("@procedures/mcp-dev-tools");
442
+ const config = loadConfig();
443
+ for (let i = 0; i < args.length; i++) {
444
+ const arg = args[i]!;
445
+ const eq = arg.indexOf("=");
446
+ const name = eq === -1 ? arg : arg.slice(0, eq);
447
+ const value = (): string => {
448
+ if (eq !== -1) return arg.slice(eq + 1);
449
+ const next = args[++i];
450
+ if (next === undefined) throw new Error(`${name} needs a value`);
451
+ return next;
452
+ };
453
+ if (name === "-h" || name === "--help") {
454
+ process.stderr.write(`${MCP_HELP}\n`);
455
+ return;
456
+ } else if (name === "--bundles") {
457
+ config.bundles = value().split(",").map((bundle) => bundle.trim()).filter(Boolean);
458
+ } else if (name === "--tool-timeout") {
459
+ const ms = Number(value());
460
+ if (!Number.isFinite(ms) || ms < 0) throw new Error(`--tool-timeout needs a number of milliseconds`);
461
+ config.toolTimeoutMs = ms;
462
+ } else if (name === "--read-only") {
463
+ config.readOnly = true;
464
+ } else if (name === "--debug") {
465
+ config.debug = true;
466
+ } else {
467
+ throw new Error(`Unknown option of mark mcp: ${arg}. Run 'mark mcp --help'.`);
468
+ }
469
+ }
470
+ // The server reads stdin until the client closes it: the process stays alive until then
471
+ await startDevToolsServer(config);
472
+ }
473
+
474
+ /**
475
+ * Run the CLI
476
+ */
477
+ async function run(argv: string[]): Promise<void> {
478
+ // The global flags before the command: read without loading the procedures
479
+ const leading = parseLeadingGlobals(argv);
480
+ if (leading.errors.length > 0) {
481
+ for (const error of leading.errors) {
482
+ print.error(error);
483
+ }
484
+ print.info("Run 'mark --help' for the options.");
485
+ process.exitCode = 1;
486
+ return;
487
+ }
488
+ const globals = leading.globals;
489
+ const verbose = globals.verbose === true;
490
+
491
+ // Handle --version early (no imports needed)
492
+ if (globals.version) {
493
+ print.info(`mark v${VERSION}`);
494
+ return;
495
+ }
496
+
497
+ // Handle --server flag: start server mode
498
+ if (globals.server) {
499
+ if (leading.rest.length > 0) {
500
+ fail(`--server takes no command: ${leading.rest.join(" ")}`);
501
+ return;
502
+ }
503
+ const port = globals.port === undefined ? 3000 : parsePort(String(globals.port));
504
+ // Loopback unless --host says otherwise (deep dive CLI-1)
505
+ const host = globals.host === undefined ? "127.0.0.1" : String(globals.host);
506
+ const transport = globals.transport === undefined ? "http" : parseTransport(String(globals.transport));
507
+ await startServerMode({ port, host, transport, verbose });
508
+ return;
509
+ }
510
+
511
+ // Handle -i / --interactive flag: start REPL
512
+ if (globals.interactive) {
513
+ if (leading.rest.length > 0) {
514
+ fail(`-i takes no command: ${leading.rest.join(" ")}`);
515
+ return;
516
+ }
517
+ await startRepl({ verbose });
518
+ return;
519
+ }
520
+
521
+ // The front door (roadmap 2.3): `mark serve` starts the procedure server (the `server` CLI),
522
+ // and `mark mcp` starts the dev-tools MCP server over stdio (the server of impl-mcp-dev).
523
+ // `mark mcp <procedure>` still runs an mcp.* procedure (`mark mcp serve`, `mark mcp listTools`).
524
+ const [command, ...commandArgs] = leading.rest;
525
+ if (command === "serve") {
526
+ const { runServer } = await import("@procedures/server");
527
+ // A --procedures package resolves from the current folder, then from mark, which depends on
528
+ // every procedure package of the workspace
529
+ const markDir = dirname(dirname(fileURLToPath(import.meta.url)));
530
+ await runServer(commandArgs, { resolveFrom: [process.cwd(), markDir] });
531
+ return;
532
+ }
533
+ if (command === "mcp" && commandArgs.every((arg) => arg.startsWith("-"))) {
534
+ await startMcp(commandArgs);
535
+ return;
536
+ }
537
+
538
+ // The manifest gives the commands without the procedures: the help and a call to the CLI
539
+ // server do not load them (roadmap 1.3)
540
+ const manifest = readFreshManifest();
541
+ if (manifest) {
542
+ await executeArgs(argv, { commands: manifest.commands, verbose, load: () => initCli(verbose) });
543
+ return;
544
+ }
545
+
546
+ // No manifest, or a stale one (a package was built again): load the procedures as before, and
547
+ // write the manifest for the next run. A failure to write costs only time.
548
+ const ctx = await initCli(verbose);
549
+ try {
550
+ await writeManifest(ctx.procedures);
551
+ } catch (error) {
552
+ if (verbose) {
553
+ print.warning(`mark: the manifest was not written: ${error instanceof Error ? error.message : String(error)}`);
554
+ }
555
+ }
556
+ await executeArgs(argv, ctx);
557
+ }
558
+
559
+ /**
560
+ * Run the CLI and report an error that escapes it as one line, with exit code 1. With
561
+ * `--verbose`, the stack follows. (Before, an error such as an invalid port showed only an
562
+ * unhandled-rejection stack: deep dive CLI-16.)
563
+ */
564
+ export async function main(argv: readonly string[]): Promise<void> {
565
+ try {
566
+ await run([...argv]);
567
+ } catch (error) {
568
+ print.error(error instanceof Error ? error.message : String(error));
569
+ if ((argv.includes("--verbose") || argv.includes("-V")) && error instanceof Error && error.stack) {
570
+ print.muted(error.stack);
571
+ }
572
+ process.exitCode = 1;
573
+ }
574
+ }
575
+
576
+ /**
577
+ * True when Node runs this file itself (`node dist/cli.js`), and false when a module imports it.
578
+ * The `mark` bin is `dist/bin.js`. `dist/cli.js` stays runnable for the documented commands
579
+ * (`node packages/mark/dist/cli.js lib new <name>`).
580
+ */
581
+ function isEntryPoint(): boolean {
582
+ const entry = process.argv[1];
583
+ if (!entry) {
584
+ return false;
585
+ }
586
+ try {
587
+ const self = fileURLToPath(import.meta.url);
588
+ const target = realpathSync(entry);
589
+ return process.platform === "win32" ? self.toLowerCase() === target.toLowerCase() : self === target;
590
+ } catch {
591
+ return false;
592
+ }
593
+ }
594
+
595
+ // Importing @procedures/mark does not run the CLI (deep dive CLI-17)
596
+ if (isEntryPoint()) {
597
+ await main(process.argv.slice(2));
598
+ }
599
+
600
+ export { run };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * CLI Client Mode
3
+ *
4
+ * Connects to a running CLI server and executes commands remotely.
5
+ * Falls back to local execution if no server is available.
6
+ */
7
+
8
+ import { currentBuild, findServer } from "./lockfile.js";
9
+
10
+ interface ClientModeResult {
11
+ success: boolean;
12
+ result?: unknown;
13
+ error?: string;
14
+ /** The onItem callback printed each item already */
15
+ printed?: boolean;
16
+ }
17
+
18
+ /**
19
+ * Try to execute command via running server. The input is the procedure input that the CLI
20
+ * parsed and validated already.
21
+ * Returns null if no server available (should fall back to local)
22
+ */
23
+ export async function tryClientMode(
24
+ path: string[],
25
+ input: unknown,
26
+ onItem?: (item: unknown) => void
27
+ ): Promise<ClientModeResult | null> {
28
+ // A running server of this build, started in this folder, that answers as the peer of its
29
+ // lockfile and has a token. Otherwise the CLI runs the command itself. (Before, any server
30
+ // with a live PID was used: relative paths resolved in the server's folder, an old build ran
31
+ // after a rebuild, and a reused PID broke every command: deep dive CLI-3, CLI-4, CLI-14.)
32
+ // server.* manages the servers themselves: it always runs here. (Through the server,
33
+ // `mark server stop` ran server.stop inside the server it stopped: no answer, no cleanup.)
34
+ if (path[0] === "server") {
35
+ return null;
36
+ }
37
+ const lockfile = await findServer({ cwd: process.cwd(), build: currentBuild() });
38
+ // mark sends its commands over HTTP: a WebSocket-only server cannot take them
39
+ if (!lockfile?.token || lockfile.transport === "websocket") {
40
+ return null;
41
+ }
42
+
43
+ let client: InstanceType<typeof import("@procedures/client").Client>;
44
+ let method: { service: string; operation: string };
45
+ try {
46
+ // Dynamic import client
47
+ const clientModule = await import("@procedures/client");
48
+ const { Client, HttpTransport } = clientModule;
49
+
50
+ // Connect to server
51
+ const transport = new HttpTransport({
52
+ baseUrl: lockfile.endpoint,
53
+ defaultHeaders: { Authorization: `Bearer ${lockfile.token}` },
54
+ });
55
+ client = new Client({ transport });
56
+
57
+ // The method of the path as ProcedureServer registers it: the last segment is the operation.
58
+ // (Before, the first segment was the service, so a path of three segments - docker compose up -
59
+ // was not found on the server.)
60
+ method = { service: path.slice(0, -1).join("."), operation: path[path.length - 1]! };
61
+ } catch {
62
+ // Nothing was sent to the server yet - fall back to local execution
63
+ return null;
64
+ }
65
+
66
+ // Execute remotely. From here on, do not fall back to local execution: the server may
67
+ // have run (part of) the command, and running it again would repeat its side effects.
68
+ try {
69
+ // Each item of the response as it arrives (a streaming procedure gives many). The result
70
+ // is the last item.
71
+ let last: { value: unknown } | undefined;
72
+ for await (const item of client.stream(method, input)) {
73
+ last = { value: item };
74
+ onItem?.(item);
75
+ }
76
+ if (!last) {
77
+ throw new Error("No response received from the CLI server");
78
+ }
79
+ return {
80
+ success: true,
81
+ result: last.value,
82
+ printed: onItem !== undefined,
83
+ };
84
+ } catch (error) {
85
+ return {
86
+ success: false,
87
+ error: error instanceof Error ? error.message : String(error),
88
+ };
89
+ }
90
+ }