From 8543fe528b0cdba1ae126dc6cfc8f62110b9d572 Mon Sep 17 00:00:00 2001 From: Denys Kashkovskyi Date: Thu, 27 Aug 2026 10:54:08 +0200 Subject: [PATCH 1/2] fix(cli): disambiguate reused flag names --- src/effect/cli_invocation.ts | 6 +- test/integration/cli.effect.test.ts | 75 +++++++++++++++++++++++ test/unit/cli-invocation.property.test.ts | 18 ++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 test/unit/cli-invocation.property.test.ts diff --git a/src/effect/cli_invocation.ts b/src/effect/cli_invocation.ts index 52702a14..67915f88 100644 --- a/src/effect/cli_invocation.ts +++ b/src/effect/cli_invocation.ts @@ -155,7 +155,11 @@ export function normalizeCliArguments(args: readonly string[]): readonly string[ const current = args[index] ?? ''; const equalsIndex = current.indexOf('='); const inlineName = equalsIndex > 0 ? current.slice(0, equalsIndex) : current; - const kind = valueFlagKinds.get(inlineName); + // A spelling can be boolean in one command and valued in another (for + // example, `init-manifest --replace` versus `remember --replace `). + // Effect's selected command can disambiguate those flags. Rewriting them + // here from the global registry would consume a following flag as a value. + const kind = booleanFlagNames.has(inlineName) ? undefined : valueFlagKinds.get(inlineName); if (!kind) { normalized.push(current); continue; diff --git a/test/integration/cli.effect.test.ts b/test/integration/cli.effect.test.ts index 318126c4..c6d7d3e3 100644 --- a/test/integration/cli.effect.test.ts +++ b/test/integration/cli.effect.test.ts @@ -83,6 +83,81 @@ describe('Effect CLI', () => { } }); + it('disambiguates reused boolean and value flag names by selected command', async () => { + const root = await mkdtemp(join(tmpdir(), 'threadnote-effect-cli-reused-flags-')); + const home = join(root, 'home'); + const firstRepo = join(root, 'first-repo'); + const secondRepo = join(root, 'second-repo'); + try { + await Promise.all([mkdir(firstRepo), mkdir(secondRepo)]); + await Promise.all([ + execFilePromise('git', ['-C', firstRepo, 'init', '-q']), + execFilePromise('git', ['-C', secondRepo, 'init', '-q']), + ]); + + const replaceBeforeRepos = await runCli([ + 'init-manifest', + '--home', + home, + '--dry-run', + '--replace', + '--repo', + firstRepo, + '--repo', + secondRepo, + ]); + const replaceAfterRepo = await runCli([ + 'init-manifest', + '--home', + home, + '--dry-run', + '--repo', + firstRepo, + '--replace', + ]); + const updateStatus = await runCli(['update', '--status', '--json'], { + THREADNOTE_HOME: home, + THREADNOTE_INSTALL_ROOT: join(root, 'install'), + }); + const replacementUri = 'threadnote://user/test/memories/durable/projects/threadnote/old.md'; + const rememberReplace = await runCli([ + 'remember', + '--home', + home, + '--dry-run', + '--text', + 'replacement', + '--project', + 'threadnote', + '--topic', + 'reused-replace', + '--replace', + replacementUri, + ]); + const projectionStatus = await runCli([ + 'projection', + 'add', + '--home', + home, + '--id', + 'reused-status', + '--vault', + root, + '--status', + 'active', + ]); + + expect(replaceBeforeRepos.stdout).toContain('name: first-repo'); + expect(replaceBeforeRepos.stdout).toContain('name: second-repo'); + expect(replaceAfterRepo.stdout).toContain('name: first-repo'); + expect(JSON.parse(updateStatus.stdout)).toMatchObject({version: 1}); + expect(rememberReplace.stdout).toContain(`supersedes: ${replacementUri}`); + expect(projectionStatus.stdout).toContain('statuses: active'); + } finally { + await rm(root, {force: true, recursive: true}); + } + }); + it('exposes local AI model installation and switching commands', async () => { const install = await runCli(['local-ai', 'install', '--help']); const switching = await runCli(['local-ai', 'model', 'switch', '--help']); diff --git a/test/unit/cli-invocation.property.test.ts b/test/unit/cli-invocation.property.test.ts new file mode 100644 index 00000000..c18a72c7 --- /dev/null +++ b/test/unit/cli-invocation.property.test.ts @@ -0,0 +1,18 @@ +import fc from 'fast-check'; +import {describe, expect, it} from 'vitest'; +import {normalizeCliArguments} from '../../src/effect/cli.js'; + +describe('CLI argument normalization properties', () => { + it('leaves globally ambiguous option spellings for the selected command parser', () => { + fc.assert( + fc.property( + fc.constantFrom('--replace', '--status'), + fc.string({minLength: 1, maxLength: 40}).map(value => `--${value}`), + (ambiguousFlag, followingFlag) => { + expect(normalizeCliArguments([ambiguousFlag, followingFlag])).toEqual([ambiguousFlag, followingFlag]); + }, + ), + {numRuns: 100}, + ); + }); +}); From 6d1c8893cd9587cc745281007ec495eb6b7dcaa6 Mon Sep 17 00:00:00 2001 From: Denys Kashkovskyi Date: Thu, 27 Aug 2026 11:01:44 +0200 Subject: [PATCH 2/2] test(cli): preserve preview flag inspection --- src/effect/cli_invocation.ts | 4 +++- test/unit/cli-production-log.test.ts | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/effect/cli_invocation.ts b/src/effect/cli_invocation.ts index 67915f88..42e7d6c6 100644 --- a/src/effect/cli_invocation.ts +++ b/src/effect/cli_invocation.ts @@ -94,7 +94,9 @@ function scanCliArguments(arguments_: readonly string[]) { const argument = arguments_[index] ?? ''; const equalsIndex = argument.indexOf('='); const flagName = equalsIndex > 0 ? argument.slice(0, equalsIndex) : argument; - const valueKind = valueFlagKinds.get(flagName) ?? cliRuntimeValueFlagKinds.get(flagName); + const valueKind = booleanFlagNames.has(flagName) + ? undefined + : (valueFlagKinds.get(flagName) ?? cliRuntimeValueFlagKinds.get(flagName)); if (valueKind !== undefined) { const value = equalsIndex > 0 ? argument.slice(equalsIndex + 1) : arguments_[index + 1]; if (flagName === '--home') { diff --git a/test/unit/cli-production-log.test.ts b/test/unit/cli-production-log.test.ts index 4502a542..6530e341 100644 --- a/test/unit/cli-production-log.test.ts +++ b/test/unit/cli-production-log.test.ts @@ -53,6 +53,12 @@ describe('CLI production log policy', () => { operation: 'migrate-lifecycle', writeProductionLog: false, }); + expect( + inspectCliInvocation(['init-manifest', '--replace', '--dry-run', '--repo', '/tmp/repository']), + ).toMatchObject({ + operation: 'init-manifest', + writeProductionLog: false, + }); for (const command of ['publish', 'publish-artifact', 'publish-bundle']) { expect(inspectCliInvocation(['share', command, '--preview'])).toMatchObject({ operation: 'share',