Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions src/workspaces/cli/bin/openalice-cli.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -225,14 +228,31 @@ 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
if (docs.length) args.docs = docs
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) {
Expand Down Expand Up @@ -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 || '')}`)
}
}

Expand Down
73 changes: 73 additions & 0 deletions src/workspaces/cli/shim.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }> = []
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<void>((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 <string>')
expect(help.stdout).toContain('--timeout-ms <number>')
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<void>((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) {
Expand Down
Loading