From f094f56bf6a984b4702ae132c347b90828df3a93 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 4 Jul 2026 16:05:02 +0300 Subject: [PATCH] Fix ncl positional IDs for generated (dashed) identifiers The dispatcher trimmed exactly one trailing dash-segment to recover the target id, so any generated id containing dashes (UUIDs, sess-*, appr-*) never matched a command and failed unknown-command; only --id worked. Resolve by longest registered prefix instead: split the dash-joined command, take the longest prefix that lookup() resolves as the command, and treat the re-joined remainder as args.id. Server-side only, no wire or client change. Co-Authored-By: Claude Opus 4.8 --- src/cli/dispatch.test.ts | 26 ++++++++++++++++++++++++++ src/cli/dispatch.ts | 21 +++++++++++++-------- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/cli/dispatch.test.ts b/src/cli/dispatch.test.ts index 20ff18707..77c2f628b 100644 --- a/src/cli/dispatch.test.ts +++ b/src/cli/dispatch.test.ts @@ -147,6 +147,16 @@ register({ handler: async (args) => ({ id: (args as Record).id, agent_group_id: 'g1' }), }); +// Echoes args back — used to assert dash-joined positional id resolution. +register({ + name: 'groups-get', + description: 'test command (groups get)', + resource: 'groups', + access: 'open', + parseArgs: (raw) => raw, + handler: async (args) => ({ echo: args }), +}); + import { dispatch } from './dispatch.js'; import type { CallerContext } from './frame.js'; @@ -512,3 +522,19 @@ describe('CLI scope enforcement', () => { } }); }); + +// --- Dash-joined positional id resolution (generated ids contain dashes) --- + +describe('dash-joined positional id resolution', () => { + it('resolves `groups-get-` to (groups get, id=)', async () => { + const uuid = '550e8400-e29b-41d4-a716-446655440000'; + + const resp = await dispatch({ id: '1', command: `groups-get-${uuid}`, args: {} }, { caller: 'host' }); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as { echo: Record }; + expect(data.echo.id).toBe(uuid); + } + }); +}); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 7878a9b57..9a6b595a5 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -17,19 +17,24 @@ import { lookup } from './registry.js'; export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise { let cmd = lookup(req.command); - // Fallback: if the full command isn't registered, trim the last - // dash-segment and treat it as the target ID. This lets clients join - // all positional args with dashes (e.g. `ncl groups get abc123` - // → command "groups-get-abc123" → trim → "groups-get" + id "abc123"). + // Fallback: if the full command isn't registered, split the dash-joined + // command and treat the longest registered prefix as the command, with the + // re-joined remainder as the target ID. Clients join all positional args + // with dashes (e.g. `ncl groups get abc123` → command "groups-get-abc123"), + // and generated ids (UUIDs, `sess-…`, `appr-…`) themselves contain dashes, + // so trimming a single trailing segment isn't enough — walk prefixes from + // longest to shortest so `groups-get-` still resolves to + // "groups-get" + id "". if (!cmd) { - const idx = req.command.lastIndexOf('-'); - if (idx > 0) { - const shortened = req.command.slice(0, idx); - const tail = req.command.slice(idx + 1); + const parts = req.command.split('-'); + for (let i = parts.length - 1; i > 0; i--) { + const shortened = parts.slice(0, i).join('-'); const fallback = lookup(shortened); if (fallback) { + const tail = parts.slice(i).join('-'); cmd = fallback; req = { ...req, command: shortened, args: { ...req.args, id: req.args.id ?? tail } }; + break; } } }