diff --git a/src/workspaces/cli/bin/openalice-cli.cjs b/src/workspaces/cli/bin/openalice-cli.cjs index f4d86a3f5..56b067269 100644 --- a/src/workspaces/cli/bin/openalice-cli.cjs +++ b/src/workspaces/cli/bin/openalice-cli.cjs @@ -77,7 +77,10 @@ async function main() { if (wantsHelp) return printVerbHelp(group, verb, cmd) // Run it. - const args = parseFlags(argv.slice(argv.indexOf(verb) + 1)) + const args = parseFlags( + argv.slice(argv.indexOf(verb) + 1), + (cmd.schema && cmd.schema.properties) || {}, + ) const res = await invoke(base, cmd.tool, args) process.stdout.write(res.endsWith('\n') ? res : res + '\n') } @@ -186,7 +189,7 @@ async function fetchSocketJson(socketPath, path, opts) { // ---- flag parsing --------------------------------------------------------- -function parseFlags(tokens) { +function parseFlags(tokens, properties) { const args = {} const meta = {} const docs = [] @@ -225,7 +228,7 @@ function parseFlags(tokens) { // per-doc fields keep working; a bare path is wrapped into { path }. docs.push(val && typeof val === 'object' ? val : { path: String(val) }) } else { - args[key] = val + args[canonicalFlagName(key, properties)] = val } } if (Object.keys(meta).length) args.metadataFilter = meta @@ -233,6 +236,23 @@ function parseFlags(tokens) { return args } +/** + * JSON-schema properties use JavaScript camelCase, while shell users naturally + * write kebab-case flags. Preserve exact schema keys for compatibility, then + * accept their kebab-case spelling only when it maps to a real property. An + * unknown flag stays unknown so the gateway's strict validation still catches + * typos instead of silently rewriting them. + */ +function canonicalFlagName(name, properties) { + if (Object.prototype.hasOwnProperty.call(properties, name)) return name + const camel = name.replace(/-([a-zA-Z0-9])/g, (_, c) => c.toUpperCase()) + return Object.prototype.hasOwnProperty.call(properties, camel) ? camel : name +} + +function displayFlagName(name) { + return name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()) +} + // ---- help rendering ------------------------------------------------------- function printGroups(m) { @@ -271,7 +291,7 @@ function printVerbHelp(group, verb, cmd) { const p = props[n] || {} const type = p.type || (p.enum ? 'enum' : '') const req = required.has(n) ? ' (required)' : '' - out(` --${n}${type ? ' <' + type + '>' : ''}${req} ${firstLine(p.description || '')}`) + out(` --${displayFlagName(n)}${type ? ' <' + type + '>' : ''}${req} ${firstLine(p.description || '')}`) } } diff --git a/src/workspaces/cli/shim.spec.ts b/src/workspaces/cli/shim.spec.ts index 51d383b5e..216c85456 100644 --- a/src/workspaces/cli/shim.spec.ts +++ b/src/workspaces/cli/shim.spec.ts @@ -136,6 +136,79 @@ describe('CLI launchers and payload', () => { } }) + it('shows kebab-case flags and maps them back to exact schema properties', async () => { + const dir = await mkdtemp(join(tmpdir(), 'openalice-cli-shim-flags-')) + const socketPath = process.platform === 'win32' + ? `\\\\.\\pipe\\openalice-cli-shim-flags-${process.pid}-${Date.now()}` + : join(dir, 'tools.sock') + const invocations: Array<{ tool?: string; args?: Record }> = [] + const manifest = { + description: 'test manifest', + groups: { + conversation: { + ask: { + tool: 'conversation_ask', + description: 'Ask a peer conversation', + schema: { + type: 'object', + properties: { + resumeId: { type: 'string', description: 'Conversation identity' }, + timeoutMs: { type: 'number', description: 'Watchdog' }, + prompt: { type: 'string', description: 'Question' }, + }, + required: ['prompt'], + }, + }, + }, + }, + } + const server = createServer((req, res) => { + if (req.method === 'GET') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(manifest)) + return + } + let raw = '' + req.setEncoding('utf8') + req.on('data', (chunk) => { raw += chunk }) + req.on('end', () => { + invocations.push(JSON.parse(raw)) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ content: [{ type: 'text', text: '{"ok":true}' }] })) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, resolve) + }) + const env = { + ...process.env, + AQ_WS_ID: 'ws1', + OPENALICE_TOOL_SOCKET: socketPath, + OPENALICE_TOOL_URL: '/cli', + } + try { + const help = await runCli('alice-workspace', ['conversation', 'ask', '--help'], env) + expect(help.stdout).toContain('--resume-id ') + expect(help.stdout).toContain('--timeout-ms ') + expect(help.stdout).not.toContain('--resumeId') + + await runCli('alice-workspace', [ + 'conversation', 'ask', + '--resume-id', 'resume-1', + '--timeout-ms', '300000', + '--prompt', 'why?', + ], env) + expect(invocations).toEqual([{ + tool: 'conversation_ask', + args: { resumeId: 'resume-1', timeoutMs: '300000', prompt: 'why?' }, + }]) + } finally { + await new Promise((resolve) => server.close(() => resolve())) + await rm(dir, { recursive: true, force: true }) + } + }) + it('every Windows `.cmd` twin derives its export and selects the managed Node runtime', () => { const canonical = read('alice.cmd') for (const name of EXPORT_BINARIES) {