Skip to content
Merged
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
10 changes: 8 additions & 2 deletions src/effect/cli_invocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -155,7 +157,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 <uri>`).
// 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;
Expand Down
75 changes: 75 additions & 0 deletions test/integration/cli.effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
18 changes: 18 additions & 0 deletions test/unit/cli-invocation.property.test.ts
Original file line number Diff line number Diff line change
@@ -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},
);
});
});
6 changes: 6 additions & 0 deletions test/unit/cli-production-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down