Skip to content
Merged
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,29 @@ slack-cli users presence --name @alice --format json
slack-cli users list --profile work
```

### User Groups

```bash
# List user groups in the workspace
slack-cli usergroups list

# Include disabled user groups
slack-cli usergroups list --include-disabled

# Output in different formats
slack-cli usergroups list --format json
slack-cli usergroups list --format simple

# List members of a user group by ID
slack-cli usergroups members --id S01ABCDEF

# List members of a user group by handle
slack-cli usergroups members --handle @engineers

# Use specific profile
slack-cli usergroups list --profile work
```

### Scheduled Messages

```bash
Expand Down Expand Up @@ -639,6 +662,25 @@ Subcommands: `list`, `info`, `lookup`
| --email | | Email address to look up (required) |
| --format | | Output format: table, simple, json (default: table) |

### usergroups command

Subcommands: `list`, `members`

#### usergroups list

| Option | Short | Description |
| ------------------ | ----- | --------------------------------------------------- |
| --include-disabled | | Include disabled user groups |
| --format | | Output format: table, simple, json (default: table) |

#### usergroups members

| Option | Short | Description |
| -------- | ----- | ---------------------------------------------------- |
| --id | | User group ID (either --id or --handle is required) |
| --handle | | User group handle (e.g. @engineers) |
| --format | | Output format: table, simple, json (default: table) |

### scheduled command

Subcommands: `list`, `cancel`
Expand Down Expand Up @@ -799,6 +841,7 @@ Your Slack API token needs the following scopes:
- `im:write` - Open DM channels for --user/--email DM sending
- `users:read` - Access user information for unread counts and user listing
- `users:read.email` - Look up users by email address
- `usergroups:read` - List user groups and their members
- `search:read` - Search messages (user token only, not supported with bot tokens)
- `reactions:write` - Add and remove reactions
- `pins:read` - List pinned items in a channel
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mimo-3/slack-cli",
"version": "0.23.0",
"version": "0.24.0",
"description": "A command-line tool for sending messages to Slack",
"main": "dist/index.js",
"bin": {
Expand Down
118 changes: 118 additions & 0 deletions src/commands/usergroups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Command } from 'commander';
import { UsergroupsListOptions, UsergroupsMembersOptions } from '../types/commands';
import { SlackUsergroup } from '../types/slack';
import { renderByFormat, withSlackClient } from '../utils/command-support';
import { wrapCommand } from '../utils/command-wrapper';
import { createMembersFormatter, MemberInfo } from '../utils/formatters/members-formatters';
import { parseFormat } from '../utils/option-parsers';
import { sanitizeTerminalData, sanitizeTerminalText } from '../utils/terminal-sanitizer';
import { createValidationHook, optionValidators } from '../utils/validators';

function renderUsergroupTable(usergroups: SlackUsergroup[]) {
const rows = usergroups.map((usergroup) => ({
id: sanitizeTerminalText(usergroup.id || ''),
handle: sanitizeTerminalText(usergroup.handle || ''),
name: sanitizeTerminalText(usergroup.name || ''),
description: sanitizeTerminalText(usergroup.description || ''),
user_count: usergroup.user_count ?? '',
}));

console.table(sanitizeTerminalData(rows));
}

function renderUsergroupSimple(usergroups: SlackUsergroup[]) {
for (const usergroup of usergroups) {
console.log(
`${sanitizeTerminalText(usergroup.id || '')}\t@${sanitizeTerminalText(
usergroup.handle || ''
)}\t${sanitizeTerminalText(usergroup.name || '')}`
);
}
}

export function setupUsergroupsCommand(): Command {
const usergroupsCommand = new Command('usergroups').description(
'List user groups and their members'
);

const listCommand = new Command('list')
.description('List user groups in the workspace')
.option('--include-disabled', 'Include disabled user groups')
.option('--format <format>', 'Output format: table, simple, json', 'table')
.option('--profile <profile>', 'Use specific workspace profile')
.hook('preAction', createValidationHook([optionValidators.format]))
.action(
wrapCommand(async (options: UsergroupsListOptions) => {
await withSlackClient(options, async (client) => {
const usergroups = await client.listUsergroups(options.includeDisabled ?? false);

if (usergroups.length === 0) {
console.log('No usergroups found');
return;
}

renderByFormat(options, usergroups, {
table: renderUsergroupTable,
simple: renderUsergroupSimple,
});
});
})
);

const membersCommand = new Command('members')
.description('List members of a user group')
.option('--id <usergroupId>', 'User group ID')
.option('--handle <handle>', 'User group handle (e.g. @engineers)')
.option('--format <format>', 'Output format: table, simple, json', 'table')
.option('--profile <profile>', 'Use specific workspace profile')
.hook('preAction', createValidationHook([optionValidators.format]))
.action(
wrapCommand(async (options: UsergroupsMembersOptions) => {
if (!options.id && !options.handle) {
throw new Error('You must specify either --id or --handle');
}
if (options.id && options.handle) {
throw new Error('Cannot use both --id and --handle');
}

await withSlackClient(options, async (client) => {
let usergroupId: string;
if (options.handle) {
usergroupId = await client.resolveUsergroupIdByHandle(options.handle);
} else {
usergroupId = options.id!;
}

const memberIds = await client.listUsergroupMembers(usergroupId);

if (memberIds.length === 0) {
console.log('No members found');
return;
}

const members: MemberInfo[] = await Promise.all(
memberIds.map(async (userId) => {
try {
const user = await client.getUserInfo(userId);
return {
id: userId,
name: user.name,
realName: user.real_name,
};
} catch {
return { id: userId };
}
})
);

const format = parseFormat(options.format);
createMembersFormatter(format).format({ members });
});
})
);

usergroupsCommand.addCommand(listCommand);
usergroupsCommand.addCommand(membersCommand);

return usergroupsCommand;
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { setupSendCommand } from './commands/send';
import { setupSendEphemeralCommand } from './commands/send-ephemeral';
import { setupUnreadCommand } from './commands/unread';
import { setupUploadCommand } from './commands/upload';
import { setupUsergroupsCommand } from './commands/usergroups';
import { setupUsersCommand } from './commands/users';
import { checkForUpdates } from './utils/update-notifier';

Expand Down Expand Up @@ -62,6 +63,7 @@ export function createProgram(): Command {
program.addCommand(setupReactionCommand());
program.addCommand(setupPinCommand());
program.addCommand(setupUsersCommand());
program.addCommand(setupUsergroupsCommand());
program.addCommand(setupChannelCommand());
program.addCommand(setupMembersCommand());
program.addCommand(setupSendEphemeralCommand());
Expand Down
13 changes: 13 additions & 0 deletions src/types/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,19 @@ export interface UsersPresenceOptions {
profile?: string;
}

export interface UsergroupsListOptions {
includeDisabled?: boolean;
format?: 'table' | 'simple' | 'json';
profile?: string;
}

export interface UsergroupsMembersOptions {
id?: string;
handle?: string;
format?: 'table' | 'simple' | 'json';
profile?: string;
}

export interface ChannelInfoOptions {
channel: string;
format?: 'table' | 'simple' | 'json';
Expand Down
12 changes: 12 additions & 0 deletions src/types/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ export interface UserPresence {
presence: string;
}

export interface SlackUsergroup {
id?: string;
team_id?: string;
name?: string;
handle?: string;
description?: string;
is_external?: boolean;
date_create?: number;
date_delete?: number;
user_count?: number;
}

export interface SlackUser {
id?: string;
name?: string;
Expand Down
16 changes: 16 additions & 0 deletions src/utils/slack-client-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
SearchMessagesOptions,
SearchResult,
SlackUser,
SlackUsergroup,
StarListResult,
UserPresence,
} from '../types/slack';
Expand All @@ -37,6 +38,7 @@ import { ReminderOperations } from './slack-operations/reminder-operations';
import { SearchOperations } from './slack-operations/search-operations';
import { StarOperations } from './slack-operations/star-operations';
import { UserOperations } from './slack-operations/user-operations';
import { UsergroupOperations } from './slack-operations/usergroup-operations';

export class SlackApiClient {
private channelOps: ChannelOperations;
Expand All @@ -45,6 +47,7 @@ export class SlackApiClient {
private reactionOps: ReactionOperations;
private pinOps: PinOperations;
private userOps: UserOperations;
private usergroupOps: UsergroupOperations;
private searchOps: SearchOperations;
private reminderOps: ReminderOperations;
private starOps: StarOperations;
Expand All @@ -58,6 +61,7 @@ export class SlackApiClient {
this.reactionOps = new ReactionOperations(sharedContext, this.channelOps);
this.pinOps = new PinOperations(sharedContext, this.channelOps);
this.userOps = new UserOperations(sharedContext);
this.usergroupOps = new UsergroupOperations(sharedContext);
this.searchOps = new SearchOperations(sharedContext);
this.reminderOps = new ReminderOperations(sharedContext);
this.starOps = new StarOperations(sharedContext);
Expand Down Expand Up @@ -215,6 +219,18 @@ export class SlackApiClient {
return this.userOps.resolveUserIdByName(username);
}

async listUsergroups(includeDisabled?: boolean): Promise<SlackUsergroup[]> {
return this.usergroupOps.listUsergroups(includeDisabled);
}

async listUsergroupMembers(usergroupId: string): Promise<string[]> {
return this.usergroupOps.listUsergroupMembers(usergroupId);
}

async resolveUsergroupIdByHandle(handle: string): Promise<string> {
return this.usergroupOps.resolveUsergroupIdByHandle(handle);
}

async searchMessages(query: string, options?: SearchMessagesOptions): Promise<SearchResult> {
return this.searchOps.searchMessages(query, options);
}
Expand Down
39 changes: 39 additions & 0 deletions src/utils/slack-operations/usergroup-operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { SlackUsergroup } from '../../types/slack';
import { ApiError } from '../errors';
import { BaseSlackClient, SlackClientDependency } from './base-client';

export class UsergroupOperations extends BaseSlackClient {
constructor(dependency: SlackClientDependency) {
super(dependency);
}

async listUsergroups(includeDisabled = false): Promise<SlackUsergroup[]> {
const response = await this.client.usergroups.list({
include_count: true,
...(includeDisabled ? { include_disabled: true } : {}),
});

return (response.usergroups || []) as SlackUsergroup[];
}

async listUsergroupMembers(usergroupId: string): Promise<string[]> {
const response = await this.client.usergroups.users.list({
usergroup: usergroupId,
});

return (response.users || []) as string[];
}

async resolveUsergroupIdByHandle(handle: string): Promise<string> {
const normalized = handle.replace(/^@/, '').toLowerCase();

const usergroups = await this.listUsergroups(true);
for (const usergroup of usergroups) {
if (usergroup.handle?.toLowerCase() === normalized) {
return usergroup.id!;
}
}

throw new ApiError(`Usergroup '@${handle.replace(/^@/, '')}' not found`);
}
}
Loading
Loading