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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@absmartly/cli",
"version": "1.10.0",
"version": "1.11.0",
"description": "ABSmartly CLI - A/B Testing and Feature Flags command-line tool for AI agents and humans",
"type": "module",
"main": "./dist/index.js",
Expand Down
18 changes: 4 additions & 14 deletions src/commands/customfields/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Command } from 'commander';
import {
getAPIClientFromOptions,
addFieldProjectionHelp,
getGlobalOptions,
printFormatted,
printResult,
Expand Down Expand Up @@ -40,22 +41,11 @@ const listCommand = createListCommand({
const getCommand = new Command('get')
.description('Get custom section field details')
.argument('<id>', 'field ID', parseCustomSectionFieldId)
.option('--show <fields...>', 'include additional fields from API response')
.option('--exclude <fields...>', 'hide fields from summary')
.option(
'--show-only <fields...>',
'show only these fields (mutually exclusive with --show and --exclude)'
)
.action(
withErrorHandling(async (id: CustomSectionFieldId, options) => {
withErrorHandling(async (id: CustomSectionFieldId) => {
const globalOptions = getGlobalOptions(getCommand);
const client = await getAPIClientFromOptions(globalOptions);
const show = (options.show as string[] | undefined) ?? [];
const exclude = (options.exclude as string[] | undefined) ?? [];
const showOnly = options.showOnly as string[] | undefined;
if (showOnly && (show.length > 0 || exclude.length > 0)) {
throw new Error('--show-only is mutually exclusive with --show and --exclude');
}
const { show = [], exclude = [], showOnly } = globalOptions;
const result = await getCustomField(client, {
id,
show,
Expand Down Expand Up @@ -138,7 +128,7 @@ const archiveCommand = new Command('archive')
);

customFieldsCommand.addCommand(listCommand);
customFieldsCommand.addCommand(getCommand);
customFieldsCommand.addCommand(addFieldProjectionHelp(getCommand));
customFieldsCommand.addCommand(createCommand);
customFieldsCommand.addCommand(updateCommand);
customFieldsCommand.addCommand(archiveCommand);
7 changes: 6 additions & 1 deletion src/commands/events/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
printFormatted,
} from '../../lib/utils/api-helper.js';
import { resetCommand } from '../../test/helpers/command-reset.js';
import { formatDateTime } from '../../api-client/format-helpers.js';

vi.mock('../../lib/utils/api-helper.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../lib/utils/api-helper.js')>();
Expand Down Expand Up @@ -337,7 +338,11 @@ describe('events command', () => {
Record<string, unknown>
>;
expect(Array.isArray(printed)).toBe(true);
expect(printed[0]).toEqual({ key: 'currency', value_type: 'string', last_event_at: 1 });
expect(printed[0]).toEqual({
key: 'currency',
value_type: 'string',
last_event_at: formatDateTime(1),
});
});

it('keeps the columnar shape for json-layouts with --raw', async () => {
Expand Down
18 changes: 13 additions & 5 deletions src/commands/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Command } from 'commander';
import chalk from 'chalk';
import {
getAPIClientFromOptions,
addFieldProjectionHelp,
getGlobalOptions,
printFormatted,
printResult,
Expand All @@ -10,6 +11,7 @@ import {
import { parseDateFlagOrUndefined } from '../../lib/utils/date-parser.js';
import {
columnarToRows,
formatEventRowTimestamps,
listEvents as coreListEvents,
listEventsHistory as coreListEventsHistory,
getEventUnitData as coreGetEventUnitData,
Expand Down Expand Up @@ -202,7 +204,10 @@ const jsonValuesCommand = new Command('json-values')
const filtered = filterColumnarRows(result.data, 'value', {
match: options.match as string | undefined,
});
printFormatted(globalOptions.raw ? filtered : columnarToRows(filtered), globalOptions);
printFormatted(
globalOptions.raw ? filtered : formatEventRowTimestamps(columnarToRows(filtered)),
globalOptions
);
})
);

Expand Down Expand Up @@ -239,14 +244,17 @@ const jsonLayoutsCommand = new Command('json-layouts')
topLevel: options.topLevel as boolean | undefined,
maxDepth: options.maxDepth as number | undefined,
});
printFormatted(globalOptions.raw ? filtered : columnarToRows(filtered), globalOptions);
printFormatted(
globalOptions.raw ? filtered : formatEventRowTimestamps(columnarToRows(filtered)),
globalOptions
);
})
);

eventsCommand.addCommand(listCommand);
eventsCommand.addCommand(addFieldProjectionHelp(listCommand));
eventsCommand.addCommand(historyCommand);
eventsCommand.addCommand(unitDataCommand);
eventsCommand.addCommand(deleteUnitDataCommand);
eventsCommand.addCommand(jsonValuesCommand);
eventsCommand.addCommand(jsonLayoutsCommand);
eventsCommand.addCommand(addFieldProjectionHelp(jsonValuesCommand));
eventsCommand.addCommand(addFieldProjectionHelp(jsonLayoutsCommand));
eventsCommand.addCommand(summaryCommand);
19 changes: 4 additions & 15 deletions src/commands/experiments/custom-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,26 +49,15 @@ const listCommand = addPaginationOptions(
const getCommand = new Command('get')
.description('Get custom section field details')
.argument('<id>', 'field ID', parseCustomSectionFieldId)
.option('--show <fields...>', 'include additional fields from API response')
.option('--exclude <fields...>', 'hide fields from summary')
.option(
'--show-only <fields...>',
'show only these fields (mutually exclusive with --show and --exclude)'
)
.action(
withErrorHandling(async (id: CustomSectionFieldId, options) => {
withErrorHandling(async (id: CustomSectionFieldId) => {
const globalOptions = getGlobalOptions(getCommand);
const client = await getAPIClientFromOptions(globalOptions);
const showOnly = options.showOnly as string[] | undefined;
if (showOnly && (options.show || options.exclude)) {
throw new Error('--show-only is mutually exclusive with --show and --exclude');
}

const result = await getCustomField(client, {
id,
show: options.show,
exclude: options.exclude,
showOnly,
show: globalOptions.show,
exclude: globalOptions.exclude,
showOnly: globalOptions.showOnly,
raw: globalOptions.raw,
});
printFormatted(result.data, globalOptions);
Expand Down
75 changes: 32 additions & 43 deletions src/commands/experiments/get.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,15 +144,19 @@ describe('get command', () => {
});

it('should include extra fields with --show', async () => {
await getCommand.parseAsync(['node', 'test', '42', '--show', 'audience']);
vi.mocked(getGlobalOptions).mockReturnValue({ output: 'table', show: ['audience'] } as any);

await getCommand.parseAsync(['node', 'test', '42']);

const data = vi.mocked(printFormatted).mock.calls[0]![0] as Record<string, unknown>;
expect(data.id).toBe(42);
expect('audience' in data).toBe(true);
});

it('should include custom fields by title with --show', async () => {
await getCommand.parseAsync(['node', 'test', '42', '--show', 'Hypothesis']);
vi.mocked(getGlobalOptions).mockReturnValue({ output: 'table', show: ['Hypothesis'] } as any);

await getCommand.parseAsync(['node', 'test', '42']);

const data = vi.mocked(printFormatted).mock.calls[0]![0] as Record<string, unknown>;
expect(data.Hypothesis).toBe('Red button converts better');
Expand Down Expand Up @@ -191,45 +195,18 @@ describe('get command', () => {
});

it('forwards --show-only to getExperiment and outputs only those fields', async () => {
await getCommand.parseAsync(['node', 'test', '42', '--show-only', 'id', 'audience']);
vi.mocked(getGlobalOptions).mockReturnValue({
output: 'table',
showOnly: ['id', 'audience'],
} as any);

await getCommand.parseAsync(['node', 'test', '42']);

const data = vi.mocked(printFormatted).mock.calls[0]![0] as Record<string, unknown>;
expect(Object.keys(data)).toEqual(['id', 'audience']);
expect(data.audience).toEqual({ filter: [] });
});

it('rejects --show-only combined with --show', async () => {
try {
await getCommand.parseAsync([
'node',
'test',
'42',
'--show-only',
'id',
'--show',
'audience',
]);
} catch (_e) {
// process.exit threw a sentinel
}
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error:',
'--show-only is mutually exclusive with --show and --exclude'
);
});

it('rejects --show-only combined with --exclude', async () => {
try {
await getCommand.parseAsync(['node', 'test', '42', '--show-only', 'id', '--exclude', 'tags']);
} catch (_e) {
// process.exit threw a sentinel
}
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error:',
'--show-only is mutually exclusive with --show and --exclude'
);
});

it('renders default sections when no filters are passed', async () => {
vi.mocked(getGlobalOptions).mockReturnValue({ output: 'rendered' } as any);

Expand All @@ -245,29 +222,38 @@ describe('get command', () => {
});

it('renders --exclude audience without ## Audience section', async () => {
vi.mocked(getGlobalOptions).mockReturnValue({ output: 'rendered' } as any);
vi.mocked(getGlobalOptions).mockReturnValue({
output: 'rendered',
exclude: ['audience'],
} as any);

await getCommand.parseAsync(['node', 'test', '42', '--exclude', 'audience']);
await getCommand.parseAsync(['node', 'test', '42']);

const output = consoleSpy.mock.calls.flat().join('');
expect(output).not.toContain('## Audience');
expect(output).toContain('## Variants'); // unrelated section still present
});

it('renders --exclude Hypothesis without that custom field section', async () => {
vi.mocked(getGlobalOptions).mockReturnValue({ output: 'rendered' } as any);
vi.mocked(getGlobalOptions).mockReturnValue({
output: 'rendered',
exclude: ['Hypothesis'],
} as any);

await getCommand.parseAsync(['node', 'test', '42', '--exclude', 'Hypothesis']);
await getCommand.parseAsync(['node', 'test', '42']);

const output = consoleSpy.mock.calls.flat().join('');
expect(output).not.toContain('### Hypothesis');
expect(output).toContain('## Audience'); // unrelated section still present
});

it('renders --show-only id name audience minimally', async () => {
vi.mocked(getGlobalOptions).mockReturnValue({ output: 'rendered' } as any);
vi.mocked(getGlobalOptions).mockReturnValue({
output: 'rendered',
showOnly: ['id', 'name', 'audience'],
} as any);

await getCommand.parseAsync(['node', 'test', '42', '--show-only', 'id', 'name', 'audience']);
await getCommand.parseAsync(['node', 'test', '42']);

const output = consoleSpy.mock.calls.flat().join('');
expect(output).toContain('42');
Expand All @@ -283,9 +269,12 @@ describe('get command', () => {
...fullExperiment,
description: 'long-form description',
});
vi.mocked(getGlobalOptions).mockReturnValue({ output: 'rendered' } as any);
vi.mocked(getGlobalOptions).mockReturnValue({
output: 'rendered',
show: ['description'],
} as any);

await getCommand.parseAsync(['node', 'test', '42', '--show', 'description']);
await getCommand.parseAsync(['node', 'test', '42']);

const output = consoleSpy.mock.calls.flat().join('');
expect(output).toContain('long-form description');
Expand Down
24 changes: 7 additions & 17 deletions src/commands/experiments/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,6 @@ export const getCommand = new Command('get')
.description('Get experiment details')
.argument('<id>', 'experiment ID or name', parseExperimentIdOrName)
.option('--activity', 'include activity notes in the output')
.option(
'--show <fields...>',
'include additional fields in summary (e.g. --show audience archived)'
)
.option('--exclude <fields...>', 'hide fields from summary (e.g. --exclude owners tags)')
.option(
'--show-only <fields...>',
'show only these fields (mutually exclusive with --show and --exclude)'
)
.option('--embed-screenshots', 'embed screenshots as base64 data URIs in template output')
.option('--screenshots-dir <path>', 'save screenshots to directory in template output')
.option(
Expand All @@ -42,10 +33,9 @@ export const getCommand = new Command('get')
const client = await getAPIClientFromOptions(globalOptions);
const id = await client.resolveExperimentId(nameOrId);

const showOnly = options.showOnly as string[] | undefined;
if (showOnly && (options.show || options.exclude)) {
throw new Error('--show-only is mutually exclusive with --show and --exclude');
}
const show = globalOptions.show ?? [];
const exclude = globalOptions.exclude ?? [];
const showOnly = globalOptions.showOnly;

// Template output mode - stays in wrapper (complex formatting)
if (globalOptions.output === 'template') {
Expand All @@ -67,8 +57,8 @@ export const getCommand = new Command('get')
const experiment = await client.getExperiment(id);
const exp = experiment as Record<string, unknown>;

const userShow = (options.show as string[] | undefined) ?? [];
const userExclude = (options.exclude as string[] | undefined) ?? [];
const userShow = show;
const userExclude = exclude;

const customFieldEntries =
(exp.custom_section_field_values as Array<Record<string, unknown>> | undefined) ?? [];
Expand Down Expand Up @@ -321,8 +311,8 @@ export const getCommand = new Command('get')
const result = await getExperiment(client, {
experimentId: id,
activity: options.activity,
show: options.show,
exclude: options.exclude,
show,
exclude,
showOnly,
raw: globalOptions.raw,
});
Expand Down
5 changes: 5 additions & 0 deletions src/commands/experiments/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from 'commander';
import { addFieldProjectionHelp } from '../../lib/utils/api-helper.js';
import { listCommand } from './list.js';
import { getCommand } from './get.js';
import { analyzeCommand } from './analyze.js';
Expand Down Expand Up @@ -73,4 +74,8 @@ export const experimentsCommand = new Command('experiments')
.alias('feature')
.description('Experiment and feature flag commands');

// These read commands honor the global --show/--exclude/--show-only flags;
// surface that on their --help (the flags live on the root program).
for (const cmd of [listCommand, getCommand, customFieldsCommand]) addFieldProjectionHelp(cmd);

for (const cmd of subcommands) experimentsCommand.addCommand(cmd);
18 changes: 3 additions & 15 deletions src/commands/experiments/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,30 +57,18 @@ export const listCommand = new Command('list')
)
.option('--asc', 'sort in ascending order')
.option('--desc', 'sort in descending order')
.option(
'--show <fields...>',
'include additional fields (e.g. --show experiment_report archived)'
)
.option('--exclude <fields...>', 'hide fields (e.g. --exclude primary_metric owner)')
.option(
'--show-only <fields...>',
'show only these fields (mutually exclusive with --show and --exclude)'
)
.action(
withErrorHandling(async (options) => {
const globalOptions = getGlobalOptions(listCommand);
const client = await getAPIClientFromOptions(globalOptions);

const showOnly = options.showOnly as string[] | undefined;
if (showOnly && (options.show || options.exclude)) {
throw new Error('--show-only is mutually exclusive with --show and --exclude');
}

const result = await listExperiments(client, {
...options,
type: options.type || getDefaultType(),
raw: globalOptions.raw,
showOnly,
show: globalOptions.show,
exclude: globalOptions.exclude,
showOnly: globalOptions.showOnly,
});

if (shouldOutputIdsOnly(globalOptions)) {
Expand Down
Loading
Loading