From 17042d362f66953ba36fc646bca89c3ec756c56d Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:34:30 +0900 Subject: [PATCH 01/11] =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC?= =?UTF-8?q?=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97=E5=8F=96=E5=BE=97=E3=81=AE?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E3=82=AF=E3=83=A9=E3=82=B9=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../slack-operations/usergroup-operations.ts | 39 ++++ .../usergroup-operations.test.ts | 197 ++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 src/utils/slack-operations/usergroup-operations.ts create mode 100644 tests/utils/slack-operations/usergroup-operations.test.ts diff --git a/src/utils/slack-operations/usergroup-operations.ts b/src/utils/slack-operations/usergroup-operations.ts new file mode 100644 index 0000000..ad1e864 --- /dev/null +++ b/src/utils/slack-operations/usergroup-operations.ts @@ -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 { + const response = await this.client.usergroups.list({ + include_count: true, + ...(includeDisabled ? { include_disabled: true } : {}), + }); + + return (response.usergroups || []) as SlackUsergroup[]; + } + + async listUsergroupUsers(usergroupId: string): Promise { + const response = await this.client.usergroups.users.list({ + usergroup: usergroupId, + }); + + return (response.users || []) as string[]; + } + + async resolveUsergroupIdByHandle(handle: string): Promise { + 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`); + } +} diff --git a/tests/utils/slack-operations/usergroup-operations.test.ts b/tests/utils/slack-operations/usergroup-operations.test.ts new file mode 100644 index 0000000..37997ea --- /dev/null +++ b/tests/utils/slack-operations/usergroup-operations.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UsergroupOperations } from '../../../src/utils/slack-operations/usergroup-operations'; + +vi.mock('@slack/web-api', () => ({ + WebClient: vi.fn().mockImplementation(function () { + return { + usergroups: { + list: vi.fn(), + users: { + list: vi.fn(), + }, + }, + }; + }), + LogLevel: { + ERROR: 'error', + }, +})); + +describe('UsergroupOperations', () => { + type MockClient = { + usergroups: { + list: ReturnType; + users: { + list: ReturnType; + }; + }; + }; + + let usergroupOps: UsergroupOperations; + let mockClient: MockClient; + + beforeEach(() => { + vi.clearAllMocks(); + usergroupOps = new UsergroupOperations('test-token'); + mockClient = (usergroupOps as unknown as { client: MockClient }).client; + }); + + describe('listUsergroups', () => { + it('should list usergroups with member counts', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [ + { + id: 'S123', + name: 'Engineering', + handle: 'engineers', + description: 'Engineering team', + user_count: 10, + date_delete: 0, + }, + { + id: 'S456', + name: 'Design', + handle: 'designers', + description: 'Design team', + user_count: 5, + date_delete: 0, + }, + ], + }); + + const result = await usergroupOps.listUsergroups(); + + expect(mockClient.usergroups.list).toHaveBeenCalledWith({ + include_count: true, + }); + expect(result).toHaveLength(2); + expect(result[0].id).toBe('S123'); + expect(result[0].handle).toBe('engineers'); + expect(result[1].id).toBe('S456'); + }); + + it('should include disabled usergroups when requested', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [], + }); + + await usergroupOps.listUsergroups(true); + + expect(mockClient.usergroups.list).toHaveBeenCalledWith({ + include_count: true, + include_disabled: true, + }); + }); + + it('should return empty array when no usergroups exist', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [], + }); + + const result = await usergroupOps.listUsergroups(); + + expect(result).toEqual([]); + }); + + it('should return empty array when usergroups field is missing', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + }); + + const result = await usergroupOps.listUsergroups(); + + expect(result).toEqual([]); + }); + + it('should throw when API call fails', async () => { + mockClient.usergroups.list.mockRejectedValue(new Error('missing_scope')); + + await expect(usergroupOps.listUsergroups()).rejects.toThrow('missing_scope'); + }); + }); + + describe('listUsergroupUsers', () => { + it('should list user IDs in a usergroup', async () => { + mockClient.usergroups.users.list.mockResolvedValue({ + ok: true, + users: ['U123', 'U456'], + }); + + const result = await usergroupOps.listUsergroupUsers('S123'); + + expect(mockClient.usergroups.users.list).toHaveBeenCalledWith({ + usergroup: 'S123', + }); + expect(result).toEqual(['U123', 'U456']); + }); + + it('should return empty array when usergroup has no users', async () => { + mockClient.usergroups.users.list.mockResolvedValue({ + ok: true, + users: [], + }); + + const result = await usergroupOps.listUsergroupUsers('S123'); + + expect(result).toEqual([]); + }); + + it('should throw when usergroup not found', async () => { + mockClient.usergroups.users.list.mockRejectedValue(new Error('no_such_subteam')); + + await expect(usergroupOps.listUsergroupUsers('SINVALID')).rejects.toThrow('no_such_subteam'); + }); + }); + + describe('resolveUsergroupIdByHandle', () => { + it('should resolve usergroup ID by handle', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [ + { id: 'S123', name: 'Engineering', handle: 'engineers' }, + { id: 'S456', name: 'Design', handle: 'designers' }, + ], + }); + + const result = await usergroupOps.resolveUsergroupIdByHandle('designers'); + + expect(result).toBe('S456'); + }); + + it('should resolve handle with @ prefix', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [{ id: 'S123', name: 'Engineering', handle: 'engineers' }], + }); + + const result = await usergroupOps.resolveUsergroupIdByHandle('@engineers'); + + expect(result).toBe('S123'); + }); + + it('should resolve handle case-insensitively', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [{ id: 'S123', name: 'Engineering', handle: 'Engineers' }], + }); + + const result = await usergroupOps.resolveUsergroupIdByHandle('engineers'); + + expect(result).toBe('S123'); + }); + + it('should throw when handle not found', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [{ id: 'S123', name: 'Engineering', handle: 'engineers' }], + }); + + await expect(usergroupOps.resolveUsergroupIdByHandle('unknown')).rejects.toThrow( + "Usergroup '@unknown' not found" + ); + }); + }); +}); From 936d281b513eae7d3f45f59b812c9307b69851bb Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:34:30 +0900 Subject: [PATCH 02/11] =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC?= =?UTF-8?q?=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97=E9=96=A2=E9=80=A3=E3=81=AE?= =?UTF-8?q?=E5=9E=8B=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/commands.ts | 13 +++++++++++++ src/types/slack.ts | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/types/commands.ts b/src/types/commands.ts index 077f595..8ddeadb 100644 --- a/src/types/commands.ts +++ b/src/types/commands.ts @@ -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'; diff --git a/src/types/slack.ts b/src/types/slack.ts index c6c0e50..78cf6d4 100644 --- a/src/types/slack.ts +++ b/src/types/slack.ts @@ -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; From 4bba44ae56bd2e35fca6d304edd83d043fdbfd5d Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:34:30 +0900 Subject: [PATCH 03/11] =?UTF-8?q?SlackApiClient=E3=81=AB=E3=83=A6=E3=83=BC?= =?UTF-8?q?=E3=82=B6=E3=83=BC=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97API?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/slack-client-service.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/utils/slack-client-service.ts b/src/utils/slack-client-service.ts index 8f09e76..48b9463 100644 --- a/src/utils/slack-client-service.ts +++ b/src/utils/slack-client-service.ts @@ -22,6 +22,7 @@ import type { SearchMessagesOptions, SearchResult, SlackUser, + SlackUsergroup, StarListResult, UserPresence, } from '../types/slack'; @@ -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; @@ -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; @@ -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); @@ -215,6 +219,18 @@ export class SlackApiClient { return this.userOps.resolveUserIdByName(username); } + async listUsergroups(includeDisabled?: boolean): Promise { + return this.usergroupOps.listUsergroups(includeDisabled); + } + + async listUsergroupMembers(usergroupId: string): Promise { + return this.usergroupOps.listUsergroupUsers(usergroupId); + } + + async resolveUsergroupIdByHandle(handle: string): Promise { + return this.usergroupOps.resolveUsergroupIdByHandle(handle); + } + async searchMessages(query: string, options?: SearchMessagesOptions): Promise { return this.searchOps.searchMessages(query, options); } From 4b10e931a355a54cec7fe65d417fa1c63c1f5829 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:34:30 +0900 Subject: [PATCH 04/11] =?UTF-8?q?usergroups=E3=82=B3=E3=83=9E=E3=83=B3?= =?UTF-8?q?=E3=83=89=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/usergroups.ts | 144 +++++++++++++ tests/commands/usergroups.test.ts | 336 ++++++++++++++++++++++++++++++ 2 files changed, 480 insertions(+) create mode 100644 src/commands/usergroups.ts create mode 100644 tests/commands/usergroups.test.ts diff --git a/src/commands/usergroups.ts b/src/commands/usergroups.ts new file mode 100644 index 0000000..4fa9302 --- /dev/null +++ b/src/commands/usergroups.ts @@ -0,0 +1,144 @@ +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 { sanitizeTerminalData, sanitizeTerminalText } from '../utils/terminal-sanitizer'; +import { createValidationHook, optionValidators } from '../utils/validators'; + +interface UsergroupMemberInfo { + id: string; + name?: string; + real_name?: string; +} + +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 || '')}` + ); + } +} + +function renderMemberTable(members: UsergroupMemberInfo[]) { + const rows = members.map((member) => ({ + id: sanitizeTerminalText(member.id), + name: sanitizeTerminalText(member.name || ''), + real_name: sanitizeTerminalText(member.real_name || ''), + })); + + console.table(sanitizeTerminalData(rows)); +} + +function renderMemberSimple(members: UsergroupMemberInfo[]) { + for (const member of members) { + console.log( + `${sanitizeTerminalText(member.id)}\t${sanitizeTerminalText( + member.name || '' + )}\t${sanitizeTerminalText(member.real_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 ', 'Output format: table, simple, json', 'table') + .option('--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 ', 'User group ID') + .option('--handle ', 'User group handle (e.g. @engineers)') + .option('--format ', 'Output format: table, simple, json', 'table') + .option('--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: UsergroupMemberInfo[] = await Promise.all( + memberIds.map(async (userId) => { + try { + const user = await client.getUserInfo(userId); + return { + id: userId, + name: user.name, + real_name: user.real_name, + }; + } catch { + return { id: userId }; + } + }) + ); + + renderByFormat(options, members, { + table: renderMemberTable, + simple: renderMemberSimple, + }); + }); + }) + ); + + usergroupsCommand.addCommand(listCommand); + usergroupsCommand.addCommand(membersCommand); + + return usergroupsCommand; +} diff --git a/tests/commands/usergroups.test.ts b/tests/commands/usergroups.test.ts new file mode 100644 index 0000000..04806a8 --- /dev/null +++ b/tests/commands/usergroups.test.ts @@ -0,0 +1,336 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setupUsergroupsCommand } from '../../src/commands/usergroups'; +import { ProfileConfigManager } from '../../src/utils/profile-config'; +import { SlackApiClient } from '../../src/utils/slack-api-client'; +import { createTestProgram, restoreMocks, setupMockConsole } from '../test-utils'; + +vi.mock('../../src/utils/slack-api-client'); +vi.mock('../../src/utils/profile-config'); + +describe('usergroups command', () => { + let program: ReturnType; + let mockSlackClient: SlackApiClient; + let mockConfigManager: ProfileConfigManager; + let mockConsole: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockConfigManager = new ProfileConfigManager(); + vi.mocked(ProfileConfigManager).mockImplementation(function () { + return mockConfigManager; + }); + + mockSlackClient = new SlackApiClient('test-token'); + vi.mocked(SlackApiClient).mockImplementation(function () { + return mockSlackClient; + }); + + mockConsole = setupMockConsole(); + program = createTestProgram(); + program.addCommand(setupUsergroupsCommand()); + }); + + afterEach(() => { + restoreMocks(); + }); + + describe('list subcommand', () => { + it('should list usergroups in table format', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue([ + { + id: 'S123', + name: 'Engineering', + handle: 'engineers', + description: 'Engineering team', + user_count: 10, + }, + ]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list']); + + expect(mockSlackClient.listUsergroups).toHaveBeenCalledWith(false); + expect(mockConsole.logSpy).toHaveBeenCalled(); + }); + + it('should include disabled usergroups with --include-disabled', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue([]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list', '--include-disabled']); + + expect(mockSlackClient.listUsergroups).toHaveBeenCalledWith(true); + }); + + it('should list usergroups in json format', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + const usergroups = [ + { + id: 'S123', + name: 'Engineering', + handle: 'engineers', + description: 'Engineering team', + user_count: 10, + }, + ]; + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue(usergroups); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list', '--format', 'json']); + + expect(mockConsole.logSpy).toHaveBeenCalledWith(JSON.stringify(usergroups, null, 2)); + }); + + it('should list usergroups in simple format', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue([ + { + id: 'S123', + name: 'Engineering', + handle: 'engineers', + description: 'Engineering team', + user_count: 10, + }, + ]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list', '--format', 'simple']); + + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('S123')); + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('engineers')); + }); + + it('should show message when no usergroups found', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue([]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list']); + + expect(mockConsole.logSpy).toHaveBeenCalledWith('No usergroups found'); + }); + + it('should use specified profile', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'work-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue([]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list', '--profile', 'work']); + + expect(mockConfigManager.getConfig).toHaveBeenCalledWith('work'); + expect(SlackApiClient).toHaveBeenCalledWith('work-token'); + }); + + it('should handle missing scope error', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockRejectedValue(new Error('missing_scope')); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list']); + + expect(mockConsole.errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + expect.any(String) + ); + expect(mockConsole.exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('members subcommand', () => { + it('should list usergroup members by id', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroupMembers).mockResolvedValue(['U123', 'U456']); + vi.mocked(mockSlackClient.getUserInfo).mockImplementation(async (userId: string) => ({ + id: userId, + name: userId === 'U123' ? 'alice' : 'bob', + real_name: userId === 'U123' ? 'Alice Smith' : 'Bob Jones', + })); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'members', '--id', 'S123']); + + expect(mockSlackClient.listUsergroupMembers).toHaveBeenCalledWith('S123'); + expect(mockSlackClient.getUserInfo).toHaveBeenCalledWith('U123'); + expect(mockSlackClient.getUserInfo).toHaveBeenCalledWith('U456'); + expect(mockConsole.logSpy).toHaveBeenCalled(); + }); + + it('should list usergroup members by handle', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.resolveUsergroupIdByHandle).mockResolvedValue('S123'); + vi.mocked(mockSlackClient.listUsergroupMembers).mockResolvedValue(['U123']); + vi.mocked(mockSlackClient.getUserInfo).mockResolvedValue({ + id: 'U123', + name: 'alice', + real_name: 'Alice Smith', + }); + + await program.parseAsync([ + 'node', + 'slack-cli', + 'usergroups', + 'members', + '--handle', + '@engineers', + ]); + + expect(mockSlackClient.resolveUsergroupIdByHandle).toHaveBeenCalledWith('@engineers'); + expect(mockSlackClient.listUsergroupMembers).toHaveBeenCalledWith('S123'); + }); + + it('should list members in simple format', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroupMembers).mockResolvedValue(['U123']); + vi.mocked(mockSlackClient.getUserInfo).mockResolvedValue({ + id: 'U123', + name: 'alice', + real_name: 'Alice Smith', + }); + + await program.parseAsync([ + 'node', + 'slack-cli', + 'usergroups', + 'members', + '--id', + 'S123', + '--format', + 'simple', + ]); + + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('U123')); + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('alice')); + }); + + it('should fall back to user ID when user info lookup fails', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroupMembers).mockResolvedValue(['U123']); + vi.mocked(mockSlackClient.getUserInfo).mockRejectedValue(new Error('user_not_found')); + + await program.parseAsync([ + 'node', + 'slack-cli', + 'usergroups', + 'members', + '--id', + 'S123', + '--format', + 'simple', + ]); + + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('U123')); + expect(mockConsole.exitSpy).not.toHaveBeenCalled(); + }); + + it('should show message when usergroup has no members', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroupMembers).mockResolvedValue([]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'members', '--id', 'S123']); + + expect(mockConsole.logSpy).toHaveBeenCalledWith('No members found'); + }); + + it('should error when neither --id nor --handle is specified', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'members']); + + expect(mockConsole.errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + expect.any(String) + ); + expect(mockConsole.exitSpy).toHaveBeenCalledWith(1); + }); + + it('should error when both --id and --handle are specified', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + + await program.parseAsync([ + 'node', + 'slack-cli', + 'usergroups', + 'members', + '--id', + 'S123', + '--handle', + 'engineers', + ]); + + expect(mockConsole.errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + expect.any(String) + ); + expect(mockConsole.exitSpy).toHaveBeenCalledWith(1); + }); + + it('should handle usergroup not found error', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroupMembers).mockRejectedValue( + new Error('no_such_subteam') + ); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'members', '--id', 'SINVALID']); + + expect(mockConsole.errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + expect.any(String) + ); + expect(mockConsole.exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('error handling', () => { + it('should handle missing configuration', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue(null); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list']); + + expect(mockConsole.errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + expect.any(String) + ); + expect(mockConsole.exitSpy).toHaveBeenCalledWith(1); + }); + }); +}); From 48e75bbcbd28ed931487d6833cd2d1747cbe55e2 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:34:30 +0900 Subject: [PATCH 05/11] =?UTF-8?q?usergroups=E3=82=B3=E3=83=9E=E3=83=B3?= =?UTF-8?q?=E3=83=89=E3=82=92=E7=99=BB=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index 3069d8e..7e5802a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; @@ -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()); From 9afcc76dac407703d2deb19ac05370d2a0cd4f46 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:34:30 +0900 Subject: [PATCH 06/11] v0.24.0 --- package-lock.json | 4 ++-- package.json | 19 +++++++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index a68a022..6b2c830 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mimo-3/slack-cli", - "version": "0.23.0", + "version": "0.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mimo-3/slack-cli", - "version": "0.23.0", + "version": "0.24.0", "license": "MIT", "dependencies": { "@slack/web-api": "7.15.0", diff --git a/package.json b/package.json index 62a2928..6e79a73 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,14 @@ { "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": { "slack-cli": "dist/index.js" }, - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "tsc", "dev": "ts-node src/index.ts", @@ -23,9 +25,18 @@ "prepare": "npm run build", "prepublishOnly": "npm run build" }, - "keywords": ["slack", "cli", "command-line", "messaging", "api"], + "keywords": [ + "slack", + "cli", + "command-line", + "messaging", + "api" + ], "author": "mimo-3 (fork of urugus/slack-cli)", - "contributors": ["urugus", "mimo-3"], + "contributors": [ + "urugus", + "mimo-3" + ], "repository": { "type": "git", "url": "git+https://github.com/mimo-3/slack-cli.git" From 0fc747b5ac91ac8cb96b1f2fa30f5429fc4c0bcd Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:35:17 +0900 Subject: [PATCH 07/11] =?UTF-8?q?README=E3=81=ABusergroups=E3=82=B3?= =?UTF-8?q?=E3=83=9E=E3=83=B3=E3=83=89=E3=81=AE=E8=AA=AC=E6=98=8E=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/README.md b/README.md index f2a933a..5ff6b22 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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` @@ -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 From d07654c941383b8256c299f2caf0f77cda609b69 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:36:10 +0900 Subject: [PATCH 08/11] =?UTF-8?q?package.json=E3=81=AE=E6=95=B4=E5=BD=A2?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 6e79a73..8f32fff 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,7 @@ "bin": { "slack-cli": "dist/index.js" }, - "files": [ - "dist" - ], + "files": ["dist"], "scripts": { "build": "tsc", "dev": "ts-node src/index.ts", @@ -25,18 +23,9 @@ "prepare": "npm run build", "prepublishOnly": "npm run build" }, - "keywords": [ - "slack", - "cli", - "command-line", - "messaging", - "api" - ], + "keywords": ["slack", "cli", "command-line", "messaging", "api"], "author": "mimo-3 (fork of urugus/slack-cli)", - "contributors": [ - "urugus", - "mimo-3" - ], + "contributors": ["urugus", "mimo-3"], "repository": { "type": "git", "url": "git+https://github.com/mimo-3/slack-cli.git" From 1dfda8cbd78ff896c5595f824db50487e2da4807 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:46:50 +0900 Subject: [PATCH 09/11] =?UTF-8?q?=E3=83=A1=E3=83=B3=E3=83=90=E3=83=BC?= =?UTF-8?q?=E5=8F=96=E5=BE=97=E3=81=AE=E3=83=A1=E3=82=BD=E3=83=83=E3=83=89?= =?UTF-8?q?=E5=90=8D=E3=82=92facade=E3=81=A8=E6=8F=83=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/slack-operations/usergroup-operations.ts | 2 +- .../slack-operations/usergroup-operations.test.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/utils/slack-operations/usergroup-operations.ts b/src/utils/slack-operations/usergroup-operations.ts index ad1e864..72e9fe6 100644 --- a/src/utils/slack-operations/usergroup-operations.ts +++ b/src/utils/slack-operations/usergroup-operations.ts @@ -16,7 +16,7 @@ export class UsergroupOperations extends BaseSlackClient { return (response.usergroups || []) as SlackUsergroup[]; } - async listUsergroupUsers(usergroupId: string): Promise { + async listUsergroupMembers(usergroupId: string): Promise { const response = await this.client.usergroups.users.list({ usergroup: usergroupId, }); diff --git a/tests/utils/slack-operations/usergroup-operations.test.ts b/tests/utils/slack-operations/usergroup-operations.test.ts index 37997ea..ae08b5e 100644 --- a/tests/utils/slack-operations/usergroup-operations.test.ts +++ b/tests/utils/slack-operations/usergroup-operations.test.ts @@ -113,14 +113,14 @@ describe('UsergroupOperations', () => { }); }); - describe('listUsergroupUsers', () => { + describe('listUsergroupMembers', () => { it('should list user IDs in a usergroup', async () => { mockClient.usergroups.users.list.mockResolvedValue({ ok: true, users: ['U123', 'U456'], }); - const result = await usergroupOps.listUsergroupUsers('S123'); + const result = await usergroupOps.listUsergroupMembers('S123'); expect(mockClient.usergroups.users.list).toHaveBeenCalledWith({ usergroup: 'S123', @@ -134,7 +134,7 @@ describe('UsergroupOperations', () => { users: [], }); - const result = await usergroupOps.listUsergroupUsers('S123'); + const result = await usergroupOps.listUsergroupMembers('S123'); expect(result).toEqual([]); }); @@ -142,7 +142,9 @@ describe('UsergroupOperations', () => { it('should throw when usergroup not found', async () => { mockClient.usergroups.users.list.mockRejectedValue(new Error('no_such_subteam')); - await expect(usergroupOps.listUsergroupUsers('SINVALID')).rejects.toThrow('no_such_subteam'); + await expect(usergroupOps.listUsergroupMembers('SINVALID')).rejects.toThrow( + 'no_such_subteam' + ); }); }); From ebc983964f2ab238794f3c7fcd388e15afb2bced Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:46:50 +0900 Subject: [PATCH 10/11] =?UTF-8?q?SlackApiClient=E5=81=B4=E3=81=AE=E5=91=BC?= =?UTF-8?q?=E3=81=B3=E5=87=BA=E3=81=97=E3=82=82=E8=BF=BD=E9=9A=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/slack-client-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/slack-client-service.ts b/src/utils/slack-client-service.ts index 48b9463..6a62dc1 100644 --- a/src/utils/slack-client-service.ts +++ b/src/utils/slack-client-service.ts @@ -224,7 +224,7 @@ export class SlackApiClient { } async listUsergroupMembers(usergroupId: string): Promise { - return this.usergroupOps.listUsergroupUsers(usergroupId); + return this.usergroupOps.listUsergroupMembers(usergroupId); } async resolveUsergroupIdByHandle(handle: string): Promise { From d82dba02e26b9cbcb9fe1c6a04c5d6ea0f6ddbfa Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:46:50 +0900 Subject: [PATCH 11/11] =?UTF-8?q?members=E3=81=AE=E8=A1=A8=E7=A4=BA?= =?UTF-8?q?=E3=82=92=E6=97=A2=E5=AD=98=E3=83=95=E3=82=A9=E3=83=BC=E3=83=9E?= =?UTF-8?q?=E3=83=83=E3=82=BF=E3=81=AB=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/usergroups.ts | 38 ++++++-------------------------------- 1 file changed, 6 insertions(+), 32 deletions(-) diff --git a/src/commands/usergroups.ts b/src/commands/usergroups.ts index 4fa9302..225ffe0 100644 --- a/src/commands/usergroups.ts +++ b/src/commands/usergroups.ts @@ -3,15 +3,11 @@ import { UsergroupsListOptions, UsergroupsMembersOptions } from '../types/comman 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'; -interface UsergroupMemberInfo { - id: string; - name?: string; - real_name?: string; -} - function renderUsergroupTable(usergroups: SlackUsergroup[]) { const rows = usergroups.map((usergroup) => ({ id: sanitizeTerminalText(usergroup.id || ''), @@ -34,26 +30,6 @@ function renderUsergroupSimple(usergroups: SlackUsergroup[]) { } } -function renderMemberTable(members: UsergroupMemberInfo[]) { - const rows = members.map((member) => ({ - id: sanitizeTerminalText(member.id), - name: sanitizeTerminalText(member.name || ''), - real_name: sanitizeTerminalText(member.real_name || ''), - })); - - console.table(sanitizeTerminalData(rows)); -} - -function renderMemberSimple(members: UsergroupMemberInfo[]) { - for (const member of members) { - console.log( - `${sanitizeTerminalText(member.id)}\t${sanitizeTerminalText( - member.name || '' - )}\t${sanitizeTerminalText(member.real_name || '')}` - ); - } -} - export function setupUsergroupsCommand(): Command { const usergroupsCommand = new Command('usergroups').description( 'List user groups and their members' @@ -114,14 +90,14 @@ export function setupUsergroupsCommand(): Command { return; } - const members: UsergroupMemberInfo[] = await Promise.all( + const members: MemberInfo[] = await Promise.all( memberIds.map(async (userId) => { try { const user = await client.getUserInfo(userId); return { id: userId, name: user.name, - real_name: user.real_name, + realName: user.real_name, }; } catch { return { id: userId }; @@ -129,10 +105,8 @@ export function setupUsergroupsCommand(): Command { }) ); - renderByFormat(options, members, { - table: renderMemberTable, - simple: renderMemberSimple, - }); + const format = parseFormat(options.format); + createMembersFormatter(format).format({ members }); }); }) );