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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1192,7 +1192,8 @@ abs events list --from 7d --to now
abs events list --event-name my-experiment --event-type exposure --from 5h
abs events list --app 1 --unit-type 2 --event-type exposure --items 50
abs events list --unit-uid user123 --env-type production
abs events list --effective-exposures --from 1d # only assignment-changing exposures
abs events list --valid-exposures --from 1d # only assignment-changing exposures
abs events list --invalid-exposures --from 1d # only ineffective exposures (filtered out)
abs events list --event-name exp1 --event-name exp2 # multiple names (all filters repeatable)
abs events history --from 7d --period 1d
abs events history --event-name my-experiment --env-type production
Expand Down
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.5.0",
"version": "1.6.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
24 changes: 24 additions & 0 deletions src/commands/events/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,30 @@ describe('events command', () => {
expect(printFormatted).toHaveBeenCalled();
});

it('should send effective_exposures=true with --valid-exposures', async () => {
await eventsCommand.parseAsync(['node', 'test', 'list', '--valid-exposures']);
expect(mockClient.listEvents).toHaveBeenCalledWith({
filters: { effective_exposures: true },
});
});

it('should send effective_exposures=false with --invalid-exposures', async () => {
await eventsCommand.parseAsync(['node', 'test', 'list', '--invalid-exposures']);
expect(mockClient.listEvents).toHaveBeenCalledWith({
filters: { effective_exposures: false },
});
});

it('should reject combining --valid-exposures and --invalid-exposures', async () => {
await expect(
eventsCommand.parseAsync(['node', 'test', 'list', '--valid-exposures', '--invalid-exposures'])
).rejects.toThrow('process.exit');
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('mutually exclusive')
);
});

it('should list events history with filters', async () => {
await eventsCommand.parseAsync([
'node',
Expand Down
14 changes: 12 additions & 2 deletions src/commands/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,24 @@ const listCommand = new Command('list')
parseStringArray,
[]
)
.option('--effective-exposures', 'only effective exposures')
.option('--valid-exposures', 'only valid (effective) exposures')
.option('--invalid-exposures', 'only invalid (ineffective) exposures')
.option('--items <count>', 'number of items to take', Number)
.option('--skip <count>', 'number of items to skip', Number)
.action(
withErrorHandling(async (options) => {
const globalOptions = getGlobalOptions(listCommand);
const client = await getAPIClientFromOptions(globalOptions);

if (options.validExposures && options.invalidExposures) {
throw new Error('--valid-exposures and --invalid-exposures are mutually exclusive');
}
Comment on lines 60 to +65
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate mutually exclusive flags before client initialisation.

With invalid combined flags, the handler still initialises the API client first. That can surface unrelated auth/config errors instead of the intended usage error.

Suggested patch
   .action(
     withErrorHandling(async (options) => {
       const globalOptions = getGlobalOptions(listCommand);
-      const client = await getAPIClientFromOptions(globalOptions);

       if (options.validExposures && options.invalidExposures) {
         throw new Error('--valid-exposures and --invalid-exposures are mutually exclusive');
       }
+      const client = await getAPIClientFromOptions(globalOptions);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const globalOptions = getGlobalOptions(listCommand);
const client = await getAPIClientFromOptions(globalOptions);
if (options.validExposures && options.invalidExposures) {
throw new Error('--valid-exposures and --invalid-exposures are mutually exclusive');
}
const globalOptions = getGlobalOptions(listCommand);
if (options.validExposures && options.invalidExposures) {
throw new Error('--valid-exposures and --invalid-exposures are mutually exclusive');
}
const client = await getAPIClientFromOptions(globalOptions);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/events/index.ts` around lines 60 - 65, The mutually-exclusive
flag check runs after initialising the API client; move the validation so it
runs immediately after computing globalOptions and before calling
getAPIClientFromOptions. Specifically, in the handler that currently calls
getGlobalOptions(listCommand) and then getAPIClientFromOptions(globalOptions),
perform the if (options.validExposures && options.invalidExposures) throw new
Error(...) check right after getGlobalOptions(listCommand) (or even before that
if options are available), and only call getAPIClientFromOptions(globalOptions)
once the flags validation passes to avoid unnecessary client initialisation.

const validExposures = options.validExposures
? true
: options.invalidExposures
? false
: undefined;

const fromTs = parseDateFlagOrUndefined(options.from);
const toTs = parseDateFlagOrUndefined(options.to);

Expand All @@ -71,7 +81,7 @@ const listCommand = new Command('list')
eventNames: options.eventName.length > 0 ? options.eventName : undefined,
unitUids: options.unitUid.length > 0 ? options.unitUid : undefined,
environmentTypes: options.envType.length > 0 ? options.envType : undefined,
effectiveExposures: options.effectiveExposures || undefined,
validExposures,
take: options.items,
skip: options.skip,
});
Expand Down
4 changes: 2 additions & 2 deletions src/commands/experiments/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import {
getAPIClientFromOptions,
getGlobalOptions,
printFormatted,
shouldOutputIdsOnly,
withErrorHandling,
} from '../../lib/utils/api-helper.js';
import { printPaginationFooter } from '../../lib/utils/pagination.js';
import { getDefaultType } from './default-type.js';
import { isStdoutPiped } from '../../lib/utils/stdin.js';
import { listExperiments } from '../../core/experiments/list.js';

export const listCommand = new Command('list')
Expand Down Expand Up @@ -73,7 +73,7 @@ export const listCommand = new Command('list')
raw: globalOptions.raw,
});

if (globalOptions.output === 'ids' || (isStdoutPiped() && !options.output)) {
if (shouldOutputIdsOnly(globalOptions)) {
for (const exp of result.data) console.log((exp as Record<string, unknown>).id);
return;
}
Expand Down
22 changes: 22 additions & 0 deletions src/core/events/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,28 @@ describe('events', () => {
await listEvents(mockClient as any, {});
expect(mockClient.listEvents).toHaveBeenCalledWith({});
});

it('should send effective_exposures=true when validExposures is true', async () => {
mockClient.listEvents.mockResolvedValue([]);
await listEvents(mockClient as any, { validExposures: true });
expect(mockClient.listEvents).toHaveBeenCalledWith({
filters: { effective_exposures: true },
});
});

it('should send effective_exposures=false when validExposures is false', async () => {
mockClient.listEvents.mockResolvedValue([]);
await listEvents(mockClient as any, { validExposures: false });
expect(mockClient.listEvents).toHaveBeenCalledWith({
filters: { effective_exposures: false },
});
});

it('should omit effective_exposures when validExposures is undefined', async () => {
mockClient.listEvents.mockResolvedValue([]);
await listEvents(mockClient as any, { from: 1 });
expect(mockClient.listEvents).toHaveBeenCalledWith({ filters: { from: 1 } });
});
});

describe('listEventsHistory', () => {
Expand Down
4 changes: 2 additions & 2 deletions src/core/events/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export interface ListEventsParams {
eventNames?: string[] | undefined;
unitUids?: string[] | undefined;
environmentTypes?: string[] | undefined;
effectiveExposures?: boolean | undefined;
validExposures?: boolean | undefined;
take?: number | undefined;
skip?: number | undefined;
}
Expand All @@ -64,7 +64,7 @@ export async function listEvents(
if (params.unitUids && params.unitUids.length > 0) filters.unit_uids = params.unitUids;
if (params.environmentTypes && params.environmentTypes.length > 0)
filters.environment_types = params.environmentTypes;
if (params.effectiveExposures) filters.effective_exposures = true;
if (params.validExposures !== undefined) filters.effective_exposures = params.validExposures;

const body: Record<string, unknown> = {};
if (Object.keys(filters).length > 0) body.filters = filters;
Expand Down
82 changes: 82 additions & 0 deletions src/lib/utils/api-helper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ import {
getAPIClientFromOptions,
getGlobalOptions,
printFormatted,
shouldOutputIdsOnly,
withErrorHandling,
resolveAuth,
} from './api-helper.js';
import { loadConfig, getProfile } from '../config/config.js';
import { getAPIKey, getOAuthToken } from '../config/keyring.js';
import { createAPIClient } from '../api/client.js';
import { formatOutput } from '../output/formatter.js';
import { setTTYOverride } from './stdin.js';

describe('API Helper', () => {
const savedEnv: Record<string, string | undefined> = {};
Expand Down Expand Up @@ -307,6 +309,86 @@ describe('API Helper', () => {
})
);
});

describe('outputExplicit', () => {
it('is false when --output is not passed on the CLI', () => {
// Mock command at line 192 registers --output with a default of 'table'.
// Defaults must NOT count as "explicit" — that's the whole point.
mockCommand.parse(['node', 'test']);

const options = getGlobalOptions(mockCommand);
expect(options.output).toBe('table');
expect(options.outputExplicit).toBe(false);
});

it('is true when --output is passed on the CLI', () => {
mockCommand.parse(['node', 'test', '--output', 'json']);

const options = getGlobalOptions(mockCommand);
expect(options.output).toBe('json');
expect(options.outputExplicit).toBe(true);
});

it('is true even when the user explicitly chooses table', () => {
// Regression: prior implementation could not distinguish "no flag"
// from "explicit -o table", which broke piping into table format.
mockCommand.parse(['node', 'test', '--output', 'table']);

const options = getGlobalOptions(mockCommand);
expect(options.output).toBe('table');
expect(options.outputExplicit).toBe(true);
});

it('is detected on the leaf subcommand when --output is set as a global option', () => {
const root = new Command();
root.option('-o, --output <format>', 'output format');
const sub = new Command('list');
root.addCommand(sub);

root.parse(['node', 'test', '--output', 'json', 'list']);

const options = getGlobalOptions(sub);
expect(options.output).toBe('json');
expect(options.outputExplicit).toBe(true);
});
});
});

describe('shouldOutputIdsOnly', () => {
afterEach(() => {
setTTYOverride({ stdin: true, stdout: true });
});

it('returns true when output is explicitly "ids"', () => {
setTTYOverride({ stdout: true });
expect(shouldOutputIdsOnly({ output: 'ids', outputExplicit: true })).toBe(true);
});

it('returns true when stdout is piped and no explicit --output was given', () => {
setTTYOverride({ stdout: false });
expect(shouldOutputIdsOnly({ output: 'table', outputExplicit: false })).toBe(true);
});

it('returns false when stdout is piped but the user explicitly chose --output json', () => {
// Regression: this is the original bug — piped + -o json was forcing ids.
setTTYOverride({ stdout: false });
expect(shouldOutputIdsOnly({ output: 'json', outputExplicit: true })).toBe(false);
});

it('returns false when stdout is piped but the user explicitly chose --output table', () => {
setTTYOverride({ stdout: false });
expect(shouldOutputIdsOnly({ output: 'table', outputExplicit: true })).toBe(false);
});

it('returns false on a TTY when no explicit output was given', () => {
setTTYOverride({ stdout: true });
expect(shouldOutputIdsOnly({ output: 'table', outputExplicit: false })).toBe(false);
});

it('returns false on a TTY when explicit --output json was given', () => {
setTTYOverride({ stdout: true });
expect(shouldOutputIdsOnly({ output: 'json', outputExplicit: true })).toBe(false);
});
});

describe('withErrorHandling', () => {
Expand Down
15 changes: 15 additions & 0 deletions src/lib/utils/api-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getAPIKey, getOAuthToken } from '../config/keyring.js';
import type { AuthConfig } from '../api/axios-adapter.js';
import { Command } from 'commander';
import { formatOutput, type OutputFormat } from '../output/formatter.js';
import { isStdoutPiped } from './stdin.js';

export async function resolveAPIKey(options: GlobalOptions): Promise<string> {
const config = loadConfig();
Expand Down Expand Up @@ -101,6 +102,10 @@ export interface GlobalOptions {
app?: string;
env?: string;
output?: OutputFormat;
// True when the user explicitly set --output (via CLI or env), false when
// the value was defaulted. Used to decide whether to auto-switch to ids
// mode when stdout is piped.
outputExplicit?: boolean;
noColor?: boolean;
// True when the user explicitly disabled color (--no-color or NO_COLOR env).
// noColor above additionally disables when stdout isn't a TTY; this field
Expand Down Expand Up @@ -135,6 +140,8 @@ const VALID_FORMATS: OutputFormat[] = [
export function getGlobalOptions(cmd: Command): GlobalOptions {
const opts = cmd.optsWithGlobals();
const output = (opts.output || 'table') as OutputFormat;
const outputSource = cmd.getOptionValueSourceWithGlobals('output');
const outputExplicit = outputSource === 'cli' || outputSource === 'env';

if (!VALID_FORMATS.includes(output)) {
throw new Error(
Expand All @@ -155,6 +162,7 @@ export function getGlobalOptions(cmd: Command): GlobalOptions {
app: opts.app,
env: opts.env,
output,
outputExplicit,
noColor: colorDisabled || !process.stdout.isTTY,
colorDisabled,
verbose: opts.verbose || false,
Expand All @@ -172,6 +180,13 @@ export function getGlobalOptions(cmd: Command): GlobalOptions {
};
}

// When stdout is piped to another process, list commands fall back to
// emitting ids only — unless the user explicitly chose an output format.
// Centralizes the rule so every list-style command behaves the same way.
export function shouldOutputIdsOnly(globalOptions: GlobalOptions): boolean {
return globalOptions.output === 'ids' || (isStdoutPiped() && !globalOptions.outputExplicit);
}

export function printFormatted(data: unknown, globalOptions: GlobalOptions): void {
const output = formatOutput(data, globalOptions.output, {
noColor: globalOptions.noColor ?? false,
Expand Down
63 changes: 63 additions & 0 deletions src/lib/utils/list-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createListCommand } from './list-command.js';
import { getAPIClientFromOptions, getGlobalOptions, printFormatted } from './api-helper.js';
import { resetCommand } from '../../test/helpers/command-reset.js';
import { setTTYOverride } from './stdin.js';

vi.mock('./api-helper.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./api-helper.js')>();
Expand Down Expand Up @@ -68,4 +69,66 @@ describe('createListCommand', () => {
expect(mockFetch).toHaveBeenCalledWith(mockClient, expect.any(Object));
expect(printFormatted).toHaveBeenCalled();
});

describe('output behavior under piping', () => {
afterEach(() => {
setTTYOverride({ stdin: true, stdout: true });
});

it('prints only ids when stdout is piped and no explicit output is set', async () => {
setTTYOverride({ stdout: false });
vi.mocked(getGlobalOptions).mockReturnValue({
output: 'table',
outputExplicit: false,
} as any);
const fetch = vi.fn().mockResolvedValue([{ id: 1 }, { id: 2 }, { id: 3 }]);
const cmd = createListCommand({ description: 'List items', fetch });
resetCommand(cmd);

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

expect(printFormatted).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(1);
expect(consoleSpy).toHaveBeenCalledWith(2);
expect(consoleSpy).toHaveBeenCalledWith(3);
});

it('renders the requested format when stdout is piped and -o json is explicit', async () => {
// Regression: previously `--items 200 -o json` over a pipe collapsed
// to id-only output because the command read its local `options.output`
// (always undefined — the flag lives on the root program), so the
// "is explicit?" check always said no.
setTTYOverride({ stdout: false });
vi.mocked(getGlobalOptions).mockReturnValue({
output: 'json',
outputExplicit: true,
} as any);
const fetch = vi.fn().mockResolvedValue([{ id: 1 }, { id: 2 }]);
const cmd = createListCommand({ description: 'List items', fetch });
resetCommand(cmd);

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

expect(printFormatted).toHaveBeenCalledOnce();
// Items must not have been spilled as bare ids on stdout.
expect(consoleSpy).not.toHaveBeenCalledWith(1);
expect(consoleSpy).not.toHaveBeenCalledWith(2);
});

it('prints only ids when output is explicitly "ids", even on a TTY', async () => {
setTTYOverride({ stdout: true });
vi.mocked(getGlobalOptions).mockReturnValue({
output: 'ids',
outputExplicit: true,
} as any);
const fetch = vi.fn().mockResolvedValue([{ id: 42 }]);
const cmd = createListCommand({ description: 'List items', fetch });
resetCommand(cmd);

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

expect(printFormatted).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(42);
});
});
});
Loading
Loading