From 96c9662be160a89bc4967248c113cba29dcafbd2 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:59:36 +0800 Subject: [PATCH 1/3] test(cli): reproduce numeric version truncation Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- cli/test/integration/version-option.test.ts | 78 +++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 cli/test/integration/version-option.test.ts diff --git a/cli/test/integration/version-option.test.ts b/cli/test/integration/version-option.test.ts new file mode 100644 index 00000000..9a8fcf26 --- /dev/null +++ b/cli/test/integration/version-option.test.ts @@ -0,0 +1,78 @@ +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, describe, expect, test } from 'bun:test' +import { strToU8, zipSync } from 'fflate' +import { createTempHome } from '../helpers/temp-env' +import { startFakeRegistry } from '../helpers/fake-registry' +import { runCli } from '../helpers/run-cli' + +const TIMESTAMP_VERSION = '20260910.021100' + +let stopServer: (() => void) | undefined + +afterEach(() => { + stopServer?.() + stopServer = undefined +}) + +describe('--version parsing', () => { + test('install preserves trailing zeros in a numeric-looking version', async () => { + const env = await createTempHome() + const registry = await startFakeRegistry({ + token: 'sk_ok', + skills: [{ + namespace: 'global', + slug: 'timestamped', + version: TIMESTAMP_VERSION, + zipBytes: zipSync({ 'SKILL.md': strToU8('# timestamped') }) + }] + }) + stopServer = registry.stop + const installDir = join(env.cwd, 'skills') + await mkdir(installDir, { recursive: true }) + + const result = await runCli([ + 'install', '@global/timestamped', + '--version', TIMESTAMP_VERSION, + '--dir', installDir, + '--registry', registry.url, + '--token', 'sk_ok' + ], { HOME: env.home, USERPROFILE: env.home }) + + expect(result.exitCode).toBe(0) + expect(registry.received.resolve?.version).toBe(TIMESTAMP_VERSION) + }) + + test('suite install preserves trailing zeros in a numeric-looking version', async () => { + const env = await createTempHome() + let requestedVersion: string | null = null + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url) + if (url.pathname === '/.well-known/clawhub.json') { + return Response.json({ apiBase: '/api/v1', capabilities: ['skill-suite-v1'] }) + } + if (url.pathname === '/api/v1/suites/global/starter-pack/install-plan') { + requestedVersion = url.searchParams.get('version') + return Response.json({ code: 404, message: 'stop after capturing version' }, { status: 404 }) + } + return Response.json({ code: 404, message: 'not found' }, { status: 404 }) + } + }) + stopServer = () => server.stop(true) + const installDir = join(env.cwd, 'suite-skills') + await mkdir(installDir, { recursive: true }) + + const result = await runCli([ + 'suite', 'install', '@global/starter-pack', + '--version', TIMESTAMP_VERSION, + '--dir', installDir, + '--registry', `http://localhost:${server.port}`, + '--token', 'sk_ok' + ], { HOME: env.home, USERPROFILE: env.home }) + + expect(result.exitCode).not.toBe(0) + expect(requestedVersion).toBe(TIMESTAMP_VERSION) + }) +}) From fa7410a56cd5a60962c3bd2f79c9e58e196d92a1 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:04:55 +0800 Subject: [PATCH 2/3] fix(cli): preserve numeric-looking version strings Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- cli/src/index.ts | 32 ++++++++++++++++++-- cli/test/integration/version-option.test.ts | 33 +++++++++++++++++++-- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/cli/src/index.ts b/cli/src/index.ts index 0fd549d8..6b27bb87 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -27,6 +27,26 @@ function toArray(val: string | string[] | undefined): string[] | undefined { return Array.isArray(val) ? val : [val] } +/** Read a string option before cac/mri coerces numeric-looking values to numbers. */ +function rawStringOption(argv: string[], name: string): string | undefined { + const optionWithEquals = `${name}=` + const end = argv.indexOf('--') + const args = end === -1 ? argv : argv.slice(0, end) + let value: string | undefined + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]! + if (argument === name) { + value = args[index + 1] + index += 1 + } else if (argument.startsWith(optionWithEquals)) { + value = argument.slice(optionWithEquals.length) + } + } + + return value +} + async function runCommand(action: () => Promise, json = false): Promise { try { const output = await action() @@ -246,7 +266,11 @@ cli .option('--token ', 'API token') .option('--json', 'Output JSON') .action((slug: string, options: InstallCommandOptions & { agent?: string | string[] }) => { - return runCommand(() => installCommand(slug, { ...options, agent: toArray(options.agent) }), Boolean(options.json)) + return runCommand(() => installCommand(slug, { + ...options, + version: rawStringOption(process.argv.slice(2), '--version'), + agent: toArray(options.agent) + }), Boolean(options.json)) }) cli @@ -262,7 +286,11 @@ cli .option('--json', 'Output JSON') .action((action: string, coordinate: string, options: SuiteCommandOptions & { agent?: string | string[] }) => { return runCommand( - () => suiteCommand(action, coordinate, { ...options, agent: toArray(options.agent) }), + () => suiteCommand(action, coordinate, { + ...options, + version: rawStringOption(process.argv.slice(2), '--version'), + agent: toArray(options.agent) + }), Boolean(options.json) ) }) diff --git a/cli/test/integration/version-option.test.ts b/cli/test/integration/version-option.test.ts index 9a8fcf26..185b65e9 100644 --- a/cli/test/integration/version-option.test.ts +++ b/cli/test/integration/version-option.test.ts @@ -43,9 +43,36 @@ describe('--version parsing', () => { expect(registry.received.resolve?.version).toBe(TIMESTAMP_VERSION) }) + test('install preserves trailing zeros with the --version=value form', async () => { + const env = await createTempHome() + const registry = await startFakeRegistry({ + token: 'sk_ok', + skills: [{ + namespace: 'global', + slug: 'timestamped-equals', + version: TIMESTAMP_VERSION, + zipBytes: zipSync({ 'SKILL.md': strToU8('# timestamped equals') }) + }] + }) + stopServer = registry.stop + const installDir = join(env.cwd, 'skills-equals') + await mkdir(installDir, { recursive: true }) + + const result = await runCli([ + 'install', '@global/timestamped-equals', + `--version=${TIMESTAMP_VERSION}`, + '--dir', installDir, + '--registry', registry.url, + '--token', 'sk_ok' + ], { HOME: env.home, USERPROFILE: env.home }) + + expect(result.exitCode).toBe(0) + expect(registry.received.resolve?.version).toBe(TIMESTAMP_VERSION) + }) + test('suite install preserves trailing zeros in a numeric-looking version', async () => { const env = await createTempHome() - let requestedVersion: string | null = null + const received = { version: null as string | null } const server = Bun.serve({ port: 0, fetch(request) { @@ -54,7 +81,7 @@ describe('--version parsing', () => { return Response.json({ apiBase: '/api/v1', capabilities: ['skill-suite-v1'] }) } if (url.pathname === '/api/v1/suites/global/starter-pack/install-plan') { - requestedVersion = url.searchParams.get('version') + received.version = url.searchParams.get('version') return Response.json({ code: 404, message: 'stop after capturing version' }, { status: 404 }) } return Response.json({ code: 404, message: 'not found' }, { status: 404 }) @@ -73,6 +100,6 @@ describe('--version parsing', () => { ], { HOME: env.home, USERPROFILE: env.home }) expect(result.exitCode).not.toBe(0) - expect(requestedVersion).toBe(TIMESTAMP_VERSION) + expect(received.version).toBe(TIMESTAMP_VERSION) }) }) From 04add6fcca9419faf207cc9aebe1e1423ffb6016 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:14:52 +0800 Subject: [PATCH 3/3] fix(cli): reject ambiguous version options Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- cli/src/index.ts | 15 +++- cli/test/integration/version-option.test.ts | 88 ++++++++++++++++++++- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/cli/src/index.ts b/cli/src/index.ts index 6b27bb87..cb21cdac 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -33,17 +33,30 @@ function rawStringOption(argv: string[], name: string): string | undefined { const end = argv.indexOf('--') const args = end === -1 ? argv : argv.slice(0, end) let value: string | undefined + let occurrences = 0 for (let index = 0; index < args.length; index += 1) { const argument = args[index]! if (argument === name) { - value = args[index + 1] + occurrences += 1 + const candidate = args[index + 1] + if (candidate === undefined || candidate.startsWith('-')) { + throw new CliError(`option "${name}" value is missing`, EXIT.usage) + } + value = candidate index += 1 } else if (argument.startsWith(optionWithEquals)) { + occurrences += 1 value = argument.slice(optionWithEquals.length) + if (!value) { + throw new CliError(`option "${name}" value is missing`, EXIT.usage) + } } } + if (occurrences > 1) { + throw new CliError(`option "${name}" cannot be repeated`, EXIT.usage) + } return value } diff --git a/cli/test/integration/version-option.test.ts b/cli/test/integration/version-option.test.ts index 185b65e9..9a5d6fab 100644 --- a/cli/test/integration/version-option.test.ts +++ b/cli/test/integration/version-option.test.ts @@ -70,7 +70,61 @@ describe('--version parsing', () => { expect(registry.received.resolve?.version).toBe(TIMESTAMP_VERSION) }) - test('suite install preserves trailing zeros in a numeric-looking version', async () => { + test('install preserves an ordinary text version', async () => { + const env = await createTempHome() + const registry = await startFakeRegistry({ + token: 'sk_ok', + skills: [{ + namespace: 'global', + slug: 'text-version', + version: 'release-a', + zipBytes: zipSync({ 'SKILL.md': strToU8('# text version') }) + }] + }) + stopServer = registry.stop + const installDir = join(env.cwd, 'skills-text-version') + await mkdir(installDir, { recursive: true }) + + const result = await runCli([ + 'install', '@global/text-version', + '--version', 'release-a', + '--dir', installDir, + '--registry', registry.url, + '--token', 'sk_ok' + ], { HOME: env.home, USERPROFILE: env.home }) + + expect(result.exitCode).toBe(0) + expect(registry.received.resolve?.version).toBe('release-a') + }) + + test('ignores --version after the option terminator', async () => { + const env = await createTempHome() + const registry = await startFakeRegistry({ + token: 'sk_ok', + skills: [{ + namespace: 'global', + slug: 'latest', + version: '1.0.0', + zipBytes: zipSync({ 'SKILL.md': strToU8('# latest') }) + }] + }) + stopServer = registry.stop + const installDir = join(env.cwd, 'skills-latest') + await mkdir(installDir, { recursive: true }) + + const result = await runCli([ + 'install', '@global/latest', + '--dir', installDir, + '--registry', registry.url, + '--token', 'sk_ok', + '--', '--version', TIMESTAMP_VERSION + ], { HOME: env.home, USERPROFILE: env.home }) + + expect(result.exitCode).toBe(0) + expect(registry.received.resolve?.version).toBeNull() + }) + + test.each(['separate', 'equals'])('suite install preserves trailing zeros with %s syntax', async syntax => { const env = await createTempHome() const received = { version: null as string | null } const server = Bun.serve({ @@ -93,7 +147,7 @@ describe('--version parsing', () => { const result = await runCli([ 'suite', 'install', '@global/starter-pack', - '--version', TIMESTAMP_VERSION, + ...(syntax === 'equals' ? [`--version=${TIMESTAMP_VERSION}`] : ['--version', TIMESTAMP_VERSION]), '--dir', installDir, '--registry', `http://localhost:${server.port}`, '--token', 'sk_ok' @@ -102,4 +156,34 @@ describe('--version parsing', () => { expect(result.exitCode).not.toBe(0) expect(received.version).toBe(TIMESTAMP_VERSION) }) + + test.each([ + ['a missing value', ['--version']], + ['a missing repeated value', ['--version', '1.0.0', '--version']], + ['repeated values', ['--version', '1.0.0', '--version', '2.0.0']], + ['an empty equals value', ['--version=']] + ])('rejects %s before contacting the registry', async (_description, versionArgs) => { + const env = await createTempHome() + const registry = await startFakeRegistry({ + token: 'sk_ok', + skills: [{ + namespace: 'global', + slug: 'rejected', + version: '1.0.0', + zipBytes: zipSync({ 'SKILL.md': strToU8('# rejected') }) + }] + }) + stopServer = registry.stop + + const result = await runCli([ + 'install', '@global/rejected', + ...versionArgs, + '--json', + '--registry', registry.url, + '--token', 'sk_ok' + ], { HOME: env.home, USERPROFILE: env.home }) + + expect(result.exitCode).toBe(5) + expect(registry.received.resolves).toBe(0) + }) })