Skip to content
This repository was archived by the owner on Jul 8, 2026. It is now read-only.
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ docs/plans/
docs/
PLAN-*.md
.worktrees/
.superpowers/
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1012,12 +1012,28 @@ Aliases: `users`, `user`
```bash
abs users list
abs users list --include-archived
abs users list --search alice # server-side fuzzy search across name/email/department/job_title
abs users list --sort email --asc # sort by a field
abs users list --ids 1,2,3 # filter by user IDs
abs users get 123
abs users create --email user@example.com --name "Jane Doe"
abs users update 123 --name "Jane Smith"
abs users archive 123
abs users archive 123 --unarchive

# Filter users by department, role, job title, email, or name (multi-value, exact by default)
abs users list --department Ancillaries # everyone in Ancillaries
abs users list --department Ancillaries,Finance # Ancillaries OR Finance
abs users list --role "API User" # everyone with the API User role (name or numeric ID)
abs users list --role "API User",Admin --department Ancillaries # role AND department
abs users list --job-title CEO --department Ancillaries
abs users list --email alice@acme.com,bob@acme.com
abs users list --name "Alice Smith"
abs users list --department anc --contains # substring match (case-insensitive) for department/job-title/email/name
# When a field filter is active, a `roles` column (global-team roles) is shown;
# the API has no server-side department/role filter, so client filters scan all
# users (200/page) and paginate the matched set.

# Reset password
abs users reset-password 123

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.12.0",
"version": "1.14.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
50 changes: 50 additions & 0 deletions src/api-client/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,56 @@ describe.skipIf(isLiveMode)('APIClient core', () => {
expect(await client.listUsers()).toHaveLength(1);
});

it('should serialize native list filters as query params', async () => {
let capturedUrl: string | undefined;
server.use(
http.get(`${BASE_URL}/users`, ({ request }) => {
capturedUrl = request.url;
return HttpResponse.json({ users: [{ id: 1, email: 'a@b.c' }] });
})
);
await client.listUsers({
search: 'alice',
sort: 'email',
sort_asc: true,
ids: '1,2,3',
});
expect(capturedUrl).toBeDefined();
const url = new URL(capturedUrl!);
expect(url.searchParams.get('search')).toBe('alice');
expect(url.searchParams.get('sort')).toBe('email');
expect(url.searchParams.get('sort_asc')).toBe('true');
expect(url.searchParams.get('ids')).toBe('1,2,3');
});

it('should send include_archived when includeArchived is true', async () => {
let capturedUrl: string | undefined;
server.use(
http.get(`${BASE_URL}/users`, ({ request }) => {
capturedUrl = request.url;
return HttpResponse.json({ users: [] });
})
);
await client.listUsers({ includeArchived: true });
expect(new URL(capturedUrl!).searchParams.get('include_archived')).toBe('1');
});

it('should not send native filter params when omitted', async () => {
let capturedUrl: string | undefined;
server.use(
http.get(`${BASE_URL}/users`, ({ request }) => {
capturedUrl = request.url;
return HttpResponse.json({ users: [] });
})
);
await client.listUsers();
const url = new URL(capturedUrl!);
expect(url.searchParams.get('sort')).toBeNull();
expect(url.searchParams.get('sort_asc')).toBeNull();
expect(url.searchParams.get('ids')).toBeNull();
expect(url.searchParams.get('search')).toBeNull();
});

it('should reset user password', async () => {
server.use(
http.put(`${BASE_URL}/users/1/reset-password`, () =>
Expand Down
13 changes: 12 additions & 1 deletion src/api-client/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -778,14 +778,25 @@ export class APIClient {
}

async listUsers(
options: { includeArchived?: boolean; search?: string; items?: number; page?: number } = {}
options: {
includeArchived?: boolean;
search?: string;
sort?: string;
sort_asc?: boolean;
ids?: string;
items?: number;
page?: number;
} = {}
): Promise<User[]> {
const params: Record<string, string> = {
items: String(options.items ?? 100),
page: String(options.page ?? 1),
};
if (options.includeArchived) params.include_archived = '1';
if (options.search) params.search = options.search;
if (options.sort) params.sort = options.sort;
if (options.sort_asc !== undefined) params.sort_asc = String(options.sort_asc);
if (options.ids) params.ids = options.ids;
const response = await this.request('GET', '/users', { params });
return this.validateListResponse<User>(response, 'users', 'listUsers');
}
Expand Down
141 changes: 127 additions & 14 deletions src/commands/users/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import {
type GlobalOptions,
} from '../../lib/utils/api-helper.js';
import { parseUserId } from '../../lib/utils/validators.js';
import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js';
import {
addPaginationOptions,
printPaginationFooter,
printFilteredFooter,
} from '../../lib/utils/pagination.js';
import { renderInlineImage, supportsInlineImages } from '../../lib/utils/terminal-image.js';
import type { UserId } from '../../lib/api/branded-types.js';
import {
Expand All @@ -30,6 +34,16 @@ import {
updateUser as coreUpdateUser,
archiveUser as coreArchiveUser,
} from '../../core/users/index.js';
import {
parseFilterValues,
hasClientFilters,
resolveRoleIds,
fetchAllUsers,
filterUsers,
buildRoleNameMap,
formatUserRoles,
type UserClientFilters,
} from '../../core/users/filter.js';
import { resetPasswordCommand } from './reset-password.js';
import { userApiKeysCommand } from './api-keys.js';

Expand Down Expand Up @@ -71,6 +85,23 @@ const listCommand = addPaginationOptions(
new Command('list')
.description('List all users')
.option('--include-archived', 'include archived users')
.option('--search <query>', 'server-side fuzzy search across name/email/department/job_title')
.option('--sort <field>', 'sort by field (e.g. email, created_at)')
.option('--asc', 'sort in ascending order')
.option('--desc', 'sort in descending order')
Comment on lines +90 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

No validation for conflicting --asc/--desc.

options.asc ? true : options.desc ? false : undefined silently prefers --asc when both flags are passed, with no warning to the user. Commander supports .conflicts() on Option instances for exactly this case.

🐛 Proposed fix
     const sortAsc = options.asc ? true : options.desc ? false : undefined;
+    if (options.asc && options.desc) {
+      console.error('Error: --asc and --desc cannot be used together');
+      process.exit(1);
+    }

Also applies to: 124-124

🤖 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/users/index.ts` around lines 90 - 91, The users command option
parsing currently allows both --asc and --desc to be passed, and the current
ternary in the users command silently picks asc over desc. Update the option
definitions in the users command (the Option chain where --asc and --desc are
declared, and the related handling that reads options.asc/options.desc) to
declare these flags as mutually exclusive using Commander’s conflicts support so
the CLI errors instead of choosing one. Apply the same validation wherever the
same asc/desc option pair is defined in the command setup to keep behavior
consistent.

.option('--ids <ids>', 'filter by user IDs (comma-separated)')
.option(
'--department <values>',
'filter by department, exact match (comma-separated; use --contains for substring)'
)
.option('--role <values>', 'filter by role name or ID (comma-separated; scans all users)')
.option('--job-title <values>', 'filter by job title, exact match (comma-separated)')
.option('--email <values>', 'filter by email, exact match (comma-separated)')
.option('--name <values>', 'filter by full name, exact match (comma-separated)')
.option(
'--contains',
'match --department/--job-title/--email/--name as substrings (case-insensitive)'
)
.option(
'--show-avatars [cols]',
'display avatars inline, optional width in columns (default: 3)',
Expand All @@ -82,12 +113,71 @@ const listCommand = addPaginationOptions(
const client = await getAPIClientFromOptions(globalOptions);
const { show = [], exclude = [], showOnly } = globalOptions;

const result = await coreListUsers(client, {
includeArchived: options.includeArchived,
items: options.items,
page: options.page,
const clientFiltersActive = hasClientFilters({
department: options.department,
role: options.role,
jobTitle: options.jobTitle,
email: options.email,
name: options.name,
});
const users = result.data;

const sortAsc = options.asc ? true : options.desc ? false : undefined;
// The roles column is about the field filters (it explains why rows matched a
// role/department query); native --search/--ids don't warrant a full roles scan.
const rolesColumnActive = clientFiltersActive;

// Parse a filter flag, erroring if it was given but reduced to nothing (e.g.
// `--department " , "`) rather than silently returning the full unfiltered list.
const parseFilterFlag = (flag: string, raw: string): string[] => {
const values = parseFilterValues(raw);
if (values.length === 0) {
throw new Error(`${flag} was given no usable values (got "${raw}").`);
}
return values;
};

// Resolve client-side filter values up front (role names -> IDs).
const filters: UserClientFilters = {};
if (options.department)
filters.department = parseFilterFlag('--department', options.department);
if (options.role)
filters.roleIds = await resolveRoleIds(client, parseFilterFlag('--role', options.role));
if (options.jobTitle) filters.jobTitle = parseFilterFlag('--job-title', options.jobTitle);
if (options.email) filters.email = parseFilterFlag('--email', options.email);
if (options.name) filters.name = parseFilterFlag('--name', options.name);

let users: unknown[];
let total: number | undefined;

if (clientFiltersActive) {
// Scan all pages honoring native filters, then apply client predicates and
// paginate the matched set client-side (consistent with --metric on
// experiments list, PR #47). The API has no server-side department/role
// filter, so this is the only way to satisfy exact + multi-value asks.
const scanOptions = {
...(options.includeArchived && { includeArchived: true }),
...(options.search && { search: options.search }),
...(options.sort && { sort: options.sort }),
...(sortAsc !== undefined && { sort_asc: sortAsc }),
...(options.ids && { ids: options.ids }),
};
const all = await fetchAllUsers(client, scanOptions);
const matched = filterUsers(all, filters, { contains: options.contains });
total = matched.length;
const start = (options.page - 1) * options.items;
users = matched.slice(start, start + options.items);
} else {
const result = await coreListUsers(client, {
includeArchived: options.includeArchived,
items: options.items,
page: options.page,
...(options.search && { search: options.search }),
...(options.sort && { sort: options.sort }),
...(sortAsc !== undefined && { sortAsc }),
...(options.ids && { ids: options.ids }),
});
users = result.data;
}

const wantAvatars =
options.showAvatars !== undefined &&
Expand All @@ -96,6 +186,25 @@ const listCommand = addPaginationOptions(
globalOptions.output !== 'json' &&
globalOptions.output !== 'yaml';

// Roles column: shown when a client field filter is active, resolved from an
// up-front scan of all roles (200/page). Only global-team roles appear, since
// the /users list response only includes user_team_roles for the global team.
let roleNames: Map<number, string> | undefined;
if (
rolesColumnActive &&
!globalOptions.raw &&
globalOptions.output !== 'json' &&
globalOptions.output !== 'yaml'
) {
roleNames = await buildRoleNameMap(client);
}

const summarizeWithRoles = (u: Record<string, unknown>): Record<string, unknown> => {
const row = summarizeUserRow(u);
if (roleNames) row.roles = formatUserRoles(u, roleNames);
return row;
};

if (wantAvatars) {
const avatarWidth = typeof options.showAvatars === 'number' ? options.showAvatars : 3;
const endpoint = resolveEndpoint(globalOptions);
Expand Down Expand Up @@ -129,7 +238,7 @@ const listCommand = addPaginationOptions(
);

const rows = (users as Array<Record<string, unknown>>).map((u) =>
applyShowExclude(summarizeUserRow(u), u, show, exclude, showOnly)
applyShowExclude(summarizeWithRoles(u), u, show, exclude, showOnly)
);
const keys = rows.length > 0 ? Object.keys(rows[0]!) : [];

Expand Down Expand Up @@ -178,17 +287,21 @@ const listCommand = addPaginationOptions(
const data = globalOptions.raw
? users
: (users as Array<Record<string, unknown>>).map((u) =>
applyShowExclude(summarizeUserRow(u), u, show, exclude, showOnly)
applyShowExclude(summarizeWithRoles(u), u, show, exclude, showOnly)
);
printFormatted(data, globalOptions);
}

printPaginationFooter(
users.length,
options.items,
options.page,
globalOptions.output as string
);
if (clientFiltersActive && total !== undefined) {
printFilteredFooter(total, globalOptions.output as string, options.page, options.items);
} else {
printPaginationFooter(
users.length,
options.items,
options.page,
globalOptions.output as string
);
}
})
);

Expand Down
Loading
Loading