Skip to content

fix(generate-cli): emit flag names commander can parse - #332

Open
Yigtwxx wants to merge 11 commits into
openclaw:mainfrom
Yigtwxx:fix/generate-cli-flag-grammar
Open

fix(generate-cli): emit flag names commander can parse#332
Yigtwxx wants to merge 11 commits into
openclaw:mainfrom
Yigtwxx:fix/generate-cli-flag-grammar

Conversation

@Yigtwxx

@Yigtwxx Yigtwxx commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Summary

mcporter generate-cli derives every flag name from the tool's JSON Schema property name. Two legal property spellings produce a flag that commander does not read the way the generated code expects.

The first one is fatal. A property whose name starts with an uppercase letter produces ---query, which commander rejects while it is still constructing the option, so the generated artifact throws at module load and no command in it can run, not even --help. Uppercase property names are what the .NET and Java MCP server SDKs emit by default.

Root cause

toCliOption prefixes a dash for every uppercase character with no guard for the leading position, and buildPlaceholder repeats the same expression instead of calling it:

// src/cli/generate/tools.ts:425
export function toCliOption(property: string): string {
  return property.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`).replace(/_/g, '-');
}

// src/cli/generate/tools.ts:168
const normalized = property.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`).replace(/_/g, '-');

Query becomes -query, so the emitted line is .option("---query <-query>", ...):

Error: option creation failed due to '---query' in option flags '---query <-query>'
- unrecognised flag format
    at splitOptionFlags (commander/lib/option.js:368:11)
    at new Option (commander/lib/option.js:20:25)
    at Command.createOption (commander/lib/command.js:587:12)

The second spelling fails more quietly. no_cache becomes --no-cache, which commander treats as a negated boolean:

new Option('--no-cache <no-cache:true|false>').attributeName()  ->  'cache'   (negate = true)

The generated command reads the key it computed itself, cmdOpts.noCache (src/cli/generate/template.ts:449), which is therefore always undefined. Commander also gives a negated option an implicit true default, so the argument is dropped when the option is optional and reported as missing when it is required, even though the user supplied it.

Fix

Derive the flag and its placeholder from one helper and drop a leading dash, so the name commander is handed is always a valid long flag.

For the --no- case the first revision escaped the flag to --nocache; review pointed out that this collapses onto a nocache property a schema may legally declare alongside no_cache, emitting two identical flags. Any escape can collide with some other legal name, so the flag is now spelled straight from the property and the generated module builds each option explicitly instead:

function defineOption(flags, description, parser) {
  const option = new Option(flags, description);
  if (parser) option.argParser(parser);
  option.negate = false;
  return option;
}

attributeName() is then noCache, which is the key the generated command already computes, and an absent flag stays undefined rather than defaulting to true.

Collision: two spellings that normalize onto one flag

Fifth commit. Query beside query, or no_cache beside noCache, are distinct legal properties that both normalize to one flag, and commander refuses a command that declares the same flag twice, so addOption throws while the command is being built:

Error: Cannot add option '--query <query>' to command 'search' due to conflicting flag '--query'
-  already used by option '--query <query>'

This one is not new here - a no_cache/noCache schema already dies that way on ae3d900 - but Query used to crash on ---query before any collision could matter, so fixing that spelling exposes the pair. Flag names are therefore assigned across the whole property list rather than per property: schema order keeps the plain flag, and a later property takes the first -2, -3, ... suffix that no other property claims naturally, so a schema that also declares query_2 does not get a second --query-2. The placeholder is built from the assigned name, and the generated command keeps reading args.<property> from the key commander stores that flag under.

Empty flag name: a property whose characters all normalize away

Sixth and seventh commits. ___ is a legal property name whose every character normalizes away, leaving an empty flag name, and the empty name is inherited by the suffix pass as well, so a schema declaring _ beside it emits ---2:

.addOption(defineOption("-- <>", "Underscore-only property"))
.addOption(defineOption("---2 <2:true|false>", "Single underscore property", ...))

new Option('-- <>')  ->  Error: option creation failed due to '--' in option flags '-- <>'

The stem is therefore assigned inside toCliOption, before names reach the collision pass, so it participates in flag assignment like any natural name and a schema declaring option beside ___ still gets two distinct flags.

The empty name is also spliced into the generated source as a property access, so the artifact fails to transpile before commander is reached - cmdOpts. and args.___ = cmdOpts.;. That half outlives the stem fix for one more spelling: 2fa is legal, --2fa is a flag commander accepts and stores under the key 2fa, but cmdOpts.2fa does not parse. Seventh commit, droppable on its own - a key that cannot be spelled as a property access is read through a subscript, and every key that can be is emitted unchanged:

function propertyAccess(target: string, key: string): string {
  return /^[A-Za-z_$][\w$]*$/.test(key) ? `${target}.${key}` : `${target}[${JSON.stringify(key)}]`;
}

A fixture declaring all four properties at once:

before  $ ./proof-cli.ts --help
        Error [TransformError]: Transform failed with 1 error:
        proof-cli.ts:134:46: ERROR: Expected identifier but found ","

after   $ ./proof-cli.ts --help
        Embedded tools
          verify - Verify a code
            --option <option> [--option-2 <option-2:true|false>] [--option-3 <option-3>]
            --2fa <2fa> [--raw <json>]

        $ ./proof-cli.ts verify --option underscores --option-2 true             --option-3 "named option" --2fa 123456
        {"___":"underscores","_":true,"option":"named option","2fa":"123456"}

Empty flag segment: a separator run or a trailing separator

Eighth commit, from review. Commander derives an option's storage key by splitting the flag on dashes and upper-casing each segment's first character, so a segment that is empty has no character to read:

new Option('--foo--bar <v>')                       ->  fine, construction never inspects the name
new Command('t').addOption(new Option('--foo--bar <v>'))
  TypeError: Cannot read properties of undefined (reading 'toUpperCase')
      at Option.attributeName (commander/lib/option.js:221:12)
      at Command.addOption (commander/lib/command.js:675:25)

Three property shapes reached such a segment: foo__bar (a run), baz_ (a trailing separator) and filter_Query, which reaches a run by mixing the two naming conventions rather than by repeating one - the uppercase rule emits -q next to the underscore's own dash. Runs are collapsed and one dash is trimmed from either end before names reach the collision pass, so foo__bar beside foo_bar still gets two flags. Enumerating every property name up to five characters over a B _ - 2 . and registering each emitted flag on a real Command rejects none of the 9330.

Unlike the leading-dash case this one is invisible at construction, so it is pinned at Command.addOption rather than at new Option.

Adjacent finding: a nullable array loses its item type and its enum members

Second commit, drop it if you would rather not take it - one helper plus two call sites.

inferType already normalizes a union type such as ["array", "null"] to array (pinned by tests/generate-cli-helpers.test.ts:137), but the two container checks beside it compare the raw value:

// src/cli/generate/tools.ts:347
if (record.type !== 'array' || !record.items || typeof record.items !== 'object') {
  return 'unknown';
}

// src/cli/generate/tools.ts:138
if (record.type === 'array' && typeof record.items === 'object' && record.items !== null) {

The option is still generated as an array while its item type and enum members are dropped, so the signature, the placeholder, the help choices and the call example all disagree for one descriptor. For a numeric variant the generated parser then picks parseArrayOption(value, 'string'), so --scores 1,2 reaches the tool as ["1","2"].

Adjacent finding: a multi-word outputSchema.title makes the emitted TypeScript unparseable

Third and fourth commits, also droppable - one helper in list-signature.ts.

inferSchemaDisplayType returns the raw title and emit-ts splices that value into the emitted interface as a type name, so {"title":"Search Results"} produces Promise<Search Results> and the emitted .d.ts does not parse. This only changes how a title is folded into an identifier; it does not change the existing decision to use the title as the type name.

Review pointed out that a title spelling a reserved word, such as class, already matches the identifier pattern and so was still emitted verbatim as Promise<class>. Reserved words now fail that test and fold like any other non-identifier title, so the title survives as Class instead of being dropped. The set was measured with tsc rather than assumed: 30 reserved words fail to parse in that position, and void, null, true, false and this parse into a keyword type that no longer describes the schema.

Behavior proof

One stdio fixture, one tool, exercising all three cases at once:

{"name":"search","description":"Search the web",
 "inputSchema":{"type":"object","required":["Query","no_cache"],"properties":{
   "Query":    {"type":"string","description":"Search text"},
   "no_cache": {"type":"boolean","description":"Bypass the cache"},
   "nocache":  {"type":"boolean","description":"Legacy flag"},
   "sources":  {"type":["array","null"],"items":{"type":"string","enum":["web","news"]}}}},
 "outputSchema":{"title":"Search Results","type":"object"}}

Generated CLI:

before  $ mcporter generate-cli --command "node proof-server.mjs" --name proof --output ./proof-cli.ts
        Generated CLI at ./proof-cli.ts
        $ ./proof-cli.ts --help
        commander/lib/option.js:368
            throw new Error(`${baseError}
        Error: option creation failed due to '---query' in option flags '---query <-query>'
        - unrecognised flag format

after   $ ./proof-cli.ts --help
        Embedded tools
          search - Search the web
            --query <query> --no-cache <no-cache:true|false> [--nocache <nocache:true|false>]
            [--sources <sources:web|news,...>] [--raw <json>]

        $ ./proof-cli.ts search --query "mcp" --no-cache true --nocache false --sources web,news
        {"Query":"mcp","no_cache":true,"nocache":false,"sources":["web","news"]}

The no_cache case on its own. The crash above hides it, so this run uses the same fixture with Query renamed to query:

before  $ ./proof-cli.ts search --query mcp --no-cache true --nocache false
        Missing required option: --no-cache

after   $ ./proof-cli.ts search --query mcp --no-cache true --nocache false
        {"query":"mcp","no_cache":true,"nocache":false}

mcporter list proof:

before    function search(Query: string, no_cache: boolean, nocache?: boolean,
                          sources?: unknown[]): Search Results;

after     function search(Query: string, no_cache: boolean, nocache?: boolean,
                          sources?: ("web" | "news")[]): SearchResults;

mcporter emit-ts proof --mode types:

before    search(..., sources?: unknown[]): Promise<Search Results>;
          $ tsc --noEmit --skipLibCheck --ignoreConfig proof-types.d.ts
          proof-types.d.ts(16,100): error TS1005: '>' expected.
          proof-types.d.ts(16,108): error TS1109: Expression expected.
          proof-types.d.ts(17,1):   error TS1128: Declaration or statement expected.

after     search(..., sources?: ("web" | "news")[]): Promise<SearchResults>;
          $ tsc --noEmit --skipLibCheck --ignoreConfig proof-types.d.ts
          (no output)

Tests

The new cases, red against unpatched main:

tests/generate-cli-helpers.test.ts
  x does not leak a leading dash from an uppercase property name
  x skips a suffix another property already spells
  x emits option flags commander accepts
  x gives every property its own flag when two spellings normalize onto one
  x gives a property whose characters all normalize away a usable flag name
  x assigns the fallback stem before suffixing so every name stays a long flag
  x emits a long flag for a property whose characters all normalize away
  x emits a parseable command for a flag commander stores under a non-identifier key
  x collapses a run of separators into a single dash
  x drops a trailing separator
  x registers a flag for a property that repeats or trails a separator
  x keeps distinct flags for two spellings that collapse onto one
  x reads every option from the key commander stores it under
  x resolves item types through a nullable array container
  x resolves enum members through a nullable array container
  x renders the same option for a nullable array as for a plain array
tests/emit-ts.test.ts
  x keeps a multi-word outputSchema title parseable in the emitted module
  x keeps a reserved-word outputSchema title parseable in the emitted module

Tests  53 failed | 65 passed (118)

One more case, keeps distinct flags for property names that only differ by the no- prefix, passes on both revisions by design: it is the guard against the escape this PR first tried and then dropped.

Four of the cases build the options through renderToolCommand and hand the emitted flag strings to commander's own Option and Command, so both the flag grammar and the no-duplicates rule are pinned against commander itself rather than against a copy of its rules.

Gates

Measured on Node 24.18.1, the runtime the repo asks for. pnpm check (oxfmt, oxlint --type-aware --deny-warnings, tsc --noEmit) is clean on this branch.

pnpm test    198 files: 2 failed | 190 passed | 6 skipped
             1764 tests: 7 failed | 1663 passed | 94 skipped

The seven live in tests/chrome-devtools-relay-handoff.test.ts and tests/runtime-chrome-relay-handoff.test.ts, and they are a property of this Windows machine rather than of this branch. Reverting the branch's three production files to ae3d900 and rerunning those two suites gives the same seven test names, character for character:

                                          production files @ ae3d900   this branch
tests/chrome-devtools-relay-handoff        7 failed | 2 passed          7 failed | 2 passed
tests/runtime-chrome-relay-handoff         (same 7 names)               (same 7 names)

Nothing in this PR touches the relay path. @DominionZA2's Node 24 run of this branch reports 1734 passed with those suites green, which is what the same code looks like off Windows: #332 (comment)

Targeted:

pnpm exec vitest run tests/generate-cli-helpers.test.ts tests/emit-ts.test.ts
    118 passed on this branch
    53 failed | 65 passed with the production files reverted to ae3d900

pnpm exec vitest run tests/generate-cli.test.ts
    3 passed | 10 skipped        (the 10 are the suite's own win32 skips)

Production delta is +154/-14 across three files; the remaining +381/-4 is tests.

A property whose name starts with an uppercase letter or underscore produced
`---flag`, which commander rejects while the option is constructed, so every
command in the generated CLI failed before it could run. A `no_*` property
produced `--no-x`, which commander treats as a negated boolean: it stores the
parsed value under `x` and defaults it to true when the flag is absent, so the
generated command read an always-undefined key and dropped the argument.

Derive both the flag and its placeholder from one helper so the two spellings
cannot drift apart again.
inferType already normalizes a union `type` such as `["array", "null"]` to
`array`, but the two container checks next to it compared the raw value, so a
nullable array lost both its item type and its enum members. The option was
still generated as an array, which left the signature, the placeholder, the
help choices and the call example disagreeing with each other, and made the
generated parser coerce every element to a string.

Ask inferType from both checks so the container answer matches the type the
option is generated with.
inferSchemaDisplayType returned the raw outputSchema title, and emit-ts splices
that value into the emitted interface as a type name. A title with a space
therefore produced a module that does not parse, so the generated .ts and .d.ts
files were unusable rather than merely imprecise.

Fold the title into an identifier and fall back to the structural type when it
cannot become one.
@clawsweeper

clawsweeper Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

🦞👀
ClawSweeper picked this up.

Pull request received. I will update this pull request when review starts.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: da9d0a378b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/cli/generate/tools.ts Outdated
.replace(/^-+/, '');
// Commander reads `--no-x` as a negated boolean: it stores the parsed value under `x` and
// defaults it to true when the flag is absent, so the generated command would never see it.
return flag.startsWith('no-') ? `no${flag.slice(3)}` : flag;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid collapsing escaped no- flags onto existing names

When a tool schema contains both no_cache and nocache (or no-cache and nocache), this branch maps every property to the same nocache CLI name. renderToolCommand consequently emits duplicate --nocache options and reads cmdOpts.nocache for both argument assignments, so callers cannot provide distinct values for these legal schema properties. Preserve a distinct escaping for the reserved no- prefix or detect and reject option-name collisions.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 31bb10b. You are right, and the escape was the wrong half of the trade.

Measured on the previous commit:

extractOptions({ no_cache: {...}, nocache: {...} })
  -> [{ property: 'no_cache', cliName: 'nocache' },
      { property: 'nocache',  cliName: 'nocache' }]

I took the second option the PR body listed rather than a different escape, since any escape can collide with some other legal property name. The flag is now spelled straight from the property (--no-cache), and the generated module builds each option explicitly and clears the negation commander would otherwise infer:

function defineOption(flags, description, parser) {
  const option = new Option(flags, description);
  if (parser) option.argParser(parser);
  option.negate = false;
  return option;
}

With that, attributeName() is noCache, which is the key the generated command already computed, and an absent flag stays undefined instead of defaulting to true. Both properties now round-trip:

$ ./proof-cli.ts fetch --query mcp --no-cache true --nocache false --sources web,news
{"Query":"mcp","no_cache":true,"nocache":false,"sources":["web","news"]}

The regression test now renders a schema carrying no_cache, nocache and url together, asserts the three emitted flags are distinct, and checks each one against the key commander stores it under.

Escaping `no_cache` to `--nocache` collapsed it onto a `nocache` property that a
schema may legally declare alongside it, emitting two identical flags that both
resolved to one storage key. Keep the flag spelled from the property name and
build each option explicitly instead, clearing the negation commander would
otherwise infer, so `--no-cache` is stored under `noCache` and defaults to
undefined when absent.

This is the alternative the PR body offered; review showed it is the correct one.
… suite

The suite that renders a CLI and inspects the file still looked for the literal
`.option("--cells`, which the explicit Option construction replaced. This suite
is skipped on win32, so the local run did not catch it.
@clawsweeper clawsweeper Bot added P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Aug 23, 2026
@clawsweeper

clawsweeper Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed August 26, 2026, 3:49 PM ET / 19:49 UTC.

ClawSweeper review

What this changes

The PR normalizes generated CLI option names and TypeScript type labels so legal MCP JSON Schema property names and output titles produce runnable, parseable artifacts.

Merge readiness

⚠️ Ready for maintainer review - 1 item remains

Keep open: current main still emits malformed Commander flag names for legal schema properties, while the latest branch resolves the prior separator finding with focused source changes, regression coverage, successful checks, and real behavior proof.

Priority: P2
Reviewed head: 2d1b765504144771983e4df607c914563bdc00c8

Review scores

Measure Result What it means
Overall readiness 🐚 platinum hermit (4/6) The repair is well-scoped, tested against Commander and TypeScript parsing, and supported by real after-fix behavior proof.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (live_output): The PR includes after-fix Node 24 fixture output and an independent live Grafana MCP-server run showing generated help and argument round trips; posted proof should remain redacted.
Patch quality 🐚 platinum hermit (4/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (live_output): The PR includes after-fix Node 24 fixture output and an independent live Grafana MCP-server run showing generated help and argument round trips; posted proof should remain redacted.
Evidence reviewed 6 items Current-main defect remains: Current main derives CLI names by inserting dashes before uppercase letters and replacing underscores, without removing leading, trailing, or repeated dashes; this can emit invalid Commander flags.
Branch repairs normalization and collisions: The branch assigns unique normalized names across a tool's properties, collapses invalid dash segments, and supplies a stem for names that normalize to empty.
Generated argument mapping is safe for unusual keys: The template now uses bracket access when Commander’s storage key or the original schema key cannot be emitted as a JavaScript property access.
Findings None None.
Security None None.

Live Verification

Command: pnpm run mcporter generate-cli --help

Result: PASS (completed)

› mcporter@0.13.7 mcporter /tmp/clawsweeper-live-proof-332-ugEpuF/target
› tsx src/cli.ts generate-cli --help

Usage: mcporter generate-cli [server | command | url] [flags]

Targets:
  ‹server›                Use a configured server.
  ‹command|url›           Infer an inline stdio or HTTP server.
  --server ‹name|json›    Server name, HTTP URL, or JSON definition.
  --command ‹value›       Inline stdio command or HTTP URL.
  --from ‹artifact›       Regenerate from an existing generated CLI.

Flags:
  --output ‹path›         Write the TypeScript template to a path.
  --bundle [path]         Emit a bundled JavaScript artifact.
  --compile [path]        Emit a Bun-compiled binary.
  --runtime node|bun      Runtime for generated code.
  --bundler rolldown|bun  Bundler for JavaScript output.
  --timeout ‹ms›          Discovery/call timeout in milliseconds.
  --minify / --no-minify  Toggle bundle minification.
  --include-tools a,b     Generate only these tools.
  --exclude-tools a,b     Omit these tools.
  --dry-run               Print regeneration command for --from.
› mcporter@0.13.7 mcporter /tmp/clawsweeper-live-proof-332-ugEpuF/target
› tsx src/cli.ts generate-cli --help

Usage: mcporter generate-cli [server | command | url] [flags]

Targets:
  ‹server›                Use a configured server.
  ‹command|url›           Infer an inline stdio or HTTP server.
  --server ‹name|json›    Server name, HTTP URL, or JSON definition.
  --command ‹value›       Inline stdio command or HTTP URL.
  --from ‹artifact›       Regenerate from an existing generated CLI.

Flags:
  --output ‹path›         Write the TypeScript template to a path.
  --bundle [path]         Emit a bundled JavaScript artifact.
  --compile [path]        Emit a Bun-compiled binary.
  --runtime node|bun      Runtime for generated code.
  --bundler rolldown|bun  Bundler for JavaScript output.
  --timeout ‹ms›          Discovery/call timeout in milliseconds.
  --minify / --no-minify  Toggle bundle minification.
  --include-tools a,b     Generate only these tools.
  --exclude-tools a,b     Omit these tools.
  --dry-run               Print regeneration command for --from.

Assertions:

  • PASS expect_output: Usage: mcporter generate-cli

How this fits together

MCPorter turns MCP tool schemas into generated Commander CLIs and TypeScript signatures. Schema metadata feeds option generation and type rendering, then the generated command maps parsed values back to the original tool argument keys.

flowchart LR
  A[MCP tool schema] --> B[Tool metadata]
  B --> C[Option-name normalization]
  C --> D[Generated Commander CLI]
  D --> E[Original tool arguments]
  B --> F[Signature renderer]
  F --> G[Generated TypeScript types]
Loading

Before merge

  • Complete next step (P2) - No discrete repair-automation task remains; this is ready for ordinary maintainer merge review.
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Production versus regression coverage production +154/-14, tests +381/-4 The expanded tests directly cover the three generated-artifact boundaries changed by the repair.

Technical review

Best possible solution:

Merge the generator-side compatibility repair so generated CLIs preserve original schema keys while using Commander-safe flags and parseable TypeScript output.

Do we have a high-confidence way to reproduce the issue?

Yes: current main source deterministically produces leading or repeated dashes from uppercase and separator-heavy property names before Commander registers the option; the PR also supplies Node 24 after-fix runs.

Is this the best way to solve the issue?

Yes: normalizing and disambiguating names at the generator boundary, while retaining original schema keys for invocation, is the narrowest maintainable repair.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against ae3d9000c320.

Labels

Label changes:

  • add rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • add status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR includes after-fix Node 24 fixture output and an independent live Grafana MCP-server run showing generated help and argument round trips; posted proof should remain redacted.
  • remove status: ⏳ waiting on author: Current PR status label is status: 👀 ready for maintainer look.
  • remove rating: 🦐 gold shrimp: Current PR rating is rating: 🐚 platinum hermit, so this older rating label is no longer current.

Label justifications:

  • P2: This fixes generated CLI failures for valid but less-common MCP schema names, with limited blast radius outside generation and type rendering.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR includes after-fix Node 24 fixture output and an independent live Grafana MCP-server run showing generated help and argument round trips; posted proof should remain redacted.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR includes after-fix Node 24 fixture output and an independent live Grafana MCP-server run showing generated help and argument round trips; posted proof should remain redacted.

Evidence

What I checked:

  • Current-main defect remains: Current main derives CLI names by inserting dashes before uppercase letters and replacing underscores, without removing leading, trailing, or repeated dashes; this can emit invalid Commander flags. (src/cli/generate/tools.ts:93, ae3d9000c320)
  • Branch repairs normalization and collisions: The branch assigns unique normalized names across a tool's properties, collapses invalid dash segments, and supplies a stem for names that normalize to empty. (src/cli/generate/tools.ts:105, 2d1b76550414)
  • Generated argument mapping is safe for unusual keys: The template now uses bracket access when Commander’s storage key or the original schema key cannot be emitted as a JavaScript property access. (src/cli/generate/template.ts:438, 2d1b76550414)
  • Regression coverage exercises Commander itself: Added tests register generated flags on a real Commander Command, cover collisions, empty names, separator runs, non-identifier keys, nullable arrays, and TypeScript parser diagnostics. (tests/generate-cli-helpers.test.ts:451, 2d1b76550414)
  • After-fix live proof: The PR discussion records an independent Node 24 run against grafana/mcp-grafana: generated help, a real call, and a negative validation probe all preserved the original filter-query key. (2d1b76550414)
  • Current-main ownership signal: Blame on the affected current-main lines resolves to the v0.13.6 release commit by Peter Steinberger; the available local history is shallow, so this is routing evidence rather than feature-introduction attribution. (src/cli/generate/tools.ts:93, e53ef107e4c9)

Likely related people:

  • Peter Steinberger: Current-main blame for the option extraction and signature paths resolves to the v0.13.6 release commit; deeper attribution is unavailable in the shallow local history. (role: current-main provenance contact; confidence: low; commits: e53ef107e4c9; files: src/cli/generate/tools.ts, src/cli/list-signature.ts)

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (11 earlier review cycles; latest 8 shown)
  • reviewed 2026-08-24T16:54:33.890Z sha 6f2d0e9 :: needs changes before merge. :: [P2] Cover remaining TypeScript keyword type names
  • reviewed 2026-08-25T09:29:57.821Z sha 6f2d0e9 :: needs changes before merge. :: [P2] Reject remaining TypeScript type-context keywords
  • reviewed 2026-08-25T14:23:13.778Z sha 37a1ae5 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-25T14:52:51.524Z sha 37a1ae5 :: needs changes before merge. :: [P2] Handle property names that normalize to no flag text
  • reviewed 2026-08-25T23:59:16.780Z sha 37a1ae5 :: needs changes before merge. :: [P2] Give empty normalized names a valid flag stem
  • reviewed 2026-08-26T06:06:38.129Z sha 5a32d16 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-26T14:12:50.135Z sha 5a32d16 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-26T18:15:37.978Z sha 5a32d16 :: needs changes before merge. :: [P2] Normalize repeated separators before emitting Commander flags

@clawsweeper clawsweeper Bot added rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Aug 24, 2026
A title that already looked like an identifier was emitted verbatim, and a
reserved word looks like one, so `{"title":"class"}` reached the emitted module
as `Promise<class>` and the declaration stopped parsing. Reserved words now take
the same folding path every other non-identifier title takes, which keeps the
title in the emitted name instead of dropping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhEWU7mL1HkUQZjeB4dNzq
@Yigtwxx

Yigtwxx commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

[P2] Reject TypeScript reserved words in schema titles - fixed in 4e32483.

toTypeName returned a title verbatim whenever it matched the identifier pattern, and a reserved word matches it. Reserved words now fail that test and fall into the same folding path every other non-identifier title already takes, so class folds to Class rather than being dropped, and the emitted type reference parses. A title with no identifier characters at all still returns undefined and the structural type is used, as before.

Which words are in the set: I ran every reserved word through tsc in the emitted position rather than assuming. 30 of them fail to parse (class, enum, for, in, typeof, ...), and five more - void, null, true, false, this - do parse but resolve to a keyword type that no longer describes the schema, so {"title":"void"} would have emitted Promise<void> for an object result. The set covers both spellings.

Behavior proof

Same stdio fixture as the PR body, with the output schema titled class:

{"name":"search","description":"Search the web",
 "inputSchema":{"type":"object","required":["Query"],
   "properties":{"Query":{"type":"string","description":"Search text"}}},
 "outputSchema":{"title":"class","type":"object"}}

mcporter emit-ts proof --mode types:

before    search(Query: string): Promise<class>;
          $ tsc --noEmit --skipLibCheck proof-types.d.ts
          proof-types.d.ts(14,34): error TS1005: '>' expected.
          proof-types.d.ts(14,39): error TS1005: '{' expected.
          proof-types.d.ts(14,40): error TS1109: Expression expected.
          proof-types.d.ts(15,1):  error TS1128: Declaration or statement expected.

after     search(Query: string): Promise<Class>;
          $ tsc --noEmit --skipLibCheck proof-types.d.ts
          (no output)

mcporter list proof:

before    function search(Query: string): class;
after     function search(Query: string): Class;

Tests

One new case, red against the previous head:

tests/emit-ts.test.ts
  x keeps a reserved-word outputSchema title parseable in the emitted module
    AssertionError: expected [ "'>' expected.", "'{' expected.",
      "Expression expected.", "Declaration or statement expected." ] to deeply equal []

It renders the module and hands it to the TypeScript parser, so the invariant is pinned against tsc rather than against a copy of its rules.

Gates

pnpm exec vitest run tests/emit-ts.test.ts tests/generate-cli-helpers.test.ts    26 passed
pnpm lint:oxlint                                                                clean
oxfmt --check <changed files>                                                   clean
pnpm typecheck                    clean for the changed files
                                  (tests/oauth-session.test.ts reports the same 2 errors on main)
pnpm test                         3 files failed, same 3 as main on Node 22.20.0
                                  (chrome-devtools-relay-handoff, runtime-chrome-relay-handoff,
                                   oauth-refresh-process.integration)

Production delta for this commit is +45/-1 in src/cli/list-signature.ts; the rest is the one test.

@clawsweeper clawsweeper Bot added the merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. label Aug 24, 2026
@Yigtwxx

Yigtwxx commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event exact_review_queue).
Result: when the review finishes, ClawSweeper will create the durable review comment if needed or update the existing comment in place.

Two legal property spellings can normalize onto one flag - `Query` beside
`query`, or `no_cache` beside `noCache` - and commander refuses a command that
declares the same flag twice, so the generated module threw at load and no
command in it could run. Flag names are now assigned across the whole property
list: schema order keeps the plain flag and a later property takes the first
suffix no other property claims.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhEWU7mL1HkUQZjeB4dNzq
@Yigtwxx

Yigtwxx commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

[P2] Reject colliding normalized flag names - confirmed and fixed.

One correction to the finding, measured rather than argued: commander 15 does not accept the duplicate and read one value into both arguments. addOption throws while the command is being built, so a colliding schema kills the whole artifact the same way ---query did.

$ ./collide-cli.ts --help
Error: Cannot add option '--query <query>' to command 'search' due to conflicting flag '--query'
-  already used by option '--query <query>'
    at Command._registerOption (commander/lib/command.js:629:13)
    at Command.addOption (commander/lib/command.js:672:10)

That also means the collision is not something this branch introduced. On ae3d900 a schema declaring no_cache beside noCache already normalizes both to --no-cache and the generated CLI dies at module load:

main @ ae3d900  .option("--no-cache <no-cache:true|false>", "Bypass the cache ...")
                .option("--no-cache <no-cache:true|false>", "Legacy bypass ...")
                $ ./collide-main-cli.ts --help
                Error: Cannot add option '--no-cache <no-cache:true|false>' to command 'search'
                due to conflicting flag '--no-cache'

What this branch changes is the reach: Query used to crash on ---query before any collision could matter, so fixing that spelling exposes the Query/query pair. Taking the repair here rather than leaving it for a follow-up.

Fix

Flag names are now assigned once for the whole property list instead of per property. Schema order keeps the plain flag, and a later property takes the first -2, -3, ... suffix that no other property claims naturally, so a schema that also declares query_2 does not get a second --query-2:

function assignCliNames(properties: string[]): string[] {
  const natural = properties.map((property) => toCliOption(property));
  const claimed = new Set(natural);
  const used = new Set<string>();
  return natural.map((base) => {
    let name = base;
    let suffix = 2;
    while (used.has(name) || (name !== base && claimed.has(name))) {
      name = `${base}-${suffix}`;
      suffix += 1;
    }
    used.add(name);
    return name;
  });
}

The placeholder is built from the assigned name rather than recomputed from the property, so --query-2 <query-2> stays consistent, and the generated command keeps reading args.<property> from the key commander stores that flag under.

Behavior proof

Fixture with both colliding pairs at once:

{"name":"search","description":"Search the web",
 "inputSchema":{"type":"object","required":["Query"],"properties":{
   "Query":    {"type":"string","description":"Search text"},
   "query":    {"type":"string","description":"Legacy search text"},
   "no_cache": {"type":"boolean","description":"Bypass the cache"},
   "noCache":  {"type":"boolean","description":"Legacy bypass"}}}}
before  $ ./collide-cli.ts --help
        Error: Cannot add option '--query <query>' to command 'search' due to
        conflicting flag '--query'

after   $ ./collide-cli.ts --help
        search - Search the web
          --query <query> [--query-2 <query-2>] [--no-cache <no-cache:true|false>]
          [--no-cache-2 <no-cache-2:true|false>] [--raw <json>]

        $ ./collide-cli.ts search --query mcp --query-2 legacy --no-cache true --no-cache-2 false
        {"Query":"mcp","query":"legacy","no_cache":true,"noCache":false}

Each property carries its own value; nothing is read twice.

Tests

Two new cases, red against the previous head:

tests/generate-cli-helpers.test.ts
  x gives every property its own flag when two spellings normalize onto one
    AssertionError: expected [ '--query <query>', '--query <query>', ... ]
  x skips a suffix another property already spells
    AssertionError: expected [ 'query', 'query', 'query-2' ] to deeply equal
                              [ 'query', 'query-3', 'query-2' ]

The first one adds every emitted flag to a real Command, so the no-duplicates invariant is pinned against commander's own registry rather than against a copy of its rules, and then asserts each property reads the key commander stores its flag under.

Gates

pnpm exec vitest run tests/generate-cli-helpers.test.ts tests/emit-ts.test.ts    28 passed
pnpm lint:oxlint                                                                clean
oxfmt --check <changed files>                                                   clean
pnpm typecheck                    clean for the changed files
                                  (tests/oauth-session.test.ts reports the same 2 errors on main)
pnpm test                         3 files / 8 tests failed, the same set as main on Node 22.20.0
                                  (chrome-devtools-relay-handoff, runtime-chrome-relay-handoff,
                                   oauth-refresh-process.integration)

Production delta for this commit is +26/-3 in src/cli/generate/tools.ts.

@Yigtwxx

Yigtwxx commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event exact_review_queue).
Result: when the review finishes, ClawSweeper will create the durable review comment if needed or update the existing comment in place.

The title guard listed JavaScript reserved words, so four spellings TypeScript
refuses in a type position reached the emitted module verbatim and the .d.ts
stopped parsing. The set is now measured against the parser rather than assumed,
and the suite renders a title for every keyword TypeScript spells so the parser,
not a copy of its rules, decides which ones have to be folded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYFLTVUC1VVb2mjRXNuv13
@Yigtwxx

Yigtwxx commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

[P2] Reject remaining TypeScript type-context keywords - fixed in 37a1ae5, with one correction to the finding.

interface is not the word that breaks. Measured against the same parser the tests use, Promise<interface> parses, so a title spelling it reaches the emitted module and tsc accepts the file:

$ mcporter emit-ts proof --mode types --out ./proof-types.d.ts   # outputSchema.title = "interface"
  search(Query: string): Promise<interface>;
$ tsc --noEmit --skipLibCheck --ignoreConfig ./proof-types.d.ts
(no output)

The guard was incomplete all the same, and you were right that the set was the wrong shape: it listed JavaScript reserved words, so the spellings TypeScript refuses only in a type position were never in it. Rendering a title for every keyword TypeScript spells and handing each emitted module back to the parser names them:

tests/emit-ts.test.ts, against 6f2d0e9
  x keeps the outputSchema title infer parseable in the emitted module
  x keeps the outputSchema title keyof parseable in the emitted module
  x keeps the outputSchema title readonly parseable in the emitted module
  x keeps the outputSchema title unique parseable in the emitted module

Tests  5 failed | 88 passed (93)

Those four are the whole set that failed; interface, type, declare, implements and the rest of the contextual keywords passed on both revisions.

Behavior, one stdio fixture whose tool declares "outputSchema": {"title": "keyof"}:

before  $ mcporter emit-ts proof --mode types --out ./proof-types.d.ts
          search(Query: string): Promise<keyof>;
        $ tsc --noEmit --skipLibCheck --ignoreConfig ./proof-types.d.ts
        proof-types.d.ts(14,39): error TS1110: Type expected.

after   $ mcporter emit-ts proof --mode types --out ./proof-types.d.ts
          search(Query: string): Promise<Keyof>;
        $ tsc --noEmit --skipLibCheck --ignoreConfig ./proof-types.d.ts
        (no output)

The set now carries two measured groups rather than one assumed one: the spellings that fail to parse in a type position (infer, keyof, readonly, unique added to the reserved words already there), and the ones TypeScript reads as a keyword type that no longer describes the schema - any, bigint, boolean, never, number, object, string, symbol, undefined and unknown join void, null, true, false and this on the same rationale the existing comment gave. A title spelling one of them survives as Keyof or String instead of being dropped or silently retyped.

The regression is the parser's answer rather than a copy of its rules: it.each renders a title for all 81 keywords and asserts the emitted module carries no parse diagnostic, so a keyword the set misses fails the suite instead of waiting for the next review.

Gates

Local runtime is Node 22.20.0 while the repo asks for >=24, so three suites fail on this branch as they did before it.

pnpm test                          8 failed | 1654 passed | 94 skipped (1756)
                                   all in the three suites the PR body already measured:
                                   tests/chrome-devtools-relay-handoff.test.ts,
                                   tests/runtime-chrome-relay-handoff.test.ts,
                                   tests/oauth-refresh-process.integration.test.ts
                                   tests/emit-ts.test.ts is not among them

pnpm lint:oxlint                   clean
pnpm typecheck                     clean for the changed files
                                   (tests/oauth-session.test.ts reports 2 errors on main too)
oxfmt --check <changed files>      clean
pnpm exec vitest run tests/emit-ts.test.ts tests/generate-cli-helpers.test.ts
                                   110 passed

Production delta for this commit is +22/-5 in src/cli/list-signature.ts; the rest is the keyword table and the four title cases, which now share one render helper instead of repeating it.

On the live verification step

pnpm run mcporter -- generate-cli --help fails for a reason this branch does not touch: pnpm run <script> -- <args> forwards the -- itself, so the CLI reads it as the server name and answers Unknown MCP server '--'. pnpm run mcporter generate-cli --help prints Usage: mcporter generate-cli [server | command | url] [flags]. Nothing on this branch touches that path.

@Yigtwxx

Yigtwxx commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event exact_review_queue).
Result: when the review finishes, ClawSweeper will create the durable review comment if needed or update the existing comment in place.

Re-review progress:

@clawsweeper clawsweeper Bot added rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. labels Aug 25, 2026
Yigtwxx and others added 2 commits August 26, 2026 09:00
A property whose characters all normalize away, such as `___`, left an empty
CLI name, so the generated module declared `--` and commander rejected it while
the command was being built. The suffix pass inherited the same empty base and
spelled the next colliding property `---2`.

The stem is assigned inside toCliOption, before names are handed to the
collision pass, so a schema declaring `option` beside `___` still gets two
distinct flags and the placeholder matches the flag it labels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGpB942NkzLFqzwubdyp7C
A property such as `2fa` produces `--2fa`, which commander accepts and stores
under the key `2fa`. The generated module read it as `cmdOpts.2fa` and assigned
`args.2fa`, so the artifact failed to parse before commander was reached.

Keys that cannot be spelled as a property access are now read through a
subscript; every key that can be is emitted unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGpB942NkzLFqzwubdyp7C
@Yigtwxx

Yigtwxx commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

[P2] Give empty normalized names a valid flag stem — confirmed and fixed in 8355ab6, with one addition the finding did not reach.

You are right, and the reproduction holds exactly as written. ___ normalizes to an empty name and the generated module declares --. Measured on 37a1ae5, against a stdio fixture whose tool declares ___, _, option and 2fa:

$ mcporter generate-cli --command "node proof-server.mjs" --name proof --output ./proof-cli.ts
Generated CLI at ./proof-cli.ts

$ grep addOption ./proof-cli.ts
  .addOption(defineOption("-- <>", "Underscore-only property"))
  .addOption(defineOption("---2 <2:true|false>", "Single underscore property (example: true)", ...))
  .addOption(defineOption("--option <option>", "A property literally named option"))
  .addOption(defineOption("--2fa <2fa>", "Digit-leading property"))

new Option('-- <>')     ->  Error: option creation failed due to '--' in option flags '-- <>'
new Option('---2 <...>')->  Error: option creation failed due to '---2' in option flags '---2 <...>'

Two things the finding did not name, both visible in that same run.

The suffix pass inherits the empty base. _ beside ___ normalizes to the same empty name, and the collision pass then spells the second one ${base}-2 = -2, so the emitted flag is ---2. This is why the stem has to be assigned inside toCliOption, before names reach assignCliNames, rather than patched afterwards — your wording said exactly this ("before suffix assignment") and the measurement confirms it.

The artifact does not reach commander at all. The empty name is also spliced into the generated source as a property access, so the module fails to transpile first:

$ ./proof-cli-before.ts --help
Error [TransformError]: Transform failed with 1 error:
proof-cli-before.ts:134:46: ERROR: Expected identifier but found ","

$ sed -n '134,141p' ./proof-cli-before.ts
  const missingRequired = [{ value: cmdOpts., flag: "--" }, { value: cmdOpts.2fa, flag: "--2fa" }]...
  if (cmdOpts. !== undefined) args.___ = cmdOpts.;
  if (cmdOpts.2 !== undefined) args._ = cmdOpts.2;
  if (cmdOpts.option !== undefined) args.option = cmdOpts.option;
  if (cmdOpts.2fa !== undefined) args.2fa = cmdOpts.2fa;

2fa is the part that outlives the stem fix. It is a legal property name, --2fa is a flag commander accepts, and commander stores it under the key 2fa — but cmdOpts.2fa and args.2fa do not parse. So the stem alone would have left the artifact unbuildable for that property.

Fix

toCliOption returns a deterministic stem when normalization empties the name, and returns it before assignCliNames runs, so the stem participates in collision assignment like any natural name (8355ab6):

return normalized === '' ? EMPTY_CLI_OPTION_STEM : normalized;   // 'option'

A schema declaring option beside ___ therefore still gets two distinct flags rather than a duplicate: schema order keeps --option and the later property takes the first free suffix, the same rule the Query/query pair already follows.

Separately, and droppable on its own commit if you would rather not take it (5a32d16, one helper plus three call sites): a key that cannot be spelled as a property access is read through a subscript. Every key that can be is emitted unchanged, so existing generated output is byte-identical.

function propertyAccess(target: string, key: string): string {
  return /^[A-Za-z_$][\w$]*$/.test(key) ? `${target}.${key}` : `${target}[${JSON.stringify(key)}]`;
}

Behavior proof

Same fixture, same four properties, on this branch:

$ ./proof-cli.ts --help
Embedded tools
  verify - Verify a code
    --option <option> [--option-2 <option-2:true|false>] [--option-3 <option-3>] --2fa <2fa> [--raw <json>]

$ ./proof-cli.ts verify --option "underscores" --option-2 true --option-3 "named option" --2fa "123456"
{"___":"underscores","_":true,"option":"named option","2fa":"123456"}

All four properties round-trip to their own flag, including the real option property sitting beside the synthesized stem.

Tests

Four new cases, red against 37a1ae5:

tests/generate-cli-helpers.test.ts
  x gives a property whose characters all normalize away a usable flag name
  x assigns the fallback stem before suffixing so every name stays a long flag
  x emits a long flag for a property whose characters all normalize away
  x emits a parseable command for a flag commander stores under a non-identifier key

The last two build the block through renderToolCommand, hand each emitted flag to commander's own Option and Command, and run the block through the TypeScript parser, so both the flag grammar and the module's parseability are pinned rather than asserted against a copy of the rules.

Gates

pnpm lint:oxlint                     clean
oxfmt --check <changed files>        clean
pnpm typecheck                       clean for the changed files
                                     (tests/oauth-session.test.ts reports the same 2 errors as main)
pnpm exec vitest run tests/generate-cli-helpers.test.ts tests/generate-cli.test.ts
                                     24 passed | 10 skipped

pnpm test    main @ 37a1ae5   3 files / 9 tests failed
             this branch      3 files / 9 tests failed   (identical; local Node 22.20.0 vs engines >=24)

Production delta for these two commits is +23/-11 across two files.

@Yigtwxx

Yigtwxx commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event exact_review_queue).
Result: when the review finishes, ClawSweeper will create the durable review comment if needed or update the existing comment in place.

Re-review progress:

@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. labels Aug 26, 2026
@DominionZA2

Copy link
Copy Markdown

Verified this PR against a real-world server that trips the non-identifier property case: the official grafana/mcp-grafana server (streamable HTTP), whose proxied Tempo tool tempo_get-attribute-values declares a parameter literally named filter-query.

Environment: Linux, Node v24.19.0, pnpm 10.34.5 via corepack.

Before (main @ ae3d900): generate-cli grafana --bundle grafana-cli.js fails during the Rolldown step — the template emits the schema property name as a bare identifier:

[PARSE_ERROR] Cannot assign to this expression
  ╭─[ grafana.ts:3794:42 ]
  │ if (cmdOpts.filterQuery !== undefined) args.filter-query = cmdOpts.filterQuery;

After (this branch @ 5a32d16): the same invocation bundles cleanly; the emitted line is args["filter-query"] = cmdOpts.filterQuery; via propertyAccess().

Runtime checks against the live server, all passing:

  • --help and per-command help render correctly, including --filter-query <filter-query>.
  • list-datasources returns the instance's datasource list.
  • tempo-get-attribute-values --datasource-uid <uid> --name resource.service.name --filter-query '{ resource.service.name != "" }' returns the filtered attribute values.
  • Negative probe: an intentionally malformed --filter-query value comes back with the server's own validation error for filter-query, confirming the value round-trips under the original schema key rather than being dropped.

Test suite on this branch (Node 24): pnpm test — 194 files passed / 4 skipped, 1734 tests passed / 26 skipped, 0 failures. pnpm check (format, oxlint, typecheck) is clean. The 9 failures noted in the PR description do not reproduce on Node 24; they appear to be artifacts of running under Node 22 against the repo's >=24 requirement.

One thing I double-checked while reading the diff: the generated defineOption sets option.negate = false after construction, and commander v15's attributeName() reads this.negate at call time, so a --no-* flag is stored under its full camelCase name as the generated reader expects. That matches the PR's claim.

@Yigtwxx

Yigtwxx commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Thank you - the grafana/mcp-grafana run is a better witness for the non-identifier case than my fixture is, because filter-query is a property name a published server actually ships rather than one I chose to break the emitter with. The seventh commit was the one I flagged as droppable on its own; tempo_get-attribute-values is the argument for keeping it.

The negative probe is the part I had not covered. My fixture only proves the flag parses and the key round-trips outward; letting the server reject a malformed filter-query under its own key proves nothing silently rewrote the property on the way in.

On the Node version: you are right, and the PR description was misleading. I have rewritten the Gates section to lead with your Node 24 numbers (1734 passed, pnpm check clean) and to say plainly that the 9 failures are an artifact of my Node 22.20.0 against the repo's >=24, not a property of this branch. The three files involved - chrome-devtools-relay-handoff, runtime-chrome-relay-handoff, oauth-refresh-process.integration - fail identically on ae3d900, so nothing here was ever implicated, but reporting them as bare failure counts invited exactly the reading I should have ruled out.

Thanks also for re-deriving the negate claim from commander's source rather than taking the description's word for it. attributeName() reading this.negate at call time is the whole reason defineOption can clear the flag after construction instead of having to escape the name, and it is the load-bearing assumption in the no_cache fix.

@clawsweeper clawsweeper Bot added rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Aug 26, 2026
Commander derives an option's storage key by splitting the flag on dashes
and upper-casing each segment's first character, so an empty segment has
no character to read and `addOption` throws while the generated command is
being built. A property spelling any of `foo__bar`, `baz_` or `filter_Query`
normalized to a flag carrying such a segment, and the generated CLI failed
at module load rather than at parse time.

Collapsing runs and trimming either end before `assignCliNames` keeps the
collision pass free to disambiguate two spellings that now land on one flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCMA8LRouQDoxh9Lr9n88o
@Yigtwxx

Yigtwxx commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

[P2] Normalize repeated separators before emitting Commander flags — confirmed and fixed in 2d1b765, with two shapes the finding did not reach.

The reproduction holds exactly as written and the mechanism is the one you named. Measured against commander 15.0.0, the version the lockfile resolves:

$ node -e '...; c.addOption(new Option("--" + name + " <value>", "x"))'
"foo--bar"  => THROW: TypeError: Cannot read properties of undefined (reading 'toUpperCase')
"foo-bar"   => OK attr= fooBar

attributeName() calls camelcase(this.name()), which does str.split('-').reduce((s, word) => s + word[0].toUpperCase() + word.slice(1)). 'foo--bar'.split('-') is ['foo', '', 'bar'], and the empty segment has no [0]. Constructing the option is not enough to see it — new Option('--foo--bar <v>') returns normally — so the failure only appears at addOption, which is why the existing real-Command registration test was the right place for the new case rather than the flag-grammar one.

foo__bar is one of three property shapes that reach the same empty segment. The other two are not repeats:

property flag before split('-') addOption
foo__bar foo--bar ['foo','','bar'] throws
baz_ baz- ['baz',''] throws
filter_Query filter--query ['filter','','query'] throws

baz_ is a trailing separator, and filter_Query reaches a run by mixing the two naming conventions rather than by repeating one — the uppercase rule emits -q next to the underscore's own dash. A fix that only collapsed repeats would have left baz_ broken.

Fix

src/cli/generate/tools.ts:456 now collapses any run of dashes and trims one from either end, so no segment can be empty:

    .replace(/-{2,}/g, '-')
    .replace(/^-|-$/g, '');

Collapsing before assignCliNames runs means foo__bar beside foo_bar still gets two flags — the collision pass hands the second one --foo-bar-2 exactly as it already does for Query beside query.

To check the class rather than the three names, I enumerated every property name up to five characters over a B _ - 2 . and registered each emitted flag on a real Command:

checked 9330 names; rejected: 0

Behavior proof

A stdio server declaring foo__bar, baz_ and filter_Query, generated and run through generate-cli on Node 24.18.1:

before   $ mcporter generate-cli proof --output ./proof-cli.ts
         Generated CLI at ./proof-cli.ts
         .addOption(defineOption("--foo--bar <foo--bar>", ...))
         .addOption(defineOption("--baz- <baz->", ...))
         .addOption(defineOption("--filter--query <filter--query>", ...))

         $ tsx ./proof-cli.ts search --foo--bar hello
         TypeError: Cannot read properties of undefined (reading 'toUpperCase')
             at Option.attributeName (.../commander/lib/option.js:221:12)
             at Command.addOption (.../commander/lib/command.js:675:25)
             at <anonymous> (proof-cli.ts:114:3)

after    $ mcporter generate-cli proof --output ./proof-cli.ts
         .addOption(defineOption("--foo-bar <foo-bar>", ...))
         .addOption(defineOption("--baz <baz>", ...))
         .addOption(defineOption("--filter-query <filter-query>", ...))

         $ tsx ./proof-cli.ts search --help
           --foo-bar <foo-bar>            A repeated separator.
           --baz <baz>                    A trailing separator.
           --filter-query <filter-query>  An underscore in front of a capital.

         $ tsx ./proof-cli.ts search --foo-bar hello --baz two --filter-query three
         server received: {"foo__bar":"hello","baz_":"two","filter_Query":"three"}

The last line is the part the flag name alone does not settle: the server sees the original schema keys, so collapsing the separator changed the flag and nothing else.

Tests

Four new cases in tests/generate-cli-helpers.test.ts, red on the previous head:

x collapses a run of separators into a single dash
x drops a trailing separator
x registers a flag for a property that repeats or trails a separator
x keeps distinct flags for two spellings that collapse onto one

The third one hands the emitted flags to commander's own Command.addOption, so the invariant is pinned against commander rather than against a copy of its rules.

@Yigtwxx

Yigtwxx commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event exact_review_queue).
Result: when the review finishes, ClawSweeper will create the durable review comment if needed or update the existing comment in place.

Re-review progress:

@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. labels Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants