@evantahler/mcpx 0.19.2 → 0.20.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.
@@ -47,6 +47,7 @@ This shows parameters, types, required fields, and the full JSON Schema.
47
47
  ```bash
48
48
  mcpx exec <tool> '<json args>' # server auto-resolved if unambiguous
49
49
  mcpx exec <server> <tool> '<json args>' # explicit server (required if tool name exists on multiple servers)
50
+ mcpx exec <server> <tool> -- --field value # shell-flag args (typed via the tool's input schema)
50
51
  mcpx exec <server> <tool> -f params.json
51
52
  ```
52
53
 
@@ -78,6 +79,9 @@ mcpx exec Slack_SendMessage '{"channel":"#general","message":"hello"}'
78
79
  # Or explicitly specify the server
79
80
  mcpx exec arcade Slack_SendMessage '{"channel":"#general","message":"hello"}'
80
81
 
82
+ # Shell-flag form (anything after `--` is parsed against the tool's input schema)
83
+ mcpx exec arcade Slack_SendMessage -- --channel "#general" --message "hello"
84
+
81
85
  # Chain commands — search repos and read the first result
82
86
  mcpx exec github search_repositories '{"query":"mcp"}' \
83
87
  | jq -r '.content[0].text | fromjson | .items[0].full_name' \
@@ -143,6 +147,7 @@ mcpx deauth <server> # remove stored auth
143
147
  | `mcpx exec <server>` | List tools for a server |
144
148
  | `mcpx exec <tool> '<json>'` | Execute tool (server auto-resolved) |
145
149
  | `mcpx exec <server> <tool> '<json>'` | Execute tool (explicit server) |
150
+ | `mcpx exec <server> <tool> -- --k=v` | Execute with shell-flag args (typed via schema) |
146
151
  | `mcpx exec <server> <tool> -f file` | Execute with args from file |
147
152
  | `mcpx search "<query>"` | Search tools (keyword + semantic) |
148
153
  | `mcpx search -k "<pattern>"` | Keyword/glob search only |
@@ -27,6 +27,7 @@ This shows parameters, types, required fields, and the full JSON Schema.
27
27
  ```bash
28
28
  mcpx exec <tool> '<json args>' # server auto-resolved if unambiguous
29
29
  mcpx exec <server> <tool> '<json args>' # explicit server (required if tool name exists on multiple servers)
30
+ mcpx exec <server> <tool> -- --field value # shell-flag args (typed via the tool's input schema)
30
31
  mcpx exec <server> <tool> -f params.json
31
32
  ```
32
33
 
@@ -58,6 +59,9 @@ mcpx exec Slack_SendMessage '{"channel":"#general","message":"hello"}'
58
59
  # Or explicitly specify the server
59
60
  mcpx exec arcade Slack_SendMessage '{"channel":"#general","message":"hello"}'
60
61
 
62
+ # Shell-flag form (anything after `--` is parsed against the tool's input schema)
63
+ mcpx exec arcade Slack_SendMessage -- --channel "#general" --message "hello"
64
+
61
65
  # Chain commands — search repos and read the first result
62
66
  mcpx exec github search_repositories '{"query":"mcp"}' \
63
67
  | jq -r '.content[0].text | fromjson | .items[0].full_name' \
@@ -139,6 +143,7 @@ mcpx deauth <server> # remove stored auth
139
143
  | `mcpx exec <server>` | List tools for a server |
140
144
  | `mcpx exec <tool> '<json>'` | Execute tool (server auto-resolved) |
141
145
  | `mcpx exec <server> <tool> '<json>'` | Execute tool (explicit server) |
146
+ | `mcpx exec <server> <tool> -- --k=v` | Execute with shell-flag args (typed via schema) |
142
147
  | `mcpx exec <server> <tool> -f file` | Execute with args from file |
143
148
  | `mcpx search "<query>"` | Search tools (keyword + semantic) |
144
149
  | `mcpx search -k "<pattern>"` | Keyword/glob search only |
package/README.md CHANGED
@@ -45,11 +45,15 @@ mcpx info github
45
45
  # Inspect a specific tool
46
46
  mcpx info github search_repositories
47
47
 
48
- # Execute a tool
48
+ # Execute a tool (JSON args)
49
49
  mcpx exec github search_repositories '{"query": "mcp server"}'
50
50
 
51
+ # Execute a tool with shell-style flags (anything after `--` is parsed against the tool's input schema)
52
+ mcpx exec github search_repositories -- --query "mcp server"
53
+
51
54
  # Execute a tool without specifying the server (auto-resolved)
52
55
  mcpx exec search_repositories '{"query": "mcp server"}'
56
+ mcpx exec search_repositories -- --query "mcp server"
53
57
 
54
58
  # Search tools — combines keyword and semantic matching
55
59
  mcpx search "post a ticket to linear"
@@ -80,6 +84,7 @@ mcpx search -n 5 "manage pull requests"
80
84
  | `mcpx index -i` | Show index status |
81
85
  | `mcpx exec <server> <tool> [json]` | Validate inputs locally, then execute tool |
82
86
  | `mcpx exec <tool> [json]` | Execute tool (server auto-resolved if unambiguous) |
87
+ | `mcpx exec <server> <tool> -- --k=v` | Shell-flag args (typed via the tool's input schema) |
83
88
  | `mcpx exec <server> <tool> -f file` | Read tool args from a JSON file |
84
89
  | `mcpx exec <server>` | List available tools for a server |
85
90
  | `mcpx auth <server>` | Authenticate with an HTTP MCP server (OAuth) |
@@ -505,6 +510,31 @@ Validation covers:
505
510
 
506
511
  If a tool's `inputSchema` is unavailable (some servers don't provide one), execution proceeds without local validation.
507
512
 
513
+ ### Shell-flag args
514
+
515
+ Anything after a `--` separator is parsed as shell flags using the tool's input schema for type coercion. This is handy for interactive use — you don't need to remember JSON quoting rules.
516
+
517
+ ```bash
518
+ # JSON form
519
+ mcpx exec github create_issue '{"owner":"evantahler","repo":"mcpx","title":"bug"}'
520
+
521
+ # Equivalent shell-flag form
522
+ mcpx exec github create_issue -- --owner evantahler --repo mcpx --title bug
523
+
524
+ # --field=value also works
525
+ mcpx exec github create_issue -- --owner=evantahler --repo=mcpx --title=bug
526
+
527
+ # Booleans
528
+ mcpx exec my-server flagit -- --enabled # true
529
+ mcpx exec my-server flagit -- --no-enabled # false
530
+
531
+ # Arrays — repeatable flag or comma-split
532
+ mcpx exec my-server tag -- --label bug --label todo
533
+ mcpx exec my-server tag -- --label bug,todo
534
+ ```
535
+
536
+ Type coercion follows the field's `type` in the input schema (`string`, `integer`, `number`, `boolean`, `array`). Nested objects must use the JSON form. Combining `--` shell flags with inline JSON args, `--file`, or stdin is rejected.
537
+
508
538
  ## Shell Output & Piping
509
539
 
510
540
  Output is human-friendly by default, JSON when piped:
@@ -635,6 +665,7 @@ To discover tools:
635
665
  To execute tools:
636
666
  mcpx exec <tool> '<json args>' # server auto-resolved
637
667
  mcpx exec <server> <tool> '<json args>' # explicit server
668
+ mcpx exec <server> <tool> -- --k=v # shell-flag args (typed via schema)
638
669
  mcpx exec <server> <tool> -f params.json
639
670
 
640
671
  Always search before executing — don't assume tool names.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evantahler/mcpx",
3
- "version": "0.19.2",
3
+ "version": "0.20.1",
4
4
  "description": "A command-line interface for MCP servers. curl for MCP.",
5
5
  "type": "module",
6
6
  "exports": {
package/src/cli.ts CHANGED
@@ -21,6 +21,7 @@ import { registerServersCommand } from "./commands/servers.ts";
21
21
  import { registerSkillCommand } from "./commands/skill.ts";
22
22
  import { registerTaskCommand } from "./commands/task.ts";
23
23
  import { registerUpgradeCommand } from "./commands/upgrade.ts";
24
+ import { logger } from "./output/logger.ts";
24
25
  import { maybeCheckForUpdate } from "./update/background.ts";
25
26
 
26
27
  program
@@ -96,5 +97,5 @@ program.parse();
96
97
  // Print update notice after command output completes
97
98
  process.on("beforeExit", async () => {
98
99
  const notice = await updateNotice;
99
- if (notice) process.stderr.write(notice);
100
+ if (notice) logger.writeRaw(notice);
100
101
  });
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { logger } from "../output/logger.ts";
2
3
 
3
4
  /**
4
5
  * Open a URL in the default browser (macOS/Windows/Linux).
@@ -12,11 +13,11 @@ export function openBrowser(url: string): Promise<void> {
12
13
  try {
13
14
  const parsed = new URL(url);
14
15
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
15
- process.stderr.write(`Refusing to open non-HTTP URL: ${url}\n`);
16
+ logger.error(`Refusing to open non-HTTP URL: ${url}`);
16
17
  return Promise.resolve();
17
18
  }
18
19
  } catch {
19
- process.stderr.write(`Invalid URL: ${url}\n`);
20
+ logger.error(`Invalid URL: ${url}`);
20
21
  return Promise.resolve();
21
22
  }
22
23
 
@@ -37,7 +38,7 @@ export function openBrowser(url: string): Promise<void> {
37
38
  return new Promise((resolve) => {
38
39
  execFile(cmd, args, (err) => {
39
40
  if (err) {
40
- process.stderr.write(`Could not open browser. Please visit:\n ${url}\n`);
41
+ logger.warn(`Could not open browser. Please visit:\n ${url}`);
41
42
  }
42
43
  resolve();
43
44
  });
@@ -7,6 +7,7 @@ import type {
7
7
  PrimitiveSchemaDefinition,
8
8
  } from "@modelcontextprotocol/sdk/types.js";
9
9
  import ansis from "ansis";
10
+ import { logger } from "../output/logger.ts";
10
11
  import { validateElicitationResponse } from "../validation/schema.ts";
11
12
  import { openBrowser } from "./browser.ts";
12
13
 
@@ -74,7 +75,7 @@ async function handleFormInteractive(params: ElicitRequestFormParams): Promise<E
74
75
  const question = (prompt: string): Promise<string> => new Promise((resolve) => rl.question(prompt, resolve));
75
76
 
76
77
  try {
77
- process.stderr.write(`\n${ansis.bold("Server requests input:")} ${params.message}\n`);
78
+ logger.writeRaw(`\n${ansis.bold("Server requests input:")} ${params.message}\n`);
78
79
 
79
80
  const schema = params.requestedSchema;
80
81
  const properties = schema.properties ?? {};
@@ -86,7 +87,7 @@ async function handleFormInteractive(params: ElicitRequestFormParams): Promise<E
86
87
  const value = await promptField(key, fieldSchema, isRequired, question);
87
88
  if (value === undefined) {
88
89
  if (isRequired) {
89
- process.stderr.write(ansis.yellow("Cancelled.\n"));
90
+ logger.writeRaw(ansis.yellow("Cancelled.\n"));
90
91
  return { action: "cancel" };
91
92
  }
92
93
  continue;
@@ -98,7 +99,7 @@ async function handleFormInteractive(params: ElicitRequestFormParams): Promise<E
98
99
  const validation = validateElicitationResponse(schema as unknown as Record<string, unknown>, content);
99
100
  if (!validation.valid) {
100
101
  const msgs = validation.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n");
101
- process.stderr.write(ansis.red(`Validation failed:\n${msgs}\n`));
102
+ logger.writeRaw(ansis.red(`Validation failed:\n${msgs}\n`));
102
103
  return { action: "cancel" };
103
104
  }
104
105
 
@@ -121,7 +122,7 @@ async function promptField(
121
122
 
122
123
  // Show description if present
123
124
  if (desc) {
124
- process.stderr.write(ansis.dim(` ${desc}\n`));
125
+ logger.writeRaw(ansis.dim(` ${desc}\n`));
125
126
  }
126
127
 
127
128
  // Enum (single-select)
@@ -178,7 +179,7 @@ async function promptNumber(
178
179
  if (!answer) return undefined;
179
180
  const num = Number(answer);
180
181
  if (Number.isNaN(num)) {
181
- process.stderr.write(ansis.red(` Invalid number: ${answer}\n`));
182
+ logger.writeRaw(ansis.red(` Invalid number: ${answer}\n`));
182
183
  return undefined;
183
184
  }
184
185
  return num;
@@ -206,10 +207,10 @@ async function promptEnum(
206
207
  ): Promise<string | undefined> {
207
208
  const values = (schema as { enum: string[] }).enum;
208
209
  const def = (schema as { default?: string }).default;
209
- process.stderr.write(` ${marker}${label}:\n`);
210
+ logger.writeRaw(` ${marker}${label}:\n`);
210
211
  values.forEach((v, i) => {
211
212
  const defMark = v === def ? ansis.dim(" (default)") : "";
212
- process.stderr.write(` [${i + 1}] ${v}${defMark}\n`);
213
+ logger.writeRaw(` [${i + 1}] ${v}${defMark}\n`);
213
214
  });
214
215
  const answer = await question(" > ");
215
216
  if (!answer && def !== undefined) return def;
@@ -228,10 +229,10 @@ async function promptOneOfEnum(
228
229
  ): Promise<string | undefined> {
229
230
  const options = (schema as { oneOf: { const: string; title: string }[] }).oneOf;
230
231
  const def = (schema as { default?: string }).default;
231
- process.stderr.write(` ${marker}${label}:\n`);
232
+ logger.writeRaw(` ${marker}${label}:\n`);
232
233
  options.forEach((opt, i) => {
233
234
  const defMark = opt.const === def ? ansis.dim(" (default)") : "";
234
- process.stderr.write(` [${i + 1}] ${opt.title} (${opt.const})${defMark}\n`);
235
+ logger.writeRaw(` [${i + 1}] ${opt.title} (${opt.const})${defMark}\n`);
235
236
  });
236
237
  const answer = await question(" > ");
237
238
  if (!answer && def !== undefined) return def;
@@ -264,10 +265,10 @@ async function promptMultiSelect(
264
265
  return undefined;
265
266
  }
266
267
 
267
- process.stderr.write(` ${marker}${label} (select multiple, comma-separated):\n`);
268
+ logger.writeRaw(` ${marker}${label} (select multiple, comma-separated):\n`);
268
269
  values.forEach((v, i) => {
269
270
  const display = titles ? `${titles[i]} (${v})` : v;
270
- process.stderr.write(` [${i + 1}] ${display}\n`);
271
+ logger.writeRaw(` [${i + 1}] ${display}\n`);
271
272
  });
272
273
  const answer = await question(" > ");
273
274
  if (!answer && def !== undefined) return def;
@@ -324,10 +325,10 @@ async function handleUrlInteractive(params: ElicitRequestURLParams): Promise<Eli
324
325
  }
325
326
  })();
326
327
 
327
- process.stderr.write(`\n${ansis.bold("Server requests URL interaction:")}\n`);
328
- process.stderr.write(` ${params.message}\n`);
329
- process.stderr.write(` ${ansis.yellow("Domain:")} ${domain}\n`);
330
- process.stderr.write(` ${ansis.yellow("URL:")} ${params.url}\n`);
328
+ logger.writeRaw(`\n${ansis.bold("Server requests URL interaction:")}\n`);
329
+ logger.writeRaw(` ${params.message}\n`);
330
+ logger.writeRaw(` ${ansis.yellow("Domain:")} ${domain}\n`);
331
+ logger.writeRaw(` ${ansis.yellow("URL:")} ${params.url}\n`);
331
332
 
332
333
  const answer = await question(` Open in browser? [y/n]: `);
333
334
  if (["y", "yes"].includes(answer.toLowerCase())) {
@@ -2,7 +2,7 @@ import type { Command } from "commander";
2
2
  import type { ServerManager } from "../client/manager.ts";
3
3
  import { DEFAULTS } from "../constants.ts";
4
4
  import { getContext } from "../context.ts";
5
- import { parseJsonArgs, readStdin } from "../lib/input.ts";
5
+ import { parseJsonArgs, parseShellArgs, readStdin } from "../lib/input.ts";
6
6
  import {
7
7
  formatCallResult,
8
8
  formatError,
@@ -15,17 +15,25 @@ import { validateToolInput } from "../validation/schema.ts";
15
15
 
16
16
  type ResolvedArgs =
17
17
  | { mode: "list-tools"; server: string }
18
- | { mode: "call-tool"; server: string; tool: string; argsStr: string | undefined };
18
+ | {
19
+ mode: "call-tool";
20
+ server: string;
21
+ tool: string;
22
+ rest: string[];
23
+ };
19
24
 
20
25
  /**
21
26
  * Resolve the positional args into either list-tools or call-tool mode.
22
27
  * Supports both `exec <server> <tool> [args]` and `exec <tool> [args]`.
28
+ *
29
+ * `rest` is whatever positional tokens remain after `<server> <tool>`. It may contain
30
+ * a single inline JSON string, or shell-flag tokens (after `--`), or be empty.
23
31
  */
24
32
  async function resolveExecArgs(
25
33
  manager: ServerManager,
26
34
  first: string,
27
35
  second: string | undefined,
28
- third: string | undefined,
36
+ rest: string[],
29
37
  ): Promise<ResolvedArgs> {
30
38
  const serverNames = manager.getServerNames();
31
39
  const isServer = serverNames.includes(first);
@@ -60,10 +68,10 @@ async function resolveExecArgs(
60
68
  }
61
69
  }
62
70
 
63
- return { mode: "call-tool", server: first, tool: second, argsStr: third };
71
+ return { mode: "call-tool", server: first, tool: second, rest };
64
72
  }
65
73
 
66
- // Not a server name — treat first as a tool name
74
+ // Not a server name — treat first as a tool name; `second` is the start of `rest`.
67
75
  const toolName = first;
68
76
  const { tools } = await manager.getAllTools();
69
77
  const matches = tools.filter((t) => t.tool.name === toolName);
@@ -79,28 +87,32 @@ async function resolveExecArgs(
79
87
  );
80
88
  }
81
89
 
82
- return { mode: "call-tool", server: matches[0]!.server, tool: toolName, argsStr: second };
90
+ const fullRest = second === undefined ? rest : [second, ...rest];
91
+ return { mode: "call-tool", server: matches[0]!.server, tool: toolName, rest: fullRest };
83
92
  }
84
93
 
85
94
  export function registerExecCommand(program: Command) {
86
95
  program
87
- .command("exec <first> [second] [third]")
88
- .description("execute a tool (server is optional if tool name is unambiguous)")
96
+ .command("exec <server> [tool] [args...]")
97
+ .description(
98
+ "execute a tool. server may be omitted if the tool name is unambiguous: `mcpx exec <tool> [args...]`. " +
99
+ "args may be a single JSON object string, or shell flags after `--` (e.g. `-- --field value`).",
100
+ )
89
101
  .option("-f, --file <path>", "read JSON args from a file")
90
102
  .option("--no-wait", "return task handle immediately without waiting for completion")
91
103
  .option("--ttl <ms>", "task TTL in milliseconds", String(DEFAULTS.TASK_TTL_MS))
92
104
  .action(
93
105
  async (
94
- first: string,
95
- second: string | undefined,
96
- third: string | undefined,
106
+ serverOrTool: string,
107
+ toolOrFirstArg: string | undefined,
108
+ trailing: string[],
97
109
  options: { file?: string; wait: boolean; ttl: string },
98
110
  ) => {
99
111
  const { manager, formatOptions } = await getContext(program);
100
112
 
101
113
  let resolved: ResolvedArgs;
102
114
  try {
103
- resolved = await resolveExecArgs(manager, first, second, third);
115
+ resolved = await resolveExecArgs(manager, serverOrTool, toolOrFirstArg, trailing);
104
116
  } catch (err) {
105
117
  console.error(formatError(String(err), formatOptions));
106
118
  await manager.close();
@@ -120,15 +132,32 @@ export function registerExecCommand(program: Command) {
120
132
  return;
121
133
  }
122
134
 
123
- const { server, tool, argsStr } = resolved;
135
+ const { server, tool, rest } = resolved;
124
136
 
125
137
  try {
126
- // Error if both --file and positional arg provided
138
+ // Classify the trailing positional tokens. If the first one starts with `--`
139
+ // it's the shell-flag form; otherwise, treat a single token as inline JSON.
140
+ const isShellFlagForm = rest.length > 0 && rest[0]!.startsWith("--");
141
+ const argsStr = !isShellFlagForm && rest.length === 1 ? rest[0] : undefined;
142
+ const shellTokens = isShellFlagForm ? rest : [];
143
+
144
+ // More than one positional token without `--` flag prefix is ambiguous.
145
+ if (!isShellFlagForm && rest.length > 1) {
146
+ throw new Error("Cannot mix inline JSON args with shell flags — use one form");
147
+ }
148
+
149
+ // Conflict checks
127
150
  if (options.file && argsStr) {
128
151
  throw new Error("Cannot specify both --file and inline JSON args");
129
152
  }
153
+ if (shellTokens.length > 0 && options.file) {
154
+ throw new Error("Cannot mix `--` shell flags with --file");
155
+ }
130
156
 
131
- // Parse args from: --file > positional arg > stdin > empty
157
+ // Fetch the tool schema once, up front, so shell-flag parsing can use it for type coercion.
158
+ const toolSchema = await manager.getToolSchema(server, tool);
159
+
160
+ // Parse args from: --file > inline JSON positional > shell flags after `--` > stdin > empty
132
161
  let args: Record<string, unknown> = {};
133
162
 
134
163
  if (options.file) {
@@ -140,6 +169,8 @@ export function registerExecCommand(program: Command) {
140
169
  args = parseJsonArgs(content);
141
170
  } else if (argsStr) {
142
171
  args = parseJsonArgs(argsStr);
172
+ } else if (shellTokens.length > 0) {
173
+ args = parseShellArgs(shellTokens, toolSchema?.inputSchema);
143
174
  } else if (!process.stdin.isTTY) {
144
175
  // Read from stdin
145
176
  const stdin = await readStdin();
@@ -149,7 +180,6 @@ export function registerExecCommand(program: Command) {
149
180
  }
150
181
 
151
182
  // Validate args against tool inputSchema before calling
152
- const toolSchema = await manager.getToolSchema(server, tool);
153
183
  if (toolSchema) {
154
184
  const validation = validateToolInput(server, toolSchema, args);
155
185
  if (!validation.valid) {
@@ -1,6 +1,7 @@
1
1
  import { chmod } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
3
  import { DEFAULT_CONFIG_DIR, ENV } from "../constants.ts";
4
+ import { logger } from "../output/logger.ts";
4
5
  import { interpolateEnv } from "./env.ts";
5
6
  import {
6
7
  type AuthFile,
@@ -64,7 +65,7 @@ export async function loadConfig(options: LoadConfigOptions = {}): Promise<Confi
64
65
  const cwd = process.cwd();
65
66
  if (await hasServersFile(cwd)) {
66
67
  configDir = cwd;
67
- process.stderr.write(`Note: using servers.json from current directory (${cwd})\n`);
68
+ logger.info(`Note: using servers.json from current directory (${cwd})`);
68
69
  }
69
70
  }
70
71
 
package/src/lib/input.ts CHANGED
@@ -31,3 +31,173 @@ export async function readStdin(): Promise<string> {
31
31
  }
32
32
  return chunks.join("");
33
33
  }
34
+
35
+ type SchemaProperty = {
36
+ type?: string | string[];
37
+ items?: { type?: string | string[] };
38
+ };
39
+
40
+ /**
41
+ * Parse shell-style flag tokens into an arguments object, using a JSON Schema for type
42
+ * coercion. Supports:
43
+ * --key value, --key=value
44
+ * --key (boolean true; only when schema says boolean or schema is unknown)
45
+ * --no-key (boolean false; only when `key` is a known boolean field)
46
+ * repeated flags or comma-separated values for arrays
47
+ *
48
+ * Coerces values to integer/number/boolean according to the field's `type` in the
49
+ * schema. For unknown fields (or empty schema), values are left as strings — Ajv
50
+ * will surface the unknown-field error during validation.
51
+ */
52
+ export function parseShellArgs(
53
+ tokens: string[],
54
+ inputSchema: Record<string, unknown> | undefined,
55
+ ): Record<string, unknown> {
56
+ const properties = (inputSchema?.properties ?? {}) as Record<string, SchemaProperty>;
57
+ const result: Record<string, unknown> = {};
58
+ const seen = new Set<string>();
59
+
60
+ function getType(key: string): string | undefined {
61
+ const prop = properties[key];
62
+ if (!prop) return undefined;
63
+ const t = prop.type;
64
+ return Array.isArray(t) ? t[0] : t;
65
+ }
66
+
67
+ function getItemType(key: string): string | undefined {
68
+ const t = properties[key]?.items?.type;
69
+ return Array.isArray(t) ? t[0] : t;
70
+ }
71
+
72
+ function coerceScalar(key: string, raw: string, type: string | undefined): unknown {
73
+ switch (type) {
74
+ case "string":
75
+ return raw;
76
+ case "integer": {
77
+ const n = Number.parseInt(raw, 10);
78
+ if (Number.isNaN(n) || !/^-?\d+$/.test(raw.trim())) {
79
+ throw new Error(`--${key}: expected integer, got "${raw}"`);
80
+ }
81
+ return n;
82
+ }
83
+ case "number": {
84
+ const n = Number.parseFloat(raw);
85
+ if (Number.isNaN(n)) {
86
+ throw new Error(`--${key}: expected number, got "${raw}"`);
87
+ }
88
+ return n;
89
+ }
90
+ case "boolean": {
91
+ const lower = raw.toLowerCase();
92
+ if (lower === "true" || lower === "1" || lower === "") return true;
93
+ if (lower === "false" || lower === "0") return false;
94
+ throw new Error(`--${key}: expected boolean, got "${raw}"`);
95
+ }
96
+ case "object":
97
+ throw new Error(`--${key}: nested objects are not supported as shell flags — use JSON form`);
98
+ default:
99
+ return raw;
100
+ }
101
+ }
102
+
103
+ function assign(key: string, rawValue: string | undefined, isBooleanFlag: boolean): void {
104
+ const type = getType(key);
105
+
106
+ if (type === "array") {
107
+ const itemType = getItemType(key);
108
+ const pieces =
109
+ rawValue === undefined
110
+ ? [""]
111
+ : rawValue
112
+ .split(",")
113
+ .map((s) => s.trim())
114
+ .filter((s) => s.length > 0);
115
+ const coerced = pieces.map((p) => coerceScalar(key, p, itemType));
116
+ const existing = result[key];
117
+ if (Array.isArray(existing)) {
118
+ existing.push(...coerced);
119
+ } else {
120
+ result[key] = coerced;
121
+ }
122
+ return;
123
+ }
124
+
125
+ if (seen.has(key)) {
126
+ throw new Error(`--${key}: specified more than once (use comma-separated values for array fields)`);
127
+ }
128
+ seen.add(key);
129
+
130
+ if (isBooleanFlag) {
131
+ result[key] = true;
132
+ return;
133
+ }
134
+
135
+ if (rawValue === undefined) {
136
+ throw new Error(`--${key}: expected value`);
137
+ }
138
+
139
+ result[key] = coerceScalar(key, rawValue, type);
140
+ }
141
+
142
+ for (let i = 0; i < tokens.length; i++) {
143
+ const token = tokens[i] ?? "";
144
+
145
+ if (!token.startsWith("--")) {
146
+ throw new Error(`unexpected positional argument "${token}" — use --field=value form`);
147
+ }
148
+
149
+ const body = token.slice(2);
150
+ if (body.length === 0) {
151
+ throw new Error('unexpected bare "--" separator');
152
+ }
153
+
154
+ const eqIdx = body.indexOf("=");
155
+ let key: string;
156
+ let inlineValue: string | undefined;
157
+ if (eqIdx === -1) {
158
+ key = body;
159
+ inlineValue = undefined;
160
+ } else {
161
+ key = body.slice(0, eqIdx);
162
+ inlineValue = body.slice(eqIdx + 1);
163
+ }
164
+
165
+ // --no-key form: only treat as negation when `key` (without "no-") is a known boolean
166
+ if (key.startsWith("no-") && inlineValue === undefined) {
167
+ const bareKey = key.slice(3);
168
+ if (getType(bareKey) === "boolean") {
169
+ if (seen.has(bareKey)) {
170
+ throw new Error(`--${bareKey}: specified more than once`);
171
+ }
172
+ seen.add(bareKey);
173
+ result[bareKey] = false;
174
+ continue;
175
+ }
176
+ }
177
+
178
+ const type = getType(key);
179
+ const isBooleanField = type === "boolean";
180
+
181
+ if (inlineValue !== undefined) {
182
+ assign(key, inlineValue, false);
183
+ continue;
184
+ }
185
+
186
+ // No inline value — peek at next token. Booleans without a value mean "true".
187
+ const next = tokens[i + 1];
188
+ const nextLooksLikeFlag = next?.startsWith("--") === true;
189
+ if (isBooleanField && (next === undefined || nextLooksLikeFlag)) {
190
+ assign(key, undefined, true);
191
+ continue;
192
+ }
193
+
194
+ if (next === undefined || nextLooksLikeFlag) {
195
+ throw new Error(`--${key}: expected value`);
196
+ }
197
+
198
+ i++;
199
+ assign(key, next, false);
200
+ }
201
+
202
+ return result;
203
+ }