From 3e5d77bed1ab202c3f5628bf215ab46df1683fe0 Mon Sep 17 00:00:00 2001 From: Mitsuki Ogasahara Date: Thu, 9 Apr 2026 23:17:34 +0900 Subject: [PATCH 01/11] docs: add SCIM API support design spec Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-04-09-scim-api-design.md | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-09-scim-api-design.md diff --git a/docs/superpowers/specs/2026-04-09-scim-api-design.md b/docs/superpowers/specs/2026-04-09-scim-api-design.md new file mode 100644 index 0000000..46a9221 --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-scim-api-design.md @@ -0,0 +1,213 @@ +# SCIM API Support Design + +## Goal + +Slack SCIM v2.0 API のフルカバーを CLI に追加する。主動機は `admin.*` API では不可能な org レベルのユーザー無効化。CLI の設計思想として SCIM API の全操作(Users CRUD + Groups CRUD)に対応する。 + +## Background + +- `admin.users.remove` はワークスペース単位の除外のみ。組織全体でのユーザー無効化は SCIM API でしかできない +- SCIM API は `@slack/web-api` の `WebClient` ではサポートされていない。REST エンドポイント(`https://api.slack.com/scim/v2/`)に直接リクエストする必要がある +- 認証は既存の `admin.*` API と同じ OAuth トークン(`admin` スコープ)を使い回す + +## Decisions + +| 判断項目 | 決定 | 理由 | +|---------|------|------| +| SCIM バージョン | v2.0 のみ | v1.1 のスーパーセット。機能欠落なし。公式も v2 推奨 | +| コマンド名前空間 | `scim-users`, `scim-groups` | トップレベルにフラットに配置。2グループのみなので `scim` 親コマンドは不要 | +| HTTP クライアント | リソース指向 `ScimClient` クラス | `WebClient` の `client.admin.users.list()` パターンに近い使い心地。型安全 | +| PUT(replace)| 省略 | 事故リスクが高い(省略フィールドが空になる)。PATCH で十分 | +| DELETE /Users の命名 | `deactivate` | 実際の動作(無効化)に忠実。完全削除ではないことを明示 | +| 認証 | 既存トークン使い回し | SCIM も同じ OAuth トークンで動作する | +| 外部依存 | なし | Bun ネイティブの `fetch` のみ使用 | + +## Architecture + +### ScimClient (`src/scim-client.ts`) + +`fetch` ベースのリソース指向 HTTP クライアント。 + +```typescript +class ScimClient { + constructor(token: string) + + users: { + list(params?: { startIndex?: number; count?: number; filter?: string }): Promise> + get(id: string): Promise + create(params: CreateScimUserParams): Promise + update(id: string, operations: ScimPatchOperation[]): Promise + deactivate(id: string): Promise + } + + groups: { + list(params?: { startIndex?: number; count?: number; filter?: string }): Promise> + get(id: string): Promise + create(params: CreateScimGroupParams): Promise + update(id: string, operations: ScimPatchOperation[]): Promise + delete(id: string): Promise + } +} +``` + +- `createScimClient(store, profileName?)` で生成(`createSlackClient` と同パターン) +- ベース URL: `https://api.slack.com/scim/v2` +- ヘッダー: `Authorization: Bearer `, `Content-Type: application/json` +- エラー時は SCIM v2 エラーレスポンス(`detail`, `status`)をパースして throw + +### 型定義 (`src/scim-types.ts`) + +```typescript +interface ScimUser { + id: string + userName: string + name: { givenName: string; familyName: string } + displayName: string + emails: Array<{ value: string; primary: boolean }> + active: boolean + title?: string + // ... +} + +interface ScimGroup { + id: string + displayName: string + members: Array<{ value: string; display?: string }> +} + +interface ScimListResponse { + totalResults: number + itemsPerPage: number + startIndex: number + Resources: T[] +} + +interface ScimPatchOperation { + op: "add" | "remove" | "replace" + path?: string + value?: unknown +} + +interface CreateScimUserParams { + userName: string + email: string + givenName?: string + familyName?: string + displayName?: string +} + +interface CreateScimGroupParams { + displayName: string + memberIds?: string[] +} +``` + +## Commands + +### scim-users + +| コマンド | SCIM エンドポイント | オプション | +|---------|-------------------|-----------| +| `list` | `GET /Users` | `--start-index`, `--count`, `--filter` | +| `get` | `GET /Users/{id}` | `--id`(必須) | +| `create` | `POST /Users` | `--user-name`(必須), `--email`(必須), `--given-name`, `--family-name`, `--display-name` | +| `update` | `PATCH /Users/{id}` | `--id`(必須), `--active`, `--user-name`, `--email`, `--given-name`, `--family-name`, `--display-name`, `--title` | +| `deactivate` | `DELETE /Users/{id}` | `--id`(必須) | + +`update` は指定されたフィールドを SCIM PATCH の `replace` オペレーションに変換して送る。 + +### scim-groups + +| コマンド | SCIM エンドポイント | オプション | +|---------|-------------------|-----------| +| `list` | `GET /Groups` | `--start-index`, `--count`, `--filter` | +| `get` | `GET /Groups/{id}` | `--id`(必須) | +| `create` | `POST /Groups` | `--display-name`(必須), `--member-ids`(カンマ区切り) | +| `update` | `PATCH /Groups/{id}` | `--id`(必須), `--display-name`, `--add-member-ids`, `--remove-member-ids` | +| `delete` | `DELETE /Groups/{id}` | `--id`(必須) | + +`update` の `--display-name` は `replace` オペレーション、`--add-member-ids` は `add`、`--remove-member-ids` は `remove` オペレーションに変換。 + +## File Structure + +### New Files + +``` +src/ +├── scim-client.ts # ScimClient クラス + createScimClient +├── scim-types.ts # SCIM 型定義 +└── commands/ + ├── scim-users/ + │ ├── list.ts + │ ├── get.ts + │ ├── create.ts + │ ├── update.ts + │ └── deactivate.ts + └── scim-groups/ + ├── list.ts + ├── get.ts + ├── create.ts + ├── update.ts + └── delete.ts + +tests/ +├── scim-client.test.ts +└── commands/ + ├── scim-users/ + │ ├── list.test.ts + │ ├── get.test.ts + │ ├── create.test.ts + │ ├── update.test.ts + │ └── deactivate.test.ts + └── scim-groups/ + ├── list.test.ts + ├── get.test.ts + ├── create.test.ts + ├── update.test.ts + └── delete.test.ts +``` + +### Modified Files + +- `src/index.ts` — パーサー定義 + rootParser 追加 + switch ケース追加 + +## Integration with index.ts + +### Parser + +```typescript +const scimUsersCommands = command("scim-users", or( + command("list", object({ cmd: constant("scim-users-list" as const), ... })), + command("get", object({ cmd: constant("scim-users-get" as const), ... })), + // ... +)); + +const scimGroupsCommands = command("scim-groups", or( + command("list", object({ cmd: constant("scim-groups-list" as const), ... })), + // ... +)); +``` + +### Routing + +```typescript +case "scim-users-list": { + const client = await createScimClient(store, profileFlag); + const result = await executeScimUsersList(client, { ... }); + console.log(formatOutput(result, [...], outputFormat)); + break; +} +``` + +`createScimClient` は `createSlackClient` と同じく `ProfileStore` からトークン取得。返すのは `ScimClient`。 + +## Testing + +コマンドテストでは `ScimClient` のメソッドをモック: + +```typescript +const mockList = mock(() => Promise.resolve({ totalResults: 1, Resources: [...] })); +const client = { users: { list: mockList } } as any; +``` + +`tests/scim-client.test.ts` では `fetch` をモックして ScimClient 自体のリクエスト組み立て・エラーハンドリングをテスト。 From fcb0a549c32f1e2c93e761cb807d13187cf80637 Mon Sep 17 00:00:00 2001 From: Mitsuki Ogasahara Date: Thu, 9 Apr 2026 23:27:46 +0900 Subject: [PATCH 02/11] docs: add SCIM API implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/superpowers/plans/2026-04-09-scim-api.md | 1737 +++++++++++++++++ 1 file changed, 1737 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-09-scim-api.md diff --git a/docs/superpowers/plans/2026-04-09-scim-api.md b/docs/superpowers/plans/2026-04-09-scim-api.md new file mode 100644 index 0000000..c43ed7f --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-scim-api.md @@ -0,0 +1,1737 @@ +# SCIM API Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Slack SCIM v2.0 API のフルカバー(Users 5コマンド + Groups 5コマンド)を CLI に追加する。 + +**Architecture:** `fetch` ベースのリソース指向 `ScimClient` クラスを新設し、既存の `WebClient` パターンと同様にコマンドに注入する。コマンドは `scim-users` / `scim-groups` としてトップレベルに配置。 + +**Tech Stack:** TypeScript / Bun / fetch (native) / @optique/core + +**Spec:** `docs/superpowers/specs/2026-04-09-scim-api-design.md` + +--- + +## File Structure + +### New Files + +| File | Responsibility | +|------|---------------| +| `src/scim-types.ts` | SCIM v2 の型定義(ScimUser, ScimGroup, ScimListResponse, ScimPatchOperation 等) | +| `src/scim-client.ts` | ScimClient クラス(fetch ベース)+ createScimClient ファクトリ | +| `src/commands/scim-users/list.ts` | `GET /Users` — ユーザー一覧 | +| `src/commands/scim-users/get.ts` | `GET /Users/{id}` — ユーザー取得 | +| `src/commands/scim-users/create.ts` | `POST /Users` — ユーザー作成 | +| `src/commands/scim-users/update.ts` | `PATCH /Users/{id}` — ユーザー部分更新 | +| `src/commands/scim-users/deactivate.ts` | `DELETE /Users/{id}` — ユーザー無効化 | +| `src/commands/scim-groups/list.ts` | `GET /Groups` — グループ一覧 | +| `src/commands/scim-groups/get.ts` | `GET /Groups/{id}` — グループ取得 | +| `src/commands/scim-groups/create.ts` | `POST /Groups` — グループ作成 | +| `src/commands/scim-groups/update.ts` | `PATCH /Groups/{id}` — グループ部分更新 | +| `src/commands/scim-groups/delete.ts` | `DELETE /Groups/{id}` — グループ削除 | +| `tests/scim-client.test.ts` | ScimClient のリクエスト組み立て・エラーハンドリングテスト | +| `tests/commands/scim-users/list.test.ts` | scim-users list テスト | +| `tests/commands/scim-users/get.test.ts` | scim-users get テスト | +| `tests/commands/scim-users/create.test.ts` | scim-users create テスト | +| `tests/commands/scim-users/update.test.ts` | scim-users update テスト | +| `tests/commands/scim-users/deactivate.test.ts` | scim-users deactivate テスト | +| `tests/commands/scim-groups/list.test.ts` | scim-groups list テスト | +| `tests/commands/scim-groups/get.test.ts` | scim-groups get テスト | +| `tests/commands/scim-groups/create.test.ts` | scim-groups create テスト | +| `tests/commands/scim-groups/update.test.ts` | scim-groups update テスト | +| `tests/commands/scim-groups/delete.test.ts` | scim-groups delete テスト | +| `skills/slack-admin-cli-skill/recipes/scim-users.md` | scim-users スキルレシピ | +| `skills/slack-admin-cli-skill/recipes/scim-groups.md` | scim-groups スキルレシピ | + +### Modified Files + +| File | Changes | +|------|---------| +| `src/index.ts` | import 追加、scimUsersCommands / scimGroupsCommands パーサー定義、rootParser 追加、switch ケース追加 | +| `README.md` | SCIM コマンド一覧追加、Required Scopes に `admin` スコープ追記 | +| `README_ja.md` | 同上(日本語) | +| `skills/slack-admin-cli-skill/SKILL.md` | コマンドグループ表に scim-users / scim-groups 追加 | + +--- + +### Task 1: SCIM 型定義 + ScimClient + +**Files:** +- Create: `src/scim-types.ts` +- Create: `src/scim-client.ts` +- Create: `tests/scim-client.test.ts` + +- [ ] **Step 1: Create SCIM type definitions** + +Create `src/scim-types.ts`: + +```typescript +export interface ScimUser { + id: string; + userName: string; + name: { + givenName: string; + familyName: string; + }; + displayName?: string; + emails: Array<{ + value: string; + primary: boolean; + type?: string; + }>; + active: boolean; + title?: string; + nickName?: string; + timezone?: string; + photos?: Array<{ + value: string; + type?: string; + primary?: boolean; + }>; + groups?: Array<{ + value: string; + display?: string; + }>; +} + +export interface ScimGroup { + id: string; + displayName: string; + members: Array<{ + value: string; + display?: string; + }>; +} + +export interface ScimListResponse { + totalResults: number; + itemsPerPage: number; + startIndex: number; + Resources: T[]; +} + +export interface ScimPatchOperation { + op: "add" | "remove" | "replace"; + path?: string; + value?: unknown; +} + +export interface CreateScimUserParams { + userName: string; + email: string; + givenName?: string; + familyName?: string; + displayName?: string; +} + +export interface CreateScimGroupParams { + displayName: string; + memberIds?: string[]; +} +``` + +- [ ] **Step 2: Write ScimClient tests** + +Create `tests/scim-client.test.ts`: + +```typescript +import { describe, expect, test, mock, afterEach } from "bun:test"; +import { ScimClient } from "../src/scim-client"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function mockFetchResponse(body: unknown, status = 200) { + const mockFn = mock(() => + Promise.resolve({ + ok: status >= 200 && status < 300, + status, + statusText: "OK", + json: () => Promise.resolve(body), + }), + ); + globalThis.fetch = mockFn as any; + return mockFn; +} + +function mockFetchNoContent() { + const mockFn = mock(() => + Promise.resolve({ + ok: true, + status: 204, + statusText: "No Content", + json: () => Promise.reject(new Error("No content")), + }), + ); + globalThis.fetch = mockFn as any; + return mockFn; +} + +describe("ScimClient", () => { + describe("users.list", () => { + test("sends GET /Users with auth header", async () => { + const mockFn = mockFetchResponse({ totalResults: 0, itemsPerPage: 100, startIndex: 1, Resources: [] }); + const client = new ScimClient("xoxp-test"); + + await client.users.list(); + + expect(mockFn).toHaveBeenCalledTimes(1); + const [url, options] = mockFn.mock.calls[0] as [string, RequestInit & { headers: Record }]; + expect(url).toBe("https://api.slack.com/scim/v2/Users"); + expect(options.method).toBe("GET"); + expect(options.headers.Authorization).toBe("Bearer xoxp-test"); + }); + + test("includes query params when provided", async () => { + mockFetchResponse({ totalResults: 0, itemsPerPage: 10, startIndex: 5, Resources: [] }); + const client = new ScimClient("xoxp-test"); + + await client.users.list({ startIndex: 5, count: 10, filter: "userName eq \"alice\"" }); + + const [url] = (globalThis.fetch as any).mock.calls[0] as [string]; + const parsed = new URL(url); + expect(parsed.searchParams.get("startIndex")).toBe("5"); + expect(parsed.searchParams.get("count")).toBe("10"); + expect(parsed.searchParams.get("filter")).toBe("userName eq \"alice\""); + }); + }); + + describe("users.get", () => { + test("sends GET /Users/{id}", async () => { + const user = { id: "U001", userName: "alice", active: true, emails: [], name: { givenName: "Alice", familyName: "Smith" } }; + const mockFn = mockFetchResponse(user); + const client = new ScimClient("xoxp-test"); + + const result = await client.users.get("U001"); + + expect(result.id).toBe("U001"); + const [url] = mockFn.mock.calls[0] as [string]; + expect(url).toBe("https://api.slack.com/scim/v2/Users/U001"); + }); + }); + + describe("users.create", () => { + test("sends POST /Users with user data", async () => { + const created = { id: "U999", userName: "newuser", active: true, emails: [{ value: "new@ex.com", primary: true }], name: { givenName: "", familyName: "" } }; + const mockFn = mockFetchResponse(created); + const client = new ScimClient("xoxp-test"); + + const result = await client.users.create({ userName: "newuser", email: "new@ex.com", givenName: "New", familyName: "User" }); + + expect(result.id).toBe("U999"); + const [url, options] = mockFn.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.slack.com/scim/v2/Users"); + expect(options.method).toBe("POST"); + const body = JSON.parse(options.body as string); + expect(body.userName).toBe("newuser"); + expect(body.emails).toEqual([{ value: "new@ex.com", primary: true }]); + expect(body.name).toEqual({ givenName: "New", familyName: "User" }); + }); + }); + + describe("users.update", () => { + test("sends PATCH /Users/{id} with operations", async () => { + const updated = { id: "U001", userName: "alice", active: false, emails: [], name: { givenName: "Alice", familyName: "Smith" } }; + const mockFn = mockFetchResponse(updated); + const client = new ScimClient("xoxp-test"); + + await client.users.update("U001", [{ op: "replace", path: "active", value: false }]); + + const [url, options] = mockFn.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.slack.com/scim/v2/Users/U001"); + expect(options.method).toBe("PATCH"); + const body = JSON.parse(options.body as string); + expect(body.schemas).toEqual(["urn:ietf:params:scim:api:messages:2.0:PatchOp"]); + expect(body.Operations).toEqual([{ op: "replace", path: "active", value: false }]); + }); + }); + + describe("users.deactivate", () => { + test("sends DELETE /Users/{id}", async () => { + const mockFn = mockFetchNoContent(); + const client = new ScimClient("xoxp-test"); + + await client.users.deactivate("U001"); + + const [url, options] = mockFn.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.slack.com/scim/v2/Users/U001"); + expect(options.method).toBe("DELETE"); + }); + }); + + describe("groups.list", () => { + test("sends GET /Groups with auth header", async () => { + const mockFn = mockFetchResponse({ totalResults: 0, itemsPerPage: 100, startIndex: 1, Resources: [] }); + const client = new ScimClient("xoxp-test"); + + await client.groups.list(); + + const [url, options] = mockFn.mock.calls[0] as [string, RequestInit & { headers: Record }]; + expect(url).toBe("https://api.slack.com/scim/v2/Groups"); + expect(options.method).toBe("GET"); + }); + }); + + describe("groups.create", () => { + test("sends POST /Groups with group data", async () => { + const created = { id: "G001", displayName: "Engineering", members: [{ value: "U001" }] }; + const mockFn = mockFetchResponse(created); + const client = new ScimClient("xoxp-test"); + + const result = await client.groups.create({ displayName: "Engineering", memberIds: ["U001"] }); + + expect(result.id).toBe("G001"); + const [, options] = mockFn.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(options.body as string); + expect(body.displayName).toBe("Engineering"); + expect(body.members).toEqual([{ value: "U001" }]); + }); + }); + + describe("groups.delete", () => { + test("sends DELETE /Groups/{id}", async () => { + const mockFn = mockFetchNoContent(); + const client = new ScimClient("xoxp-test"); + + await client.groups.delete("G001"); + + const [url, options] = mockFn.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.slack.com/scim/v2/Groups/G001"); + expect(options.method).toBe("DELETE"); + }); + }); + + describe("error handling", () => { + test("throws on non-ok response with SCIM error detail", async () => { + mockFetchResponse({ Errors: { description: "User not found", code: 404 } }, 404); + const client = new ScimClient("xoxp-test"); + + await expect(client.users.get("U999")).rejects.toThrow("SCIM API error (404)"); + }); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `bun test tests/scim-client.test.ts` +Expected: FAIL — `ScimClient` does not exist yet. + +- [ ] **Step 4: Implement ScimClient** + +Create `src/scim-client.ts`: + +```typescript +import type { ProfileStore } from "./config"; +import type { + ScimUser, + ScimGroup, + ScimListResponse, + ScimPatchOperation, + CreateScimUserParams, + CreateScimGroupParams, +} from "./scim-types"; + +const SCIM_BASE_URL = "https://api.slack.com/scim/v2"; + +export class ScimClient { + private token: string; + + constructor(token: string) { + this.token = token; + } + + private async request( + method: string, + path: string, + body?: unknown, + params?: Record, + ): Promise { + const url = new URL(`${SCIM_BASE_URL}${path}`); + if (params) { + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + } + + const headers: Record = { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + }; + + const response = await fetch(url.toString(), { + method, + headers, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => null); + const detail = errorBody?.Errors?.description ?? errorBody?.detail ?? response.statusText; + throw new Error(`SCIM API error (${response.status}): ${detail}`); + } + + if (response.status === 204) { + return undefined as never; + } + + return response.json(); + } + + users = { + list: (params?: { + startIndex?: number; + count?: number; + filter?: string; + }): Promise> => { + const queryParams: Record = {}; + if (params?.startIndex !== undefined) queryParams.startIndex = String(params.startIndex); + if (params?.count !== undefined) queryParams.count = String(params.count); + if (params?.filter !== undefined) queryParams.filter = params.filter; + return this.request("GET", "/Users", undefined, queryParams); + }, + + get: (id: string): Promise => { + return this.request("GET", `/Users/${id}`); + }, + + create: (params: CreateScimUserParams): Promise => { + const body: Record = { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], + userName: params.userName, + emails: [{ value: params.email, primary: true }], + }; + if (params.givenName !== undefined || params.familyName !== undefined) { + body.name = { + ...(params.givenName !== undefined ? { givenName: params.givenName } : {}), + ...(params.familyName !== undefined ? { familyName: params.familyName } : {}), + }; + } + if (params.displayName !== undefined) body.displayName = params.displayName; + return this.request("POST", "/Users", body); + }, + + update: (id: string, operations: ScimPatchOperation[]): Promise => { + return this.request("PATCH", `/Users/${id}`, { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: operations, + }); + }, + + deactivate: (id: string): Promise => { + return this.request("DELETE", `/Users/${id}`); + }, + }; + + groups = { + list: (params?: { + startIndex?: number; + count?: number; + filter?: string; + }): Promise> => { + const queryParams: Record = {}; + if (params?.startIndex !== undefined) queryParams.startIndex = String(params.startIndex); + if (params?.count !== undefined) queryParams.count = String(params.count); + if (params?.filter !== undefined) queryParams.filter = params.filter; + return this.request("GET", "/Groups", undefined, queryParams); + }, + + get: (id: string): Promise => { + return this.request("GET", `/Groups/${id}`); + }, + + create: (params: CreateScimGroupParams): Promise => { + const body: Record = { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], + displayName: params.displayName, + }; + if (params.memberIds !== undefined) { + body.members = params.memberIds.map((id) => ({ value: id })); + } + return this.request("POST", "/Groups", body); + }, + + update: (id: string, operations: ScimPatchOperation[]): Promise => { + return this.request("PATCH", `/Groups/${id}`, { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: operations, + }); + }, + + delete: (id: string): Promise => { + return this.request("DELETE", `/Groups/${id}`); + }, + }; +} + +export async function createScimClient( + store: ProfileStore, + profileName?: string, +): Promise { + const resolved = await store.resolveProfileName(profileName); + const token = await store.getToken(resolved); + return new ScimClient(token); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `bun test tests/scim-client.test.ts` +Expected: All tests PASS. + +- [ ] **Step 6: Run type check** + +Run: `bun run lint` +Expected: No type errors. + +- [ ] **Step 7: Commit** + +```bash +git add src/scim-types.ts src/scim-client.ts tests/scim-client.test.ts +git commit -m "feat: add SCIM types and ScimClient" +``` + +--- + +### Task 2: scim-users list & get コマンド + +**Files:** +- Create: `src/commands/scim-users/list.ts` +- Create: `src/commands/scim-users/get.ts` +- Create: `tests/commands/scim-users/list.test.ts` +- Create: `tests/commands/scim-users/get.test.ts` + +- [ ] **Step 1: Write scim-users list test** + +Create `tests/commands/scim-users/list.test.ts`: + +```typescript +import { describe, expect, test, mock } from "bun:test"; +import { executeScimUsersList } from "../../../src/commands/scim-users/list"; + +describe("scim-users list", () => { + test("returns users from SCIM API", async () => { + const mockList = mock(() => + Promise.resolve({ + totalResults: 2, + itemsPerPage: 100, + startIndex: 1, + Resources: [ + { id: "U001", userName: "alice", active: true, emails: [{ value: "alice@ex.com", primary: true }], name: { givenName: "Alice", familyName: "Smith" } }, + { id: "U002", userName: "bob", active: false, emails: [{ value: "bob@ex.com", primary: true }], name: { givenName: "Bob", familyName: "Jones" } }, + ], + }), + ); + const client = { users: { list: mockList } } as any; + + const result = await executeScimUsersList(client, {}); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe("U001"); + expect(result[1].userName).toBe("bob"); + }); + + test("passes pagination and filter params", async () => { + const mockList = mock(() => + Promise.resolve({ totalResults: 0, itemsPerPage: 10, startIndex: 5, Resources: [] }), + ); + const client = { users: { list: mockList } } as any; + + await executeScimUsersList(client, { startIndex: 5, count: 10, filter: "userName eq \"alice\"" }); + + expect(mockList).toHaveBeenCalledWith({ startIndex: 5, count: 10, filter: "userName eq \"alice\"" }); + }); + + test("returns empty array when no users", async () => { + const mockList = mock(() => + Promise.resolve({ totalResults: 0, itemsPerPage: 100, startIndex: 1, Resources: [] }), + ); + const client = { users: { list: mockList } } as any; + + const result = await executeScimUsersList(client, {}); + + expect(result).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Write scim-users get test** + +Create `tests/commands/scim-users/get.test.ts`: + +```typescript +import { describe, expect, test, mock } from "bun:test"; +import { executeScimUsersGet } from "../../../src/commands/scim-users/get"; + +describe("scim-users get", () => { + test("returns user by id", async () => { + const user = { + id: "U001", + userName: "alice", + active: true, + emails: [{ value: "alice@ex.com", primary: true }], + name: { givenName: "Alice", familyName: "Smith" }, + displayName: "Alice Smith", + }; + const mockGet = mock(() => Promise.resolve(user)); + const client = { users: { get: mockGet } } as any; + + const result = await executeScimUsersGet(client, { id: "U001" }); + + expect(result.id).toBe("U001"); + expect(result.userName).toBe("alice"); + expect(mockGet).toHaveBeenCalledWith("U001"); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `bun test tests/commands/scim-users/` +Expected: FAIL — execute functions don't exist. + +- [ ] **Step 4: Implement scim-users list** + +Create `src/commands/scim-users/list.ts`: + +```typescript +import type { ScimClient } from "../../scim-client"; +import type { ScimUser } from "../../scim-types"; + +interface ScimUsersListOptions { + startIndex?: number; + count?: number; + filter?: string; +} + +export async function executeScimUsersList( + client: ScimClient, + opts: ScimUsersListOptions, +): Promise { + const response = await client.users.list(opts); + return response.Resources; +} +``` + +- [ ] **Step 5: Implement scim-users get** + +Create `src/commands/scim-users/get.ts`: + +```typescript +import type { ScimClient } from "../../scim-client"; +import type { ScimUser } from "../../scim-types"; + +interface ScimUsersGetOptions { + id: string; +} + +export async function executeScimUsersGet( + client: ScimClient, + opts: ScimUsersGetOptions, +): Promise { + return client.users.get(opts.id); +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `bun test tests/commands/scim-users/` +Expected: All tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/commands/scim-users/list.ts src/commands/scim-users/get.ts tests/commands/scim-users/list.test.ts tests/commands/scim-users/get.test.ts +git commit -m "feat: add scim-users list and get commands" +``` + +--- + +### Task 3: scim-users create, update, deactivate コマンド + +**Files:** +- Create: `src/commands/scim-users/create.ts` +- Create: `src/commands/scim-users/update.ts` +- Create: `src/commands/scim-users/deactivate.ts` +- Create: `tests/commands/scim-users/create.test.ts` +- Create: `tests/commands/scim-users/update.test.ts` +- Create: `tests/commands/scim-users/deactivate.test.ts` + +- [ ] **Step 1: Write scim-users create test** + +Create `tests/commands/scim-users/create.test.ts`: + +```typescript +import { describe, expect, test, mock } from "bun:test"; +import { executeScimUsersCreate } from "../../../src/commands/scim-users/create"; + +describe("scim-users create", () => { + test("creates user with required params", async () => { + const created = { id: "U999", userName: "newuser", active: true, emails: [{ value: "new@ex.com", primary: true }], name: { givenName: "", familyName: "" } }; + const mockCreate = mock(() => Promise.resolve(created)); + const client = { users: { create: mockCreate } } as any; + + const result = await executeScimUsersCreate(client, { userName: "newuser", email: "new@ex.com" }); + + expect(result.id).toBe("U999"); + expect(mockCreate).toHaveBeenCalledWith({ userName: "newuser", email: "new@ex.com" }); + }); + + test("passes optional name fields", async () => { + const created = { id: "U999", userName: "newuser", active: true, emails: [{ value: "new@ex.com", primary: true }], name: { givenName: "New", familyName: "User" } }; + const mockCreate = mock(() => Promise.resolve(created)); + const client = { users: { create: mockCreate } } as any; + + await executeScimUsersCreate(client, { userName: "newuser", email: "new@ex.com", givenName: "New", familyName: "User", displayName: "New User" }); + + expect(mockCreate).toHaveBeenCalledWith({ userName: "newuser", email: "new@ex.com", givenName: "New", familyName: "User", displayName: "New User" }); + }); +}); +``` + +- [ ] **Step 2: Write scim-users update test** + +Create `tests/commands/scim-users/update.test.ts`: + +```typescript +import { describe, expect, test, mock } from "bun:test"; +import { executeScimUsersUpdate } from "../../../src/commands/scim-users/update"; + +describe("scim-users update", () => { + test("builds replace operations for provided fields", async () => { + const updated = { id: "U001", userName: "alice", active: false, emails: [], name: { givenName: "Alice", familyName: "Smith" } }; + const mockUpdate = mock(() => Promise.resolve(updated)); + const client = { users: { update: mockUpdate } } as any; + + await executeScimUsersUpdate(client, { id: "U001", active: false, title: "Engineer" }); + + expect(mockUpdate).toHaveBeenCalledWith("U001", [ + { op: "replace", path: "active", value: false }, + { op: "replace", path: "title", value: "Engineer" }, + ]); + }); + + test("handles single field update", async () => { + const updated = { id: "U001", userName: "alice-new", active: true, emails: [], name: { givenName: "Alice", familyName: "Smith" } }; + const mockUpdate = mock(() => Promise.resolve(updated)); + const client = { users: { update: mockUpdate } } as any; + + const result = await executeScimUsersUpdate(client, { id: "U001", userName: "alice-new" }); + + expect(result.userName).toBe("alice-new"); + expect(mockUpdate).toHaveBeenCalledWith("U001", [ + { op: "replace", path: "userName", value: "alice-new" }, + ]); + }); + + test("throws when no fields provided", async () => { + const client = { users: { update: mock(() => Promise.resolve({})) } } as any; + + await expect(executeScimUsersUpdate(client, { id: "U001" })).rejects.toThrow( + "At least one field to update must be specified", + ); + }); +}); +``` + +- [ ] **Step 3: Write scim-users deactivate test** + +Create `tests/commands/scim-users/deactivate.test.ts`: + +```typescript +import { describe, expect, test, mock } from "bun:test"; +import { executeScimUsersDeactivate } from "../../../src/commands/scim-users/deactivate"; + +describe("scim-users deactivate", () => { + test("calls deactivate with user id", async () => { + const mockDeactivate = mock(() => Promise.resolve(undefined)); + const client = { users: { deactivate: mockDeactivate } } as any; + + await executeScimUsersDeactivate(client, { id: "U001" }); + + expect(mockDeactivate).toHaveBeenCalledWith("U001"); + }); +}); +``` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: `bun test tests/commands/scim-users/` +Expected: FAIL — create, update, deactivate functions don't exist. + +- [ ] **Step 5: Implement scim-users create** + +Create `src/commands/scim-users/create.ts`: + +```typescript +import type { ScimClient } from "../../scim-client"; +import type { ScimUser } from "../../scim-types"; + +interface ScimUsersCreateOptions { + userName: string; + email: string; + givenName?: string; + familyName?: string; + displayName?: string; +} + +export async function executeScimUsersCreate( + client: ScimClient, + opts: ScimUsersCreateOptions, +): Promise { + return client.users.create(opts); +} +``` + +- [ ] **Step 6: Implement scim-users update** + +Create `src/commands/scim-users/update.ts`: + +```typescript +import type { ScimClient } from "../../scim-client"; +import type { ScimPatchOperation, ScimUser } from "../../scim-types"; + +interface ScimUsersUpdateOptions { + id: string; + active?: boolean; + userName?: string; + email?: string; + givenName?: string; + familyName?: string; + displayName?: string; + title?: string; +} + +export async function executeScimUsersUpdate( + client: ScimClient, + opts: ScimUsersUpdateOptions, +): Promise { + const operations: ScimPatchOperation[] = []; + + if (opts.active !== undefined) operations.push({ op: "replace", path: "active", value: opts.active }); + if (opts.userName !== undefined) operations.push({ op: "replace", path: "userName", value: opts.userName }); + if (opts.email !== undefined) operations.push({ op: "replace", path: "emails", value: [{ value: opts.email, primary: true }] }); + if (opts.givenName !== undefined) operations.push({ op: "replace", path: "name.givenName", value: opts.givenName }); + if (opts.familyName !== undefined) operations.push({ op: "replace", path: "name.familyName", value: opts.familyName }); + if (opts.displayName !== undefined) operations.push({ op: "replace", path: "displayName", value: opts.displayName }); + if (opts.title !== undefined) operations.push({ op: "replace", path: "title", value: opts.title }); + + if (operations.length === 0) { + throw new Error("At least one field to update must be specified"); + } + + return client.users.update(opts.id, operations); +} +``` + +- [ ] **Step 7: Implement scim-users deactivate** + +Create `src/commands/scim-users/deactivate.ts`: + +```typescript +import type { ScimClient } from "../../scim-client"; + +interface ScimUsersDeactivateOptions { + id: string; +} + +export async function executeScimUsersDeactivate( + client: ScimClient, + opts: ScimUsersDeactivateOptions, +): Promise { + await client.users.deactivate(opts.id); +} +``` + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `bun test tests/commands/scim-users/` +Expected: All tests PASS. + +- [ ] **Step 9: Commit** + +```bash +git add src/commands/scim-users/create.ts src/commands/scim-users/update.ts src/commands/scim-users/deactivate.ts tests/commands/scim-users/create.test.ts tests/commands/scim-users/update.test.ts tests/commands/scim-users/deactivate.test.ts +git commit -m "feat: add scim-users create, update, deactivate commands" +``` + +--- + +### Task 4: scim-groups list & get コマンド + +**Files:** +- Create: `src/commands/scim-groups/list.ts` +- Create: `src/commands/scim-groups/get.ts` +- Create: `tests/commands/scim-groups/list.test.ts` +- Create: `tests/commands/scim-groups/get.test.ts` + +- [ ] **Step 1: Write scim-groups list test** + +Create `tests/commands/scim-groups/list.test.ts`: + +```typescript +import { describe, expect, test, mock } from "bun:test"; +import { executeScimGroupsList } from "../../../src/commands/scim-groups/list"; + +describe("scim-groups list", () => { + test("returns groups from SCIM API", async () => { + const mockList = mock(() => + Promise.resolve({ + totalResults: 2, + itemsPerPage: 100, + startIndex: 1, + Resources: [ + { id: "G001", displayName: "Engineering", members: [{ value: "U001" }, { value: "U002" }] }, + { id: "G002", displayName: "Design", members: [{ value: "U003" }] }, + ], + }), + ); + const client = { groups: { list: mockList } } as any; + + const result = await executeScimGroupsList(client, {}); + + expect(result).toHaveLength(2); + expect(result[0].displayName).toBe("Engineering"); + expect(result[1].id).toBe("G002"); + }); + + test("passes pagination and filter params", async () => { + const mockList = mock(() => + Promise.resolve({ totalResults: 0, itemsPerPage: 10, startIndex: 1, Resources: [] }), + ); + const client = { groups: { list: mockList } } as any; + + await executeScimGroupsList(client, { startIndex: 1, count: 10, filter: "displayName eq \"Eng\"" }); + + expect(mockList).toHaveBeenCalledWith({ startIndex: 1, count: 10, filter: "displayName eq \"Eng\"" }); + }); +}); +``` + +- [ ] **Step 2: Write scim-groups get test** + +Create `tests/commands/scim-groups/get.test.ts`: + +```typescript +import { describe, expect, test, mock } from "bun:test"; +import { executeScimGroupsGet } from "../../../src/commands/scim-groups/get"; + +describe("scim-groups get", () => { + test("returns group by id", async () => { + const group = { id: "G001", displayName: "Engineering", members: [{ value: "U001", display: "alice" }] }; + const mockGet = mock(() => Promise.resolve(group)); + const client = { groups: { get: mockGet } } as any; + + const result = await executeScimGroupsGet(client, { id: "G001" }); + + expect(result.id).toBe("G001"); + expect(result.displayName).toBe("Engineering"); + expect(mockGet).toHaveBeenCalledWith("G001"); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `bun test tests/commands/scim-groups/` +Expected: FAIL — execute functions don't exist. + +- [ ] **Step 4: Implement scim-groups list** + +Create `src/commands/scim-groups/list.ts`: + +```typescript +import type { ScimClient } from "../../scim-client"; +import type { ScimGroup } from "../../scim-types"; + +interface ScimGroupsListOptions { + startIndex?: number; + count?: number; + filter?: string; +} + +export async function executeScimGroupsList( + client: ScimClient, + opts: ScimGroupsListOptions, +): Promise { + const response = await client.groups.list(opts); + return response.Resources; +} +``` + +- [ ] **Step 5: Implement scim-groups get** + +Create `src/commands/scim-groups/get.ts`: + +```typescript +import type { ScimClient } from "../../scim-client"; +import type { ScimGroup } from "../../scim-types"; + +interface ScimGroupsGetOptions { + id: string; +} + +export async function executeScimGroupsGet( + client: ScimClient, + opts: ScimGroupsGetOptions, +): Promise { + return client.groups.get(opts.id); +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `bun test tests/commands/scim-groups/` +Expected: All tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/commands/scim-groups/list.ts src/commands/scim-groups/get.ts tests/commands/scim-groups/list.test.ts tests/commands/scim-groups/get.test.ts +git commit -m "feat: add scim-groups list and get commands" +``` + +--- + +### Task 5: scim-groups create, update, delete コマンド + +**Files:** +- Create: `src/commands/scim-groups/create.ts` +- Create: `src/commands/scim-groups/update.ts` +- Create: `src/commands/scim-groups/delete.ts` +- Create: `tests/commands/scim-groups/create.test.ts` +- Create: `tests/commands/scim-groups/update.test.ts` +- Create: `tests/commands/scim-groups/delete.test.ts` + +- [ ] **Step 1: Write scim-groups create test** + +Create `tests/commands/scim-groups/create.test.ts`: + +```typescript +import { describe, expect, test, mock } from "bun:test"; +import { executeScimGroupsCreate } from "../../../src/commands/scim-groups/create"; + +describe("scim-groups create", () => { + test("creates group with display name only", async () => { + const created = { id: "G999", displayName: "New Team", members: [] }; + const mockCreate = mock(() => Promise.resolve(created)); + const client = { groups: { create: mockCreate } } as any; + + const result = await executeScimGroupsCreate(client, { displayName: "New Team" }); + + expect(result.id).toBe("G999"); + expect(mockCreate).toHaveBeenCalledWith({ displayName: "New Team" }); + }); + + test("creates group with members", async () => { + const created = { id: "G999", displayName: "New Team", members: [{ value: "U001" }, { value: "U002" }] }; + const mockCreate = mock(() => Promise.resolve(created)); + const client = { groups: { create: mockCreate } } as any; + + await executeScimGroupsCreate(client, { displayName: "New Team", memberIds: ["U001", "U002"] }); + + expect(mockCreate).toHaveBeenCalledWith({ displayName: "New Team", memberIds: ["U001", "U002"] }); + }); +}); +``` + +- [ ] **Step 2: Write scim-groups update test** + +Create `tests/commands/scim-groups/update.test.ts`: + +```typescript +import { describe, expect, test, mock } from "bun:test"; +import { executeScimGroupsUpdate } from "../../../src/commands/scim-groups/update"; + +describe("scim-groups update", () => { + test("builds replace operation for displayName", async () => { + const updated = { id: "G001", displayName: "New Name", members: [] }; + const mockUpdate = mock(() => Promise.resolve(updated)); + const client = { groups: { update: mockUpdate } } as any; + + await executeScimGroupsUpdate(client, { id: "G001", displayName: "New Name" }); + + expect(mockUpdate).toHaveBeenCalledWith("G001", [ + { op: "replace", path: "displayName", value: "New Name" }, + ]); + }); + + test("builds add and remove operations for members", async () => { + const updated = { id: "G001", displayName: "Eng", members: [{ value: "U002" }, { value: "U003" }] }; + const mockUpdate = mock(() => Promise.resolve(updated)); + const client = { groups: { update: mockUpdate } } as any; + + await executeScimGroupsUpdate(client, { id: "G001", addMemberIds: ["U003"], removeMemberIds: ["U001"] }); + + expect(mockUpdate).toHaveBeenCalledWith("G001", [ + { op: "add", path: "members", value: [{ value: "U003" }] }, + { op: "remove", path: "members[value eq \"U001\"]" }, + ]); + }); + + test("throws when no fields provided", async () => { + const client = { groups: { update: mock(() => Promise.resolve({})) } } as any; + + await expect(executeScimGroupsUpdate(client, { id: "G001" })).rejects.toThrow( + "At least one field to update must be specified", + ); + }); +}); +``` + +- [ ] **Step 3: Write scim-groups delete test** + +Create `tests/commands/scim-groups/delete.test.ts`: + +```typescript +import { describe, expect, test, mock } from "bun:test"; +import { executeScimGroupsDelete } from "../../../src/commands/scim-groups/delete"; + +describe("scim-groups delete", () => { + test("calls delete with group id", async () => { + const mockDelete = mock(() => Promise.resolve(undefined)); + const client = { groups: { delete: mockDelete } } as any; + + await executeScimGroupsDelete(client, { id: "G001" }); + + expect(mockDelete).toHaveBeenCalledWith("G001"); + }); +}); +``` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: `bun test tests/commands/scim-groups/` +Expected: FAIL — create, update, delete functions don't exist. + +- [ ] **Step 5: Implement scim-groups create** + +Create `src/commands/scim-groups/create.ts`: + +```typescript +import type { ScimClient } from "../../scim-client"; +import type { ScimGroup } from "../../scim-types"; + +interface ScimGroupsCreateOptions { + displayName: string; + memberIds?: string[]; +} + +export async function executeScimGroupsCreate( + client: ScimClient, + opts: ScimGroupsCreateOptions, +): Promise { + return client.groups.create(opts); +} +``` + +- [ ] **Step 6: Implement scim-groups update** + +Create `src/commands/scim-groups/update.ts`: + +```typescript +import type { ScimClient } from "../../scim-client"; +import type { ScimPatchOperation, ScimGroup } from "../../scim-types"; + +interface ScimGroupsUpdateOptions { + id: string; + displayName?: string; + addMemberIds?: string[]; + removeMemberIds?: string[]; +} + +export async function executeScimGroupsUpdate( + client: ScimClient, + opts: ScimGroupsUpdateOptions, +): Promise { + const operations: ScimPatchOperation[] = []; + + if (opts.displayName !== undefined) { + operations.push({ op: "replace", path: "displayName", value: opts.displayName }); + } + if (opts.addMemberIds !== undefined) { + for (const memberId of opts.addMemberIds) { + operations.push({ op: "add", path: "members", value: [{ value: memberId }] }); + } + } + if (opts.removeMemberIds !== undefined) { + for (const memberId of opts.removeMemberIds) { + operations.push({ op: "remove", path: `members[value eq "${memberId}"]` }); + } + } + + if (operations.length === 0) { + throw new Error("At least one field to update must be specified"); + } + + return client.groups.update(opts.id, operations); +} +``` + +- [ ] **Step 7: Implement scim-groups delete** + +Create `src/commands/scim-groups/delete.ts`: + +```typescript +import type { ScimClient } from "../../scim-client"; + +interface ScimGroupsDeleteOptions { + id: string; +} + +export async function executeScimGroupsDelete( + client: ScimClient, + opts: ScimGroupsDeleteOptions, +): Promise { + await client.groups.delete(opts.id); +} +``` + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `bun test tests/commands/scim-groups/` +Expected: All tests PASS. + +- [ ] **Step 9: Commit** + +```bash +git add src/commands/scim-groups/create.ts src/commands/scim-groups/update.ts src/commands/scim-groups/delete.ts tests/commands/scim-groups/create.test.ts tests/commands/scim-groups/update.test.ts tests/commands/scim-groups/delete.test.ts +git commit -m "feat: add scim-groups create, update, delete commands" +``` + +--- + +### Task 6: index.ts に SCIM コマンドを統合 + +**Files:** +- Modify: `src/index.ts` + +この task は index.ts に以下を追加する: +1. import 文(10個の execute 関数 + createScimClient) +2. scimUsersCommands パーサー定義 +3. scimGroupsCommands パーサー定義 +4. rootParser に追加 +5. switch 文に 10 ケース追加 + +- [ ] **Step 1: Add imports to index.ts** + +`src/index.ts` の import セクション末尾(`import { createSlackClient } from "./client";` の直前あたり)に追加: + +```typescript +import { executeScimUsersList } from "./commands/scim-users/list"; +import { executeScimUsersGet } from "./commands/scim-users/get"; +import { executeScimUsersCreate } from "./commands/scim-users/create"; +import { executeScimUsersUpdate } from "./commands/scim-users/update"; +import { executeScimUsersDeactivate } from "./commands/scim-users/deactivate"; +import { executeScimGroupsList } from "./commands/scim-groups/list"; +import { executeScimGroupsGet } from "./commands/scim-groups/get"; +import { executeScimGroupsCreate } from "./commands/scim-groups/create"; +import { executeScimGroupsUpdate } from "./commands/scim-groups/update"; +import { executeScimGroupsDelete } from "./commands/scim-groups/delete"; +import { createScimClient } from "./scim-client"; +``` + +- [ ] **Step 2: Add scimUsersCommands parser** + +`src/index.ts` の functionsCommands 定義の後、rootParser の前に追加: + +```typescript +// --------------------------------------------------------------------------- +// SCIM Users commands +// --------------------------------------------------------------------------- + +const scimUsersCommands = command( + "scim-users", + or( + command("list", object({ + cmd: constant("scim-users-list" as const), + startIndex: optional(option("--start-index", integer({ metavar: "START_INDEX" }))), + count: optional(option("--count", integer({ metavar: "COUNT" }))), + filter: optional(option("--filter", string({ metavar: "FILTER" }))), + })), + command("get", object({ + cmd: constant("scim-users-get" as const), + id: option("--id", string({ metavar: "USER_ID" })), + })), + command("create", object({ + cmd: constant("scim-users-create" as const), + userName: option("--user-name", string({ metavar: "USER_NAME" })), + email: option("--email", string({ metavar: "EMAIL" })), + givenName: optional(option("--given-name", string({ metavar: "GIVEN_NAME" }))), + familyName: optional(option("--family-name", string({ metavar: "FAMILY_NAME" }))), + displayName: optional(option("--display-name", string({ metavar: "DISPLAY_NAME" }))), + })), + command("update", object({ + cmd: constant("scim-users-update" as const), + id: option("--id", string({ metavar: "USER_ID" })), + active: optional(option("--active", boolValueParser)), + userName: optional(option("--user-name", string({ metavar: "USER_NAME" }))), + email: optional(option("--email", string({ metavar: "EMAIL" }))), + givenName: optional(option("--given-name", string({ metavar: "GIVEN_NAME" }))), + familyName: optional(option("--family-name", string({ metavar: "FAMILY_NAME" }))), + displayName: optional(option("--display-name", string({ metavar: "DISPLAY_NAME" }))), + title: optional(option("--title", string({ metavar: "TITLE" }))), + })), + command("deactivate", object({ + cmd: constant("scim-users-deactivate" as const), + id: option("--id", string({ metavar: "USER_ID" })), + })), + ), +); +``` + +- [ ] **Step 3: Add scimGroupsCommands parser** + +`scimUsersCommands` の直後に追加: + +```typescript +// --------------------------------------------------------------------------- +// SCIM Groups commands +// --------------------------------------------------------------------------- + +const scimGroupsCommands = command( + "scim-groups", + or( + command("list", object({ + cmd: constant("scim-groups-list" as const), + startIndex: optional(option("--start-index", integer({ metavar: "START_INDEX" }))), + count: optional(option("--count", integer({ metavar: "COUNT" }))), + filter: optional(option("--filter", string({ metavar: "FILTER" }))), + })), + command("get", object({ + cmd: constant("scim-groups-get" as const), + id: option("--id", string({ metavar: "GROUP_ID" })), + })), + command("create", object({ + cmd: constant("scim-groups-create" as const), + displayName: option("--display-name", string({ metavar: "DISPLAY_NAME" })), + memberIds: optional(option("--member-ids", string({ metavar: "MEMBER_IDS" }))), + })), + command("update", object({ + cmd: constant("scim-groups-update" as const), + id: option("--id", string({ metavar: "GROUP_ID" })), + displayName: optional(option("--display-name", string({ metavar: "DISPLAY_NAME" }))), + addMemberIds: optional(option("--add-member-ids", string({ metavar: "MEMBER_IDS" }))), + removeMemberIds: optional(option("--remove-member-ids", string({ metavar: "MEMBER_IDS" }))), + })), + command("delete", object({ + cmd: constant("scim-groups-delete" as const), + id: option("--id", string({ metavar: "GROUP_ID" })), + })), + ), +); +``` + +- [ ] **Step 4: Update rootParser** + +Change the rootParser to include the new commands: + +```typescript +const rootParser = or( + or(tokenCommands, teamsCommands, usersCommands), + or(conversationsCommands, appsCommands), + or(inviteRequestsCommands, workflowsCommands, functionsCommands), + or(scimUsersCommands, scimGroupsCommands), +); +``` + +- [ ] **Step 5: Add switch cases for scim-users** + +switch 文の `default` ケースの直前に追加: + +```typescript + case "scim-users-list": { + const client = await createScimClient(store, profileFlag); + const users = await executeScimUsersList(client, { + startIndex: config.startIndex, + count: config.count, + filter: config.filter, + }); + const rows = users.map((u) => ({ + id: u.id, + userName: u.userName, + email: u.emails?.find((e) => e.primary)?.value ?? u.emails?.[0]?.value ?? "", + active: String(u.active), + })); + console.log(formatOutput(rows, ["id", "userName", "email", "active"], outputFormat)); + break; + } + case "scim-users-get": { + const client = await createScimClient(store, profileFlag); + const user = await executeScimUsersGet(client, { id: config.id }); + console.log(JSON.stringify(user, null, 2)); + break; + } + case "scim-users-create": { + const client = await createScimClient(store, profileFlag); + const created = await executeScimUsersCreate(client, { + userName: config.userName, + email: config.email, + givenName: config.givenName, + familyName: config.familyName, + displayName: config.displayName, + }); + console.log(JSON.stringify(created, null, 2)); + break; + } + case "scim-users-update": { + const client = await createScimClient(store, profileFlag); + const updated = await executeScimUsersUpdate(client, { + id: config.id, + active: config.active, + userName: config.userName, + email: config.email, + givenName: config.givenName, + familyName: config.familyName, + displayName: config.displayName, + title: config.title, + }); + console.log(JSON.stringify(updated, null, 2)); + break; + } + case "scim-users-deactivate": { + const client = await createScimClient(store, profileFlag); + await executeScimUsersDeactivate(client, { id: config.id }); + console.log(`User '${config.id}' deactivated.`); + break; + } +``` + +- [ ] **Step 6: Add switch cases for scim-groups** + +scim-users のケースの直後に追加: + +```typescript + case "scim-groups-list": { + const client = await createScimClient(store, profileFlag); + const groups = await executeScimGroupsList(client, { + startIndex: config.startIndex, + count: config.count, + filter: config.filter, + }); + const rows = groups.map((g) => ({ + id: g.id, + displayName: g.displayName, + memberCount: String(g.members?.length ?? 0), + })); + console.log(formatOutput(rows, ["id", "displayName", "memberCount"], outputFormat)); + break; + } + case "scim-groups-get": { + const client = await createScimClient(store, profileFlag); + const group = await executeScimGroupsGet(client, { id: config.id }); + console.log(JSON.stringify(group, null, 2)); + break; + } + case "scim-groups-create": { + const client = await createScimClient(store, profileFlag); + let groupMemberIds: string[] | undefined; + if (config.memberIds !== undefined) { + groupMemberIds = config.memberIds.split(","); + } + const created = await executeScimGroupsCreate(client, { + displayName: config.displayName, + memberIds: groupMemberIds, + }); + console.log(JSON.stringify(created, null, 2)); + break; + } + case "scim-groups-update": { + const client = await createScimClient(store, profileFlag); + let groupAddMemberIds: string[] | undefined; + let groupRemoveMemberIds: string[] | undefined; + if (config.addMemberIds !== undefined) { + groupAddMemberIds = config.addMemberIds.split(","); + } + if (config.removeMemberIds !== undefined) { + groupRemoveMemberIds = config.removeMemberIds.split(","); + } + const updated = await executeScimGroupsUpdate(client, { + id: config.id, + displayName: config.displayName, + addMemberIds: groupAddMemberIds, + removeMemberIds: groupRemoveMemberIds, + }); + console.log(JSON.stringify(updated, null, 2)); + break; + } + case "scim-groups-delete": { + const client = await createScimClient(store, profileFlag); + await executeScimGroupsDelete(client, { id: config.id }); + console.log(`Group '${config.id}' deleted.`); + break; + } +``` + +- [ ] **Step 7: Run type check** + +Run: `bun run lint` +Expected: No type errors. + +- [ ] **Step 8: Run all tests** + +Run: `bun test` +Expected: All tests PASS. + +- [ ] **Step 9: Commit** + +```bash +git add src/index.ts +git commit -m "feat: wire up all SCIM commands in index.ts" +``` + +--- + +### Task 7: ドキュメント更新(README.md, README_ja.md, CLAUDE.md) + +**Files:** +- Modify: `README.md` +- Modify: `README_ja.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Update README.md — Features section** + +`README.md` の Features セクションのコマンド数を更新。`73+` → `83+`(10 コマンド追加)。 + +- [ ] **Step 2: Update README.md — Commands section** + +Commands セクションに SCIM Users と SCIM Groups のテーブルを追加。Conversations セクションの後に: + +```markdown +### SCIM Users + +| Command | Description | +|---------|-------------| +| `scim-users list` | List users (SCIM) | +| `scim-users get` | Get user details (SCIM) | +| `scim-users create` | Create user (SCIM) | +| `scim-users update` | Update user attributes (SCIM) | +| `scim-users deactivate` | Deactivate user (SCIM) | + +### SCIM Groups + +| Command | Description | +|---------|-------------| +| `scim-groups list` | List groups (SCIM) | +| `scim-groups get` | Get group details (SCIM) | +| `scim-groups create` | Create group (SCIM) | +| `scim-groups update` | Update group (SCIM) | +| `scim-groups delete` | Delete group (SCIM) | +``` + +- [ ] **Step 3: Update README.md — Required Scopes** + +Required Scopes テーブルに追加: + +```markdown +| `admin` | SCIM user and group management | +``` + +- [ ] **Step 4: Update README_ja.md — Features section** + +同様にコマンド数を更新: `73以上` → `83以上`。 + +- [ ] **Step 5: Update README_ja.md — Commands section** + +Commands セクションに追加: + +```markdown +### SCIM Users + +| コマンド | 説明 | +|---------|------| +| `scim-users list` | ユーザー一覧(SCIM) | +| `scim-users get` | ユーザー詳細取得(SCIM) | +| `scim-users create` | ユーザー作成(SCIM) | +| `scim-users update` | ユーザー属性更新(SCIM) | +| `scim-users deactivate` | ユーザー無効化(SCIM) | + +### SCIM Groups + +| コマンド | 説明 | +|---------|------| +| `scim-groups list` | グループ一覧(SCIM) | +| `scim-groups get` | グループ詳細取得(SCIM) | +| `scim-groups create` | グループ作成(SCIM) | +| `scim-groups update` | グループ更新(SCIM) | +| `scim-groups delete` | グループ削除(SCIM) | +``` + +- [ ] **Step 6: Update README_ja.md — Required Scopes** + +Required Scopes テーブルに追加: + +```markdown +| `admin` | SCIM ユーザー・グループ管理 | +``` + +- [ ] **Step 7: Update CLAUDE.md — Architecture** + +`CLAUDE.md` のディレクトリ構成に `scim-client.ts`、`scim-types.ts`、`scim-users/`、`scim-groups/` を追加: + +``` +src/ +├── index.ts # CLI パーサー定義 + コマンドルーティング(switch文) +├── client.ts # WebClient ファクトリ +├── scim-client.ts # ScimClient ファクトリ(SCIM v2 API 用) +├── scim-types.ts # SCIM 型定義 +├── config.ts # プロファイル・トークン管理 +├── output.ts # 出力フォーマッタ(JSON / table / plain) +└── commands/ # コマンド実装(グループ/サブコマンドごとにファイル分割) + ├── token/ + ├── teams/ + ├── users/ + ├── conversations/ + ├── apps/ + ├── invite-requests/ + ├── workflows/ + ├── functions/ + ├── scim-users/ + └── scim-groups/ +``` + +- [ ] **Step 8: Commit** + +```bash +git add README.md README_ja.md CLAUDE.md +git commit -m "docs: add SCIM commands to README, README_ja, CLAUDE.md" +``` + +--- + +### Task 8: Skills 更新(SKILL.md, recipes) + +**Files:** +- Modify: `skills/slack-admin-cli-skill/SKILL.md` +- Create: `skills/slack-admin-cli-skill/recipes/scim-users.md` +- Create: `skills/slack-admin-cli-skill/recipes/scim-groups.md` + +- [ ] **Step 1: Update SKILL.md** + +`skills/slack-admin-cli-skill/SKILL.md` のコマンドグループテーブルに追加: + +```markdown +| `scim-users` | SCIM ユーザー管理(作成・更新・無効化) | [scim-users](recipes/scim-users.md) | +| `scim-groups` | SCIM グループ管理(作成・更新・削除) | [scim-groups](recipes/scim-groups.md) | +``` + +また、description のフロントマターにも SCIM を追記。 + +- [ ] **Step 2: Create scim-users recipe** + +Create `skills/slack-admin-cli-skill/recipes/scim-users.md`: + +```markdown +# SCIM Users 操作 + +SCIM v2.0 API 経由のユーザー管理。組織全体(Org レベル)でのユーザー作成・更新・無効化が可能。 + +## コマンド一覧 + +| コマンド | 説明 | 必須スコープ | +|---------|------|-------------| +| `scim-users list` | ユーザー一覧 | `admin` | +| `scim-users get` | ユーザー詳細取得 | `admin` | +| `scim-users create` | ユーザー作成 | `admin` | +| `scim-users update` | ユーザー属性更新 | `admin` | +| `scim-users deactivate` | ユーザー無効化 | `admin` | + +## 使用例 + +```bash +# ユーザー一覧(最初の50件) +sladm scim-users list --count 50 + +# フィルターで検索 +sladm scim-users list --filter 'userName eq "alice"' + +# ユーザー詳細 +sladm scim-users get --id U12345 + +# ユーザー作成 +sladm scim-users create --user-name alice --email alice@example.com --given-name Alice --family-name Smith + +# ユーザー属性更新 +sladm scim-users update --id U12345 --title "Senior Engineer" --display-name "Alice S." + +# ユーザー無効化(組織全体から deactivate) +sladm scim-users deactivate --id U12345 +``` + +## admin.users との違い + +| 操作 | admin.users | scim-users | +|------|------------|------------| +| ユーザー一覧 | ワークスペース単位(`--team-id` 必須) | 組織全体 | +| ユーザー作成 | `invite`(招待) | `create`(直接作成) | +| ユーザー無効化 | `remove`(ワークスペースから除外のみ) | `deactivate`(組織全体で無効化) | +| プロフィール更新 | 不可 | `update` で可能 | + +## Tips + +- `--filter` は SCIM フィルター構文: `userName eq "alice"`, `active eq "true"` 等 +- `deactivate` は完全削除ではなく無効化。ユーザーレコードは残る +- `update` は指定したフィールドのみ更新(PATCH)。省略したフィールドは変更されない +``` + +- [ ] **Step 3: Create scim-groups recipe** + +Create `skills/slack-admin-cli-skill/recipes/scim-groups.md`: + +```markdown +# SCIM Groups 操作 + +SCIM v2.0 API 経由のグループ(IDP グループ)管理。 + +## コマンド一覧 + +| コマンド | 説明 | 必須スコープ | +|---------|------|-------------| +| `scim-groups list` | グループ一覧 | `admin` | +| `scim-groups get` | グループ詳細取得 | `admin` | +| `scim-groups create` | グループ作成 | `admin` | +| `scim-groups update` | グループ更新 | `admin` | +| `scim-groups delete` | グループ削除 | `admin` | + +## 使用例 + +```bash +# グループ一覧 +sladm scim-groups list + +# グループ詳細(メンバー一覧含む) +sladm scim-groups get --id G12345 + +# グループ作成(メンバー付き) +sladm scim-groups create --display-name "Engineering" --member-ids U001,U002,U003 + +# グループ名変更 +sladm scim-groups update --id G12345 --display-name "Platform Engineering" + +# メンバー追加 +sladm scim-groups update --id G12345 --add-member-ids U004,U005 + +# メンバー削除 +sladm scim-groups update --id G12345 --remove-member-ids U001 + +# グループ削除(メンバーはアクティブのまま) +sladm scim-groups delete --id G12345 +``` + +## Tips + +- `delete` はグループのみ削除。メンバーのアカウントには影響しない +- `update` の `--add-member-ids` と `--remove-member-ids` は同時に指定可能 +- 大量メンバー追加は 5,000 件以下に分割することを推奨(Slack 制限) +``` + +- [ ] **Step 4: Commit** + +```bash +git add skills/slack-admin-cli-skill/SKILL.md skills/slack-admin-cli-skill/recipes/scim-users.md skills/slack-admin-cli-skill/recipes/scim-groups.md +git commit -m "feat: add SCIM skill recipes for scim-users and scim-groups" +``` From 0ee2c844defb3d1267e427c17d3e941d1371e36a Mon Sep 17 00:00:00 2001 From: Mitsuki Ogasahara Date: Thu, 9 Apr 2026 23:34:03 +0900 Subject: [PATCH 03/11] feat: add SCIM types and ScimClient Co-Authored-By: Claude Sonnet 4.6 --- src/scim-client.ts | 154 +++++++++++++++++++++++++++++++++ src/scim-types.ts | 62 +++++++++++++ tests/scim-client.test.ts | 178 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 394 insertions(+) create mode 100644 src/scim-client.ts create mode 100644 src/scim-types.ts create mode 100644 tests/scim-client.test.ts diff --git a/src/scim-client.ts b/src/scim-client.ts new file mode 100644 index 0000000..fd82480 --- /dev/null +++ b/src/scim-client.ts @@ -0,0 +1,154 @@ +import type { ProfileStore } from "./config"; +import type { + ScimUser, + ScimGroup, + ScimListResponse, + ScimPatchOperation, + CreateScimUserParams, + CreateScimGroupParams, +} from "./scim-types"; + +const SCIM_BASE_URL = "https://api.slack.com/scim/v2"; + +export class ScimClient { + private token: string; + + constructor(token: string) { + this.token = token; + } + + private async request( + method: string, + path: string, + body?: unknown, + params?: Record, + ): Promise { + const url = new URL(`${SCIM_BASE_URL}${path}`); + if (params) { + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + } + + const headers: Record = { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + }; + + const response = await fetch(url.toString(), { + method, + headers, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => null); + const detail = + (errorBody as { Errors?: { description?: string }; detail?: string } | null)?.Errors + ?.description ?? + (errorBody as { detail?: string } | null)?.detail ?? + response.statusText; + throw new Error(`SCIM API error (${response.status}): ${detail}`); + } + + if (response.status === 204) { + return undefined as never; + } + + return response.json(); + } + + users = { + list: (params?: { + startIndex?: number; + count?: number; + filter?: string; + }): Promise> => { + const queryParams: Record = {}; + if (params?.startIndex !== undefined) queryParams.startIndex = String(params.startIndex); + if (params?.count !== undefined) queryParams.count = String(params.count); + if (params?.filter !== undefined) queryParams.filter = params.filter; + return this.request("GET", "/Users", undefined, queryParams); + }, + + get: (id: string): Promise => { + return this.request("GET", `/Users/${id}`); + }, + + create: (params: CreateScimUserParams): Promise => { + const body: Record = { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], + userName: params.userName, + emails: [{ value: params.email, primary: true }], + }; + if (params.givenName !== undefined || params.familyName !== undefined) { + body.name = { + ...(params.givenName !== undefined ? { givenName: params.givenName } : {}), + ...(params.familyName !== undefined ? { familyName: params.familyName } : {}), + }; + } + if (params.displayName !== undefined) body.displayName = params.displayName; + return this.request("POST", "/Users", body); + }, + + update: (id: string, operations: ScimPatchOperation[]): Promise => { + return this.request("PATCH", `/Users/${id}`, { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: operations, + }); + }, + + deactivate: (id: string): Promise => { + return this.request("DELETE", `/Users/${id}`); + }, + }; + + groups = { + list: (params?: { + startIndex?: number; + count?: number; + filter?: string; + }): Promise> => { + const queryParams: Record = {}; + if (params?.startIndex !== undefined) queryParams.startIndex = String(params.startIndex); + if (params?.count !== undefined) queryParams.count = String(params.count); + if (params?.filter !== undefined) queryParams.filter = params.filter; + return this.request("GET", "/Groups", undefined, queryParams); + }, + + get: (id: string): Promise => { + return this.request("GET", `/Groups/${id}`); + }, + + create: (params: CreateScimGroupParams): Promise => { + const body: Record = { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], + displayName: params.displayName, + }; + if (params.memberIds !== undefined) { + body.members = params.memberIds.map((id) => ({ value: id })); + } + return this.request("POST", "/Groups", body); + }, + + update: (id: string, operations: ScimPatchOperation[]): Promise => { + return this.request("PATCH", `/Groups/${id}`, { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: operations, + }); + }, + + delete: (id: string): Promise => { + return this.request("DELETE", `/Groups/${id}`); + }, + }; +} + +export async function createScimClient( + store: ProfileStore, + profileName?: string, +): Promise { + const resolved = await store.resolveProfileName(profileName); + const token = await store.getToken(resolved); + return new ScimClient(token); +} diff --git a/src/scim-types.ts b/src/scim-types.ts new file mode 100644 index 0000000..581010c --- /dev/null +++ b/src/scim-types.ts @@ -0,0 +1,62 @@ +export interface ScimUser { + id: string; + userName: string; + name: { + givenName: string; + familyName: string; + }; + displayName?: string; + emails: Array<{ + value: string; + primary: boolean; + type?: string; + }>; + active: boolean; + title?: string; + nickName?: string; + timezone?: string; + photos?: Array<{ + value: string; + type?: string; + primary?: boolean; + }>; + groups?: Array<{ + value: string; + display?: string; + }>; +} + +export interface ScimGroup { + id: string; + displayName: string; + members: Array<{ + value: string; + display?: string; + }>; +} + +export interface ScimListResponse { + totalResults: number; + itemsPerPage: number; + startIndex: number; + Resources: T[]; +} + +export interface ScimPatchOperation { + op: "add" | "remove" | "replace"; + path?: string; + value?: unknown; +} + +export interface CreateScimUserParams { + userName: string; + email: string; + givenName?: string; + familyName?: string; + displayName?: string; +} + +export interface CreateScimGroupParams { + displayName: string; + memberIds?: string[]; +} diff --git a/tests/scim-client.test.ts b/tests/scim-client.test.ts new file mode 100644 index 0000000..0e06d9e --- /dev/null +++ b/tests/scim-client.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test, mock, afterEach } from "bun:test"; +import { ScimClient } from "../src/scim-client"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function mockFetchResponse(body: unknown, status = 200) { + const mockFn = mock(() => + Promise.resolve({ + ok: status >= 200 && status < 300, + status, + statusText: "OK", + json: () => Promise.resolve(body), + }), + ); + globalThis.fetch = mockFn as any; + return mockFn; +} + +function mockFetchNoContent() { + const mockFn = mock(() => + Promise.resolve({ + ok: true, + status: 204, + statusText: "No Content", + json: () => Promise.reject(new Error("No content")), + }), + ); + globalThis.fetch = mockFn as any; + return mockFn; +} + +describe("ScimClient", () => { + describe("users.list", () => { + test("sends GET /Users with auth header", async () => { + const mockFn = mockFetchResponse({ totalResults: 0, itemsPerPage: 100, startIndex: 1, Resources: [] }); + const client = new ScimClient("xoxp-test"); + + await client.users.list(); + + expect(mockFn).toHaveBeenCalledTimes(1); + const [url, options] = mockFn.mock.calls[0] as [string, RequestInit & { headers: Record }]; + expect(url).toBe("https://api.slack.com/scim/v2/Users"); + expect(options.method).toBe("GET"); + expect(options.headers.Authorization).toBe("Bearer xoxp-test"); + }); + + test("includes query params when provided", async () => { + mockFetchResponse({ totalResults: 0, itemsPerPage: 10, startIndex: 5, Resources: [] }); + const client = new ScimClient("xoxp-test"); + + await client.users.list({ startIndex: 5, count: 10, filter: "userName eq \"alice\"" }); + + const [url] = (globalThis.fetch as any).mock.calls[0] as [string]; + const parsed = new URL(url); + expect(parsed.searchParams.get("startIndex")).toBe("5"); + expect(parsed.searchParams.get("count")).toBe("10"); + expect(parsed.searchParams.get("filter")).toBe("userName eq \"alice\""); + }); + }); + + describe("users.get", () => { + test("sends GET /Users/{id}", async () => { + const user = { id: "U001", userName: "alice", active: true, emails: [], name: { givenName: "Alice", familyName: "Smith" } }; + const mockFn = mockFetchResponse(user); + const client = new ScimClient("xoxp-test"); + + const result = await client.users.get("U001"); + + expect(result.id).toBe("U001"); + const [url] = mockFn.mock.calls[0] as [string]; + expect(url).toBe("https://api.slack.com/scim/v2/Users/U001"); + }); + }); + + describe("users.create", () => { + test("sends POST /Users with user data", async () => { + const created = { id: "U999", userName: "newuser", active: true, emails: [{ value: "new@ex.com", primary: true }], name: { givenName: "", familyName: "" } }; + const mockFn = mockFetchResponse(created); + const client = new ScimClient("xoxp-test"); + + const result = await client.users.create({ userName: "newuser", email: "new@ex.com", givenName: "New", familyName: "User" }); + + expect(result.id).toBe("U999"); + const [url, options] = mockFn.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.slack.com/scim/v2/Users"); + expect(options.method).toBe("POST"); + const body = JSON.parse(options.body as string); + expect(body.userName).toBe("newuser"); + expect(body.emails).toEqual([{ value: "new@ex.com", primary: true }]); + expect(body.name).toEqual({ givenName: "New", familyName: "User" }); + }); + }); + + describe("users.update", () => { + test("sends PATCH /Users/{id} with operations", async () => { + const updated = { id: "U001", userName: "alice", active: false, emails: [], name: { givenName: "Alice", familyName: "Smith" } }; + const mockFn = mockFetchResponse(updated); + const client = new ScimClient("xoxp-test"); + + await client.users.update("U001", [{ op: "replace", path: "active", value: false }]); + + const [url, options] = mockFn.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.slack.com/scim/v2/Users/U001"); + expect(options.method).toBe("PATCH"); + const body = JSON.parse(options.body as string); + expect(body.schemas).toEqual(["urn:ietf:params:scim:api:messages:2.0:PatchOp"]); + expect(body.Operations).toEqual([{ op: "replace", path: "active", value: false }]); + }); + }); + + describe("users.deactivate", () => { + test("sends DELETE /Users/{id}", async () => { + const mockFn = mockFetchNoContent(); + const client = new ScimClient("xoxp-test"); + + await client.users.deactivate("U001"); + + const [url, options] = mockFn.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.slack.com/scim/v2/Users/U001"); + expect(options.method).toBe("DELETE"); + }); + }); + + describe("groups.list", () => { + test("sends GET /Groups with auth header", async () => { + const mockFn = mockFetchResponse({ totalResults: 0, itemsPerPage: 100, startIndex: 1, Resources: [] }); + const client = new ScimClient("xoxp-test"); + + await client.groups.list(); + + const [url, options] = mockFn.mock.calls[0] as [string, RequestInit & { headers: Record }]; + expect(url).toBe("https://api.slack.com/scim/v2/Groups"); + expect(options.method).toBe("GET"); + }); + }); + + describe("groups.create", () => { + test("sends POST /Groups with group data", async () => { + const created = { id: "G001", displayName: "Engineering", members: [{ value: "U001" }] }; + const mockFn = mockFetchResponse(created); + const client = new ScimClient("xoxp-test"); + + const result = await client.groups.create({ displayName: "Engineering", memberIds: ["U001"] }); + + expect(result.id).toBe("G001"); + const [, options] = mockFn.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(options.body as string); + expect(body.displayName).toBe("Engineering"); + expect(body.members).toEqual([{ value: "U001" }]); + }); + }); + + describe("groups.delete", () => { + test("sends DELETE /Groups/{id}", async () => { + const mockFn = mockFetchNoContent(); + const client = new ScimClient("xoxp-test"); + + await client.groups.delete("G001"); + + const [url, options] = mockFn.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.slack.com/scim/v2/Groups/G001"); + expect(options.method).toBe("DELETE"); + }); + }); + + describe("error handling", () => { + test("throws on non-ok response with SCIM error detail", async () => { + mockFetchResponse({ Errors: { description: "User not found", code: 404 } }, 404); + const client = new ScimClient("xoxp-test"); + + await expect(client.users.get("U999")).rejects.toThrow("SCIM API error (404)"); + }); + }); +}); From 8119a514376bb21fd017792c34576cf2645dcb37 Mon Sep 17 00:00:00 2001 From: Mitsuki Ogasahara Date: Thu, 9 Apr 2026 23:35:50 +0900 Subject: [PATCH 04/11] fix: remove as casts from ScimClient, add missing tests Co-Authored-By: Claude Sonnet 4.6 --- src/scim-client.ts | 7 ++----- tests/scim-client.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/scim-client.ts b/src/scim-client.ts index fd82480..94b3220 100644 --- a/src/scim-client.ts +++ b/src/scim-client.ts @@ -44,15 +44,12 @@ export class ScimClient { if (!response.ok) { const errorBody = await response.json().catch(() => null); const detail = - (errorBody as { Errors?: { description?: string }; detail?: string } | null)?.Errors - ?.description ?? - (errorBody as { detail?: string } | null)?.detail ?? - response.statusText; + errorBody?.Errors?.description ?? errorBody?.detail ?? response.statusText; throw new Error(`SCIM API error (${response.status}): ${detail}`); } if (response.status === 204) { - return undefined as never; + return undefined!; } return response.json(); diff --git a/tests/scim-client.test.ts b/tests/scim-client.test.ts index 0e06d9e..068aba7 100644 --- a/tests/scim-client.test.ts +++ b/tests/scim-client.test.ts @@ -154,6 +154,37 @@ describe("ScimClient", () => { }); }); + describe("groups.get", () => { + test("sends GET /Groups/{id}", async () => { + const group = { id: "G001", displayName: "Engineering", members: [{ value: "U001", display: "alice" }] }; + const mockFn = mockFetchResponse(group); + const client = new ScimClient("xoxp-test"); + + const result = await client.groups.get("G001"); + + expect(result.id).toBe("G001"); + const [url] = mockFn.mock.calls[0] as [string]; + expect(url).toBe("https://api.slack.com/scim/v2/Groups/G001"); + }); + }); + + describe("groups.update", () => { + test("sends PATCH /Groups/{id} with operations", async () => { + const updated = { id: "G001", displayName: "New Name", members: [] }; + const mockFn = mockFetchResponse(updated); + const client = new ScimClient("xoxp-test"); + + await client.groups.update("G001", [{ op: "replace", path: "displayName", value: "New Name" }]); + + const [url, options] = mockFn.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.slack.com/scim/v2/Groups/G001"); + expect(options.method).toBe("PATCH"); + const body = JSON.parse(options.body as string); + expect(body.schemas).toEqual(["urn:ietf:params:scim:api:messages:2.0:PatchOp"]); + expect(body.Operations).toEqual([{ op: "replace", path: "displayName", value: "New Name" }]); + }); + }); + describe("groups.delete", () => { test("sends DELETE /Groups/{id}", async () => { const mockFn = mockFetchNoContent(); From b7330b2b2e24ccce1bad89361498528921f78fe3 Mon Sep 17 00:00:00 2001 From: Mitsuki Ogasahara Date: Thu, 9 Apr 2026 23:38:40 +0900 Subject: [PATCH 05/11] feat: add scim-users list and get commands --- .../plans/2026-04-07-release-flow.md | 400 ++++++++++++++++++ src/commands/scim-users/get.ts | 13 + src/commands/scim-users/list.ts | 16 + tests/commands/scim-users/get.test.ts | 21 + tests/commands/scim-users/list.test.ts | 41 ++ 5 files changed, 491 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-07-release-flow.md create mode 100644 src/commands/scim-users/get.ts create mode 100644 src/commands/scim-users/list.ts create mode 100644 tests/commands/scim-users/get.test.ts create mode 100644 tests/commands/scim-users/list.test.ts diff --git a/docs/superpowers/plans/2026-04-07-release-flow.md b/docs/superpowers/plans/2026-04-07-release-flow.md new file mode 100644 index 0000000..5455c4a --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-release-flow.md @@ -0,0 +1,400 @@ +# Release Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Set up CI, release-please, npm publish, and cross-platform binary builds as GitHub Actions workflows. + +**Architecture:** Three GitHub Actions workflows: (1) CI runs test + lint on every push/PR, (2) release-please creates Release PRs from conventional commits and publishes to npm on merge, (3) binary build triggers on GitHub Release creation, compiles single executables for 5 platforms via `bun build --compile`, and uploads them as release assets. Also add LICENSE, update package.json metadata, and update .gitignore. + +**Tech Stack:** GitHub Actions, release-please, `bun build --compile`, npm registry + +--- + +## File Structure + +``` +(create) LICENSE — MIT license text +(create) .github/workflows/ci.yml — test + lint on push/PR +(create) .github/workflows/release.yml — release-please + npm publish +(create) .github/workflows/binary.yml — cross-platform binary build on release +(modify) package.json — add metadata (repository, keywords, engines, files, publishConfig) +(modify) .gitignore — add dist/ +(modify) README.md — add binary download instructions +(modify) README_ja.md — add binary download instructions (Japanese) +``` + +--- + +### Task 1: LICENSE file + +**Files:** +- Create: `LICENSE` + +- [ ] **Step 1: Create MIT LICENSE file** + +``` +MIT License + +Copyright (c) 2025 Mitsuki Ogasahara + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +- [ ] **Step 2: Commit** + +```bash +git add LICENSE +git commit -m "chore: add MIT license" +``` + +--- + +### Task 2: Update package.json metadata + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Add npm publishing metadata to package.json** + +Add/modify these fields in `package.json`: + +```json +{ + "name": "sladm", + "version": "0.1.0", + "description": "Slack Admin CLI for humans and AI agents — manage Enterprise Grid via admin.* APIs", + "type": "module", + "bin": { + "sladm": "./src/index.ts" + }, + "files": [ + "src/**/*.ts", + "skills/**/*", + ".claude-plugin/**/*", + "LICENSE", + "README.md" + ], + "repository": { + "type": "git", + "url": "https://github.com/yamitzky/slack-admin-cli.git" + }, + "keywords": [ + "slack", + "slack-admin", + "enterprise-grid", + "cli", + "agent-skill" + ], + "author": "Mitsuki Ogasahara", + "license": "MIT", + "engines": { + "bun": ">=1.0" + }, + "scripts": { + "dev": "bun run src/index.ts", + "test": "bun test", + "lint": "bunx tsc --noEmit" + }, + "dependencies": { + "@optique/core": "latest", + "@optique/run": "latest", + "@slack/web-api": "latest" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "latest" + } +} +``` + +The key additions are: `description`, `files`, `repository`, `keywords`, `author`, `license`, `engines`. + +- [ ] **Step 2: Verify lint still passes** + +Run: `bun run lint` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add package.json +git commit -m "chore: add npm publishing metadata to package.json" +``` + +--- + +### Task 3: Update .gitignore + +**Files:** +- Modify: `.gitignore` + +- [ ] **Step 1: Update .gitignore** + +Replace current content with: + +``` +node_modules/ +dist/ +.DS_Store +*.tgz +``` + +`*.tgz` is added because `npm pack` creates tarball files in the project root. + +- [ ] **Step 2: Commit** + +```bash +git add .gitignore +git commit -m "chore: update .gitignore" +``` + +--- + +### Task 4: CI workflow (test + lint) + +**Files:** +- Create: `.github/workflows/ci.yml` + +- [ ] **Step 1: Create CI workflow** + +```yaml +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run lint + - run: bun test +``` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: add test and lint workflow" +``` + +--- + +### Task 5: Release workflow (release-please + npm publish) + +**Files:** +- Create: `.github/workflows/release.yml` + +release-please detects conventional commits (`feat:`, `fix:`, etc.) on main, opens a Release PR that bumps version in `package.json` and updates `CHANGELOG.md`. When merged, it creates a GitHub Release. The npm publish job runs after the release is created. + +- [ ] **Step 1: Create release workflow** + +```yaml +name: Release + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - uses: googleapis/release-please-action@v4 + id: release + with: + release-type: node + + npm-publish: + needs: release-please + if: ${{ needs.release-please.outputs.release_created }} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run lint + - run: bun test + - uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + - run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} +``` + +Note: `npm publish` is used instead of `bun publish` because npm provenance requires Node.js. The `NPM_TOKEN` secret must be configured in the repository settings. + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "ci: add release-please and npm publish workflow" +``` + +--- + +### Task 6: Binary build workflow + +**Files:** +- Create: `.github/workflows/binary.yml` + +This workflow triggers when a GitHub Release is published (by release-please). It builds single executables for 5 platforms using `bun build --compile` and uploads them as release assets. + +- [ ] **Step 1: Create binary build workflow** + +```yaml +name: Build Binaries + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - target: bun-darwin-arm64 + artifact: sladm-darwin-arm64 + - target: bun-darwin-x64 + artifact: sladm-darwin-x64 + - target: bun-linux-x64 + artifact: sladm-linux-x64 + - target: bun-linux-arm64 + artifact: sladm-linux-arm64 + - target: bun-windows-x64 + artifact: sladm-windows-x64.exe + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - name: Build binary + run: bun build --compile --minify --sourcemap --bytecode --target ${{ matrix.target }} ./src/index.ts --outfile ${{ matrix.artifact }} + - name: Upload release asset + uses: softprops/action-gh-release@v2 + with: + files: ${{ matrix.artifact }} +``` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/binary.yml +git commit -m "ci: add cross-platform binary build workflow" +``` + +--- + +### Task 7: Update READMEs with binary download instructions + +**Files:** +- Modify: `README.md` +- Modify: `README_ja.md` + +- [ ] **Step 1: Add binary download section to README.md** + +Add the following after the existing Installation section's `npx sladm --help` block, before the `## Agent Skill` section: + +```markdown +### Pre-built Binaries + +Standalone binaries (no runtime required) are available on the [Releases](https://github.com/yamitzky/slack-admin-cli/releases) page: + +| Platform | File | +|----------|------| +| macOS (Apple Silicon) | `sladm-darwin-arm64` | +| macOS (Intel) | `sladm-darwin-x64` | +| Linux (x64) | `sladm-linux-x64` | +| Linux (arm64) | `sladm-linux-arm64` | +| Windows (x64) | `sladm-windows-x64.exe` | + +```bash +# Example: download and install on macOS (Apple Silicon) +curl -L https://github.com/yamitzky/slack-admin-cli/releases/latest/download/sladm-darwin-arm64 -o sladm +chmod +x sladm +sudo mv sladm /usr/local/bin/ +``` + +- [ ] **Step 2: Add binary download section to README_ja.md** + +Add the following after the existing Installation section's `npx sladm --help` block, before the `## Agent Skill` section: + +```markdown +### ビルド済みバイナリ + +ランタイム不要のスタンドアロンバイナリを [Releases](https://github.com/yamitzky/slack-admin-cli/releases) ページからダウンロードできます: + +| プラットフォーム | ファイル | +|----------------|---------| +| macOS (Apple Silicon) | `sladm-darwin-arm64` | +| macOS (Intel) | `sladm-darwin-x64` | +| Linux (x64) | `sladm-linux-x64` | +| Linux (arm64) | `sladm-linux-arm64` | +| Windows (x64) | `sladm-windows-x64.exe` | + +```bash +# 例: macOS (Apple Silicon) でダウンロード・インストール +curl -L https://github.com/yamitzky/slack-admin-cli/releases/latest/download/sladm-darwin-arm64 -o sladm +chmod +x sladm +sudo mv sladm /usr/local/bin/ +``` + +- [ ] **Step 3: Commit** + +```bash +git add README.md README_ja.md +git commit -m "docs: add binary download instructions to READMEs" +``` + +--- + +## Post-Implementation Checklist + +After all tasks are complete, the following manual steps are needed: + +1. **NPM_TOKEN secret**: Go to GitHub repo Settings > Secrets and variables > Actions > New repository secret. Add `NPM_TOKEN` with an npm access token (create at https://www.npmjs.com/settings/~/tokens with "Automation" type). +2. **Push to main**: Push all commits. release-please will create the first Release PR based on existing conventional commits. +3. **Verify CI**: Confirm the CI workflow runs on push and passes. +4. **Merge Release PR**: When ready, merge the release-please PR. This triggers npm publish + binary build. +5. **GitHub repo description**: Update to `sladm — Slack Admin CLI for humans and AI agents`. diff --git a/src/commands/scim-users/get.ts b/src/commands/scim-users/get.ts new file mode 100644 index 0000000..f6cd324 --- /dev/null +++ b/src/commands/scim-users/get.ts @@ -0,0 +1,13 @@ +import type { ScimClient } from "../../scim-client"; +import type { ScimUser } from "../../scim-types"; + +interface ScimUsersGetOptions { + id: string; +} + +export async function executeScimUsersGet( + client: ScimClient, + opts: ScimUsersGetOptions, +): Promise { + return client.users.get(opts.id); +} diff --git a/src/commands/scim-users/list.ts b/src/commands/scim-users/list.ts new file mode 100644 index 0000000..22c9be7 --- /dev/null +++ b/src/commands/scim-users/list.ts @@ -0,0 +1,16 @@ +import type { ScimClient } from "../../scim-client"; +import type { ScimUser } from "../../scim-types"; + +interface ScimUsersListOptions { + startIndex?: number; + count?: number; + filter?: string; +} + +export async function executeScimUsersList( + client: ScimClient, + opts: ScimUsersListOptions, +): Promise { + const response = await client.users.list(opts); + return response.Resources; +} diff --git a/tests/commands/scim-users/get.test.ts b/tests/commands/scim-users/get.test.ts new file mode 100644 index 0000000..60a8045 --- /dev/null +++ b/tests/commands/scim-users/get.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeScimUsersGet } from "../../../src/commands/scim-users/get"; + +describe("scim-users get", () => { + test("returns user by id", async () => { + const user = { + id: "U001", + userName: "alice", + active: true, + emails: [{ value: "alice@ex.com", primary: true }], + name: { givenName: "Alice", familyName: "Smith" }, + displayName: "Alice Smith", + }; + const mockGet = mock(() => Promise.resolve(user)); + const client = { users: { get: mockGet } } as any; + const result = await executeScimUsersGet(client, { id: "U001" }); + expect(result.id).toBe("U001"); + expect(result.userName).toBe("alice"); + expect(mockGet).toHaveBeenCalledWith("U001"); + }); +}); diff --git a/tests/commands/scim-users/list.test.ts b/tests/commands/scim-users/list.test.ts new file mode 100644 index 0000000..2e26401 --- /dev/null +++ b/tests/commands/scim-users/list.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeScimUsersList } from "../../../src/commands/scim-users/list"; + +describe("scim-users list", () => { + test("returns users from SCIM API", async () => { + const mockList = mock(() => + Promise.resolve({ + totalResults: 2, + itemsPerPage: 100, + startIndex: 1, + Resources: [ + { id: "U001", userName: "alice", active: true, emails: [{ value: "alice@ex.com", primary: true }], name: { givenName: "Alice", familyName: "Smith" } }, + { id: "U002", userName: "bob", active: false, emails: [{ value: "bob@ex.com", primary: true }], name: { givenName: "Bob", familyName: "Jones" } }, + ], + }), + ); + const client = { users: { list: mockList } } as any; + const result = await executeScimUsersList(client, {}); + expect(result).toHaveLength(2); + expect(result[0].id).toBe("U001"); + expect(result[1].userName).toBe("bob"); + }); + + test("passes pagination and filter params", async () => { + const mockList = mock(() => + Promise.resolve({ totalResults: 0, itemsPerPage: 10, startIndex: 5, Resources: [] }), + ); + const client = { users: { list: mockList } } as any; + await executeScimUsersList(client, { startIndex: 5, count: 10, filter: "userName eq \"alice\"" }); + expect(mockList).toHaveBeenCalledWith({ startIndex: 5, count: 10, filter: "userName eq \"alice\"" }); + }); + + test("returns empty array when no users", async () => { + const mockList = mock(() => + Promise.resolve({ totalResults: 0, itemsPerPage: 100, startIndex: 1, Resources: [] }), + ); + const client = { users: { list: mockList } } as any; + const result = await executeScimUsersList(client, {}); + expect(result).toEqual([]); + }); +}); From f2d198ad14f292aa3b169899f5268e0571ba1b82 Mon Sep 17 00:00:00 2001 From: Mitsuki Ogasahara Date: Thu, 9 Apr 2026 23:40:01 +0900 Subject: [PATCH 06/11] feat: add scim-users create, update, deactivate commands Co-Authored-By: Claude Sonnet 4.6 --- src/commands/scim-users/create.ts | 17 ++++++++++ src/commands/scim-users/deactivate.ts | 12 +++++++ src/commands/scim-users/update.ts | 34 ++++++++++++++++++++ tests/commands/scim-users/create.test.ts | 21 ++++++++++++ tests/commands/scim-users/deactivate.test.ts | 11 +++++++ tests/commands/scim-users/update.test.ts | 33 +++++++++++++++++++ 6 files changed, 128 insertions(+) create mode 100644 src/commands/scim-users/create.ts create mode 100644 src/commands/scim-users/deactivate.ts create mode 100644 src/commands/scim-users/update.ts create mode 100644 tests/commands/scim-users/create.test.ts create mode 100644 tests/commands/scim-users/deactivate.test.ts create mode 100644 tests/commands/scim-users/update.test.ts diff --git a/src/commands/scim-users/create.ts b/src/commands/scim-users/create.ts new file mode 100644 index 0000000..d8c2016 --- /dev/null +++ b/src/commands/scim-users/create.ts @@ -0,0 +1,17 @@ +import type { ScimClient } from "../../scim-client"; +import type { ScimUser } from "../../scim-types"; + +interface ScimUsersCreateOptions { + userName: string; + email: string; + givenName?: string; + familyName?: string; + displayName?: string; +} + +export async function executeScimUsersCreate( + client: ScimClient, + opts: ScimUsersCreateOptions, +): Promise { + return client.users.create(opts); +} diff --git a/src/commands/scim-users/deactivate.ts b/src/commands/scim-users/deactivate.ts new file mode 100644 index 0000000..c005ff6 --- /dev/null +++ b/src/commands/scim-users/deactivate.ts @@ -0,0 +1,12 @@ +import type { ScimClient } from "../../scim-client"; + +interface ScimUsersDeactivateOptions { + id: string; +} + +export async function executeScimUsersDeactivate( + client: ScimClient, + opts: ScimUsersDeactivateOptions, +): Promise { + await client.users.deactivate(opts.id); +} diff --git a/src/commands/scim-users/update.ts b/src/commands/scim-users/update.ts new file mode 100644 index 0000000..967a328 --- /dev/null +++ b/src/commands/scim-users/update.ts @@ -0,0 +1,34 @@ +import type { ScimClient } from "../../scim-client"; +import type { ScimPatchOperation, ScimUser } from "../../scim-types"; + +interface ScimUsersUpdateOptions { + id: string; + active?: boolean; + userName?: string; + email?: string; + givenName?: string; + familyName?: string; + displayName?: string; + title?: string; +} + +export async function executeScimUsersUpdate( + client: ScimClient, + opts: ScimUsersUpdateOptions, +): Promise { + const operations: ScimPatchOperation[] = []; + + if (opts.active !== undefined) operations.push({ op: "replace", path: "active", value: opts.active }); + if (opts.userName !== undefined) operations.push({ op: "replace", path: "userName", value: opts.userName }); + if (opts.email !== undefined) operations.push({ op: "replace", path: "emails", value: [{ value: opts.email, primary: true }] }); + if (opts.givenName !== undefined) operations.push({ op: "replace", path: "name.givenName", value: opts.givenName }); + if (opts.familyName !== undefined) operations.push({ op: "replace", path: "name.familyName", value: opts.familyName }); + if (opts.displayName !== undefined) operations.push({ op: "replace", path: "displayName", value: opts.displayName }); + if (opts.title !== undefined) operations.push({ op: "replace", path: "title", value: opts.title }); + + if (operations.length === 0) { + throw new Error("At least one field to update must be specified"); + } + + return client.users.update(opts.id, operations); +} diff --git a/tests/commands/scim-users/create.test.ts b/tests/commands/scim-users/create.test.ts new file mode 100644 index 0000000..57733a3 --- /dev/null +++ b/tests/commands/scim-users/create.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeScimUsersCreate } from "../../../src/commands/scim-users/create"; + +describe("scim-users create", () => { + test("creates user with required params", async () => { + const created = { id: "U999", userName: "newuser", active: true, emails: [{ value: "new@ex.com", primary: true }], name: { givenName: "", familyName: "" } }; + const mockCreate = mock(() => Promise.resolve(created)); + const client = { users: { create: mockCreate } } as any; + const result = await executeScimUsersCreate(client, { userName: "newuser", email: "new@ex.com" }); + expect(result.id).toBe("U999"); + expect(mockCreate).toHaveBeenCalledWith({ userName: "newuser", email: "new@ex.com" }); + }); + + test("passes optional name fields", async () => { + const created = { id: "U999", userName: "newuser", active: true, emails: [{ value: "new@ex.com", primary: true }], name: { givenName: "New", familyName: "User" } }; + const mockCreate = mock(() => Promise.resolve(created)); + const client = { users: { create: mockCreate } } as any; + await executeScimUsersCreate(client, { userName: "newuser", email: "new@ex.com", givenName: "New", familyName: "User", displayName: "New User" }); + expect(mockCreate).toHaveBeenCalledWith({ userName: "newuser", email: "new@ex.com", givenName: "New", familyName: "User", displayName: "New User" }); + }); +}); diff --git a/tests/commands/scim-users/deactivate.test.ts b/tests/commands/scim-users/deactivate.test.ts new file mode 100644 index 0000000..4ac9675 --- /dev/null +++ b/tests/commands/scim-users/deactivate.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeScimUsersDeactivate } from "../../../src/commands/scim-users/deactivate"; + +describe("scim-users deactivate", () => { + test("calls deactivate with user id", async () => { + const mockDeactivate = mock(() => Promise.resolve(undefined)); + const client = { users: { deactivate: mockDeactivate } } as any; + await executeScimUsersDeactivate(client, { id: "U001" }); + expect(mockDeactivate).toHaveBeenCalledWith("U001"); + }); +}); diff --git a/tests/commands/scim-users/update.test.ts b/tests/commands/scim-users/update.test.ts new file mode 100644 index 0000000..5b13b6f --- /dev/null +++ b/tests/commands/scim-users/update.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeScimUsersUpdate } from "../../../src/commands/scim-users/update"; + +describe("scim-users update", () => { + test("builds replace operations for provided fields", async () => { + const updated = { id: "U001", userName: "alice", active: false, emails: [], name: { givenName: "Alice", familyName: "Smith" } }; + const mockUpdate = mock(() => Promise.resolve(updated)); + const client = { users: { update: mockUpdate } } as any; + await executeScimUsersUpdate(client, { id: "U001", active: false, title: "Engineer" }); + expect(mockUpdate).toHaveBeenCalledWith("U001", [ + { op: "replace", path: "active", value: false }, + { op: "replace", path: "title", value: "Engineer" }, + ]); + }); + + test("handles single field update", async () => { + const updated = { id: "U001", userName: "alice-new", active: true, emails: [], name: { givenName: "Alice", familyName: "Smith" } }; + const mockUpdate = mock(() => Promise.resolve(updated)); + const client = { users: { update: mockUpdate } } as any; + const result = await executeScimUsersUpdate(client, { id: "U001", userName: "alice-new" }); + expect(result.userName).toBe("alice-new"); + expect(mockUpdate).toHaveBeenCalledWith("U001", [ + { op: "replace", path: "userName", value: "alice-new" }, + ]); + }); + + test("throws when no fields provided", async () => { + const client = { users: { update: mock(() => Promise.resolve({})) } } as any; + await expect(executeScimUsersUpdate(client, { id: "U001" })).rejects.toThrow( + "At least one field to update must be specified", + ); + }); +}); From 8fbfb919db12a415ebe410d3ba4d2257566c0c6c Mon Sep 17 00:00:00 2001 From: Mitsuki Ogasahara Date: Thu, 9 Apr 2026 23:41:37 +0900 Subject: [PATCH 07/11] feat: add scim-groups list and get commands Co-Authored-By: Claude Sonnet 4.6 --- src/commands/scim-groups/get.ts | 13 +++++++++++ src/commands/scim-groups/list.ts | 16 +++++++++++++ tests/commands/scim-groups/get.test.ts | 14 ++++++++++++ tests/commands/scim-groups/list.test.ts | 30 +++++++++++++++++++++++++ 4 files changed, 73 insertions(+) create mode 100644 src/commands/scim-groups/get.ts create mode 100644 src/commands/scim-groups/list.ts create mode 100644 tests/commands/scim-groups/get.test.ts create mode 100644 tests/commands/scim-groups/list.test.ts diff --git a/src/commands/scim-groups/get.ts b/src/commands/scim-groups/get.ts new file mode 100644 index 0000000..261c3d1 --- /dev/null +++ b/src/commands/scim-groups/get.ts @@ -0,0 +1,13 @@ +import type { ScimClient } from "../../scim-client"; +import type { ScimGroup } from "../../scim-types"; + +interface ScimGroupsGetOptions { + id: string; +} + +export async function executeScimGroupsGet( + client: ScimClient, + opts: ScimGroupsGetOptions, +): Promise { + return client.groups.get(opts.id); +} diff --git a/src/commands/scim-groups/list.ts b/src/commands/scim-groups/list.ts new file mode 100644 index 0000000..9e8ec0a --- /dev/null +++ b/src/commands/scim-groups/list.ts @@ -0,0 +1,16 @@ +import type { ScimClient } from "../../scim-client"; +import type { ScimGroup } from "../../scim-types"; + +interface ScimGroupsListOptions { + startIndex?: number; + count?: number; + filter?: string; +} + +export async function executeScimGroupsList( + client: ScimClient, + opts: ScimGroupsListOptions, +): Promise { + const response = await client.groups.list(opts); + return response.Resources; +} diff --git a/tests/commands/scim-groups/get.test.ts b/tests/commands/scim-groups/get.test.ts new file mode 100644 index 0000000..67c19aa --- /dev/null +++ b/tests/commands/scim-groups/get.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeScimGroupsGet } from "../../../src/commands/scim-groups/get"; + +describe("scim-groups get", () => { + test("returns group by id", async () => { + const group = { id: "G001", displayName: "Engineering", members: [{ value: "U001", display: "alice" }] }; + const mockGet = mock(() => Promise.resolve(group)); + const client = { groups: { get: mockGet } } as any; + const result = await executeScimGroupsGet(client, { id: "G001" }); + expect(result.id).toBe("G001"); + expect(result.displayName).toBe("Engineering"); + expect(mockGet).toHaveBeenCalledWith("G001"); + }); +}); diff --git a/tests/commands/scim-groups/list.test.ts b/tests/commands/scim-groups/list.test.ts new file mode 100644 index 0000000..1bad72d --- /dev/null +++ b/tests/commands/scim-groups/list.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeScimGroupsList } from "../../../src/commands/scim-groups/list"; + +describe("scim-groups list", () => { + test("returns groups from SCIM API", async () => { + const mockList = mock(() => + Promise.resolve({ + totalResults: 2, itemsPerPage: 100, startIndex: 1, + Resources: [ + { id: "G001", displayName: "Engineering", members: [{ value: "U001" }, { value: "U002" }] }, + { id: "G002", displayName: "Design", members: [{ value: "U003" }] }, + ], + }), + ); + const client = { groups: { list: mockList } } as any; + const result = await executeScimGroupsList(client, {}); + expect(result).toHaveLength(2); + expect(result[0].displayName).toBe("Engineering"); + expect(result[1].id).toBe("G002"); + }); + + test("passes pagination and filter params", async () => { + const mockList = mock(() => + Promise.resolve({ totalResults: 0, itemsPerPage: 10, startIndex: 1, Resources: [] }), + ); + const client = { groups: { list: mockList } } as any; + await executeScimGroupsList(client, { startIndex: 1, count: 10, filter: "displayName eq \"Eng\"" }); + expect(mockList).toHaveBeenCalledWith({ startIndex: 1, count: 10, filter: "displayName eq \"Eng\"" }); + }); +}); From f054f94777bbbcb6ad2dc91c6d8cb192ca6716b6 Mon Sep 17 00:00:00 2001 From: Mitsuki Ogasahara Date: Thu, 9 Apr 2026 23:41:40 +0900 Subject: [PATCH 08/11] feat: add scim-groups create, update, delete commands Co-Authored-By: Claude Sonnet 4.6 --- src/commands/scim-groups/create.ts | 14 +++++++++ src/commands/scim-groups/delete.ts | 12 ++++++++ src/commands/scim-groups/update.ts | 36 +++++++++++++++++++++++ tests/commands/scim-groups/create.test.ts | 21 +++++++++++++ tests/commands/scim-groups/delete.test.ts | 11 +++++++ tests/commands/scim-groups/update.test.ts | 32 ++++++++++++++++++++ 6 files changed, 126 insertions(+) create mode 100644 src/commands/scim-groups/create.ts create mode 100644 src/commands/scim-groups/delete.ts create mode 100644 src/commands/scim-groups/update.ts create mode 100644 tests/commands/scim-groups/create.test.ts create mode 100644 tests/commands/scim-groups/delete.test.ts create mode 100644 tests/commands/scim-groups/update.test.ts diff --git a/src/commands/scim-groups/create.ts b/src/commands/scim-groups/create.ts new file mode 100644 index 0000000..62a9d94 --- /dev/null +++ b/src/commands/scim-groups/create.ts @@ -0,0 +1,14 @@ +import type { ScimClient } from "../../scim-client"; +import type { ScimGroup } from "../../scim-types"; + +interface ScimGroupsCreateOptions { + displayName: string; + memberIds?: string[]; +} + +export async function executeScimGroupsCreate( + client: ScimClient, + opts: ScimGroupsCreateOptions, +): Promise { + return client.groups.create(opts); +} diff --git a/src/commands/scim-groups/delete.ts b/src/commands/scim-groups/delete.ts new file mode 100644 index 0000000..61ba861 --- /dev/null +++ b/src/commands/scim-groups/delete.ts @@ -0,0 +1,12 @@ +import type { ScimClient } from "../../scim-client"; + +interface ScimGroupsDeleteOptions { + id: string; +} + +export async function executeScimGroupsDelete( + client: ScimClient, + opts: ScimGroupsDeleteOptions, +): Promise { + await client.groups.delete(opts.id); +} diff --git a/src/commands/scim-groups/update.ts b/src/commands/scim-groups/update.ts new file mode 100644 index 0000000..c9ff391 --- /dev/null +++ b/src/commands/scim-groups/update.ts @@ -0,0 +1,36 @@ +import type { ScimClient } from "../../scim-client"; +import type { ScimPatchOperation, ScimGroup } from "../../scim-types"; + +interface ScimGroupsUpdateOptions { + id: string; + displayName?: string; + addMemberIds?: string[]; + removeMemberIds?: string[]; +} + +export async function executeScimGroupsUpdate( + client: ScimClient, + opts: ScimGroupsUpdateOptions, +): Promise { + const operations: ScimPatchOperation[] = []; + + if (opts.displayName !== undefined) { + operations.push({ op: "replace", path: "displayName", value: opts.displayName }); + } + if (opts.addMemberIds !== undefined) { + for (const memberId of opts.addMemberIds) { + operations.push({ op: "add", path: "members", value: [{ value: memberId }] }); + } + } + if (opts.removeMemberIds !== undefined) { + for (const memberId of opts.removeMemberIds) { + operations.push({ op: "remove", path: `members[value eq "${memberId}"]` }); + } + } + + if (operations.length === 0) { + throw new Error("At least one field to update must be specified"); + } + + return client.groups.update(opts.id, operations); +} diff --git a/tests/commands/scim-groups/create.test.ts b/tests/commands/scim-groups/create.test.ts new file mode 100644 index 0000000..bc56882 --- /dev/null +++ b/tests/commands/scim-groups/create.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeScimGroupsCreate } from "../../../src/commands/scim-groups/create"; + +describe("scim-groups create", () => { + test("creates group with display name only", async () => { + const created = { id: "G999", displayName: "New Team", members: [] }; + const mockCreate = mock(() => Promise.resolve(created)); + const client = { groups: { create: mockCreate } } as any; + const result = await executeScimGroupsCreate(client, { displayName: "New Team" }); + expect(result.id).toBe("G999"); + expect(mockCreate).toHaveBeenCalledWith({ displayName: "New Team" }); + }); + + test("creates group with members", async () => { + const created = { id: "G999", displayName: "New Team", members: [{ value: "U001" }, { value: "U002" }] }; + const mockCreate = mock(() => Promise.resolve(created)); + const client = { groups: { create: mockCreate } } as any; + await executeScimGroupsCreate(client, { displayName: "New Team", memberIds: ["U001", "U002"] }); + expect(mockCreate).toHaveBeenCalledWith({ displayName: "New Team", memberIds: ["U001", "U002"] }); + }); +}); diff --git a/tests/commands/scim-groups/delete.test.ts b/tests/commands/scim-groups/delete.test.ts new file mode 100644 index 0000000..9ea7c9c --- /dev/null +++ b/tests/commands/scim-groups/delete.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeScimGroupsDelete } from "../../../src/commands/scim-groups/delete"; + +describe("scim-groups delete", () => { + test("calls delete with group id", async () => { + const mockDelete = mock(() => Promise.resolve(undefined)); + const client = { groups: { delete: mockDelete } } as any; + await executeScimGroupsDelete(client, { id: "G001" }); + expect(mockDelete).toHaveBeenCalledWith("G001"); + }); +}); diff --git a/tests/commands/scim-groups/update.test.ts b/tests/commands/scim-groups/update.test.ts new file mode 100644 index 0000000..8362707 --- /dev/null +++ b/tests/commands/scim-groups/update.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeScimGroupsUpdate } from "../../../src/commands/scim-groups/update"; + +describe("scim-groups update", () => { + test("builds replace operation for displayName", async () => { + const updated = { id: "G001", displayName: "New Name", members: [] }; + const mockUpdate = mock(() => Promise.resolve(updated)); + const client = { groups: { update: mockUpdate } } as any; + await executeScimGroupsUpdate(client, { id: "G001", displayName: "New Name" }); + expect(mockUpdate).toHaveBeenCalledWith("G001", [ + { op: "replace", path: "displayName", value: "New Name" }, + ]); + }); + + test("builds add and remove operations for members", async () => { + const updated = { id: "G001", displayName: "Eng", members: [{ value: "U002" }, { value: "U003" }] }; + const mockUpdate = mock(() => Promise.resolve(updated)); + const client = { groups: { update: mockUpdate } } as any; + await executeScimGroupsUpdate(client, { id: "G001", addMemberIds: ["U003"], removeMemberIds: ["U001"] }); + expect(mockUpdate).toHaveBeenCalledWith("G001", [ + { op: "add", path: "members", value: [{ value: "U003" }] }, + { op: "remove", path: "members[value eq \"U001\"]" }, + ]); + }); + + test("throws when no fields provided", async () => { + const client = { groups: { update: mock(() => Promise.resolve({})) } } as any; + await expect(executeScimGroupsUpdate(client, { id: "G001" })).rejects.toThrow( + "At least one field to update must be specified", + ); + }); +}); From 93f1888588a1d4c8f6425d967ab6e7525262b2d6 Mon Sep 17 00:00:00 2001 From: Mitsuki Ogasahara Date: Thu, 9 Apr 2026 23:43:40 +0900 Subject: [PATCH 09/11] feat: wire up all SCIM commands in index.ts Co-Authored-By: Claude Sonnet 4.6 --- src/index.ts | 206 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) diff --git a/src/index.ts b/src/index.ts index b80f269..d4feef9 100755 --- a/src/index.ts +++ b/src/index.ts @@ -82,6 +82,18 @@ import { executeFunctionsList } from "./commands/functions/list"; import { executeFunctionsPermissionsLookup } from "./commands/functions/permissions/lookup"; import { executeFunctionsPermissionsSet } from "./commands/functions/permissions/set"; +import { executeScimUsersList } from "./commands/scim-users/list"; +import { executeScimUsersGet } from "./commands/scim-users/get"; +import { executeScimUsersCreate } from "./commands/scim-users/create"; +import { executeScimUsersUpdate } from "./commands/scim-users/update"; +import { executeScimUsersDeactivate } from "./commands/scim-users/deactivate"; +import { executeScimGroupsList } from "./commands/scim-groups/list"; +import { executeScimGroupsGet } from "./commands/scim-groups/get"; +import { executeScimGroupsCreate } from "./commands/scim-groups/create"; +import { executeScimGroupsUpdate } from "./commands/scim-groups/update"; +import { executeScimGroupsDelete } from "./commands/scim-groups/delete"; +import { createScimClient } from "./scim-client"; + import { ProfileStore } from "./config"; import { createSlackClient } from "./client"; import { formatOutput, type OutputFormat } from "./output"; @@ -740,6 +752,85 @@ const functionsCommands = command( ), ); +// --------------------------------------------------------------------------- +// SCIM Users commands +// --------------------------------------------------------------------------- + +const scimUsersCommands = command( + "scim-users", + or( + command("list", object({ + cmd: constant("scim-users-list" as const), + startIndex: optional(option("--start-index", integer({ metavar: "START_INDEX" }))), + count: optional(option("--count", integer({ metavar: "COUNT" }))), + filter: optional(option("--filter", string({ metavar: "FILTER" }))), + })), + command("get", object({ + cmd: constant("scim-users-get" as const), + id: option("--id", string({ metavar: "USER_ID" })), + })), + command("create", object({ + cmd: constant("scim-users-create" as const), + userName: option("--user-name", string({ metavar: "USER_NAME" })), + email: option("--email", string({ metavar: "EMAIL" })), + givenName: optional(option("--given-name", string({ metavar: "GIVEN_NAME" }))), + familyName: optional(option("--family-name", string({ metavar: "FAMILY_NAME" }))), + displayName: optional(option("--display-name", string({ metavar: "DISPLAY_NAME" }))), + })), + command("update", object({ + cmd: constant("scim-users-update" as const), + id: option("--id", string({ metavar: "USER_ID" })), + active: optional(option("--active", boolValueParser)), + userName: optional(option("--user-name", string({ metavar: "USER_NAME" }))), + email: optional(option("--email", string({ metavar: "EMAIL" }))), + givenName: optional(option("--given-name", string({ metavar: "GIVEN_NAME" }))), + familyName: optional(option("--family-name", string({ metavar: "FAMILY_NAME" }))), + displayName: optional(option("--display-name", string({ metavar: "DISPLAY_NAME" }))), + title: optional(option("--title", string({ metavar: "TITLE" }))), + })), + command("deactivate", object({ + cmd: constant("scim-users-deactivate" as const), + id: option("--id", string({ metavar: "USER_ID" })), + })), + ), +); + +// --------------------------------------------------------------------------- +// SCIM Groups commands +// --------------------------------------------------------------------------- + +const scimGroupsCommands = command( + "scim-groups", + or( + command("list", object({ + cmd: constant("scim-groups-list" as const), + startIndex: optional(option("--start-index", integer({ metavar: "START_INDEX" }))), + count: optional(option("--count", integer({ metavar: "COUNT" }))), + filter: optional(option("--filter", string({ metavar: "FILTER" }))), + })), + command("get", object({ + cmd: constant("scim-groups-get" as const), + id: option("--id", string({ metavar: "GROUP_ID" })), + })), + command("create", object({ + cmd: constant("scim-groups-create" as const), + displayName: option("--display-name", string({ metavar: "DISPLAY_NAME" })), + memberIds: optional(option("--member-ids", string({ metavar: "MEMBER_IDS" }))), + })), + command("update", object({ + cmd: constant("scim-groups-update" as const), + id: option("--id", string({ metavar: "GROUP_ID" })), + displayName: optional(option("--display-name", string({ metavar: "DISPLAY_NAME" }))), + addMemberIds: optional(option("--add-member-ids", string({ metavar: "MEMBER_IDS" }))), + removeMemberIds: optional(option("--remove-member-ids", string({ metavar: "MEMBER_IDS" }))), + })), + command("delete", object({ + cmd: constant("scim-groups-delete" as const), + id: option("--id", string({ metavar: "GROUP_ID" })), + })), + ), +); + // --------------------------------------------------------------------------- // Root parser // --------------------------------------------------------------------------- @@ -748,6 +839,7 @@ const rootParser = or( or(tokenCommands, teamsCommands, usersCommands), or(conversationsCommands, appsCommands), or(inviteRequestsCommands, workflowsCommands, functionsCommands), + or(scimUsersCommands, scimGroupsCommands), ); // --------------------------------------------------------------------------- @@ -1499,6 +1591,120 @@ switch (config.cmd) { console.log("Function permissions updated."); break; } + case "scim-users-list": { + const client = await createScimClient(store, profileFlag); + const users = await executeScimUsersList(client, { + startIndex: config.startIndex, + count: config.count, + filter: config.filter, + }); + const rows = users.map((u) => ({ + id: u.id, + userName: u.userName, + email: u.emails?.find((e) => e.primary)?.value ?? u.emails?.[0]?.value ?? "", + active: String(u.active), + })); + console.log(formatOutput(rows, ["id", "userName", "email", "active"], outputFormat)); + break; + } + case "scim-users-get": { + const client = await createScimClient(store, profileFlag); + const user = await executeScimUsersGet(client, { id: config.id }); + console.log(JSON.stringify(user, null, 2)); + break; + } + case "scim-users-create": { + const client = await createScimClient(store, profileFlag); + const created = await executeScimUsersCreate(client, { + userName: config.userName, + email: config.email, + givenName: config.givenName, + familyName: config.familyName, + displayName: config.displayName, + }); + console.log(JSON.stringify(created, null, 2)); + break; + } + case "scim-users-update": { + const client = await createScimClient(store, profileFlag); + const updated = await executeScimUsersUpdate(client, { + id: config.id, + active: config.active, + userName: config.userName, + email: config.email, + givenName: config.givenName, + familyName: config.familyName, + displayName: config.displayName, + title: config.title, + }); + console.log(JSON.stringify(updated, null, 2)); + break; + } + case "scim-users-deactivate": { + const client = await createScimClient(store, profileFlag); + await executeScimUsersDeactivate(client, { id: config.id }); + console.log(`User '${config.id}' deactivated.`); + break; + } + case "scim-groups-list": { + const client = await createScimClient(store, profileFlag); + const groups = await executeScimGroupsList(client, { + startIndex: config.startIndex, + count: config.count, + filter: config.filter, + }); + const rows = groups.map((g) => ({ + id: g.id, + displayName: g.displayName, + memberCount: String(g.members?.length ?? 0), + })); + console.log(formatOutput(rows, ["id", "displayName", "memberCount"], outputFormat)); + break; + } + case "scim-groups-get": { + const client = await createScimClient(store, profileFlag); + const group = await executeScimGroupsGet(client, { id: config.id }); + console.log(JSON.stringify(group, null, 2)); + break; + } + case "scim-groups-create": { + const client = await createScimClient(store, profileFlag); + let groupMemberIds: string[] | undefined; + if (config.memberIds !== undefined) { + groupMemberIds = config.memberIds.split(","); + } + const created = await executeScimGroupsCreate(client, { + displayName: config.displayName, + memberIds: groupMemberIds, + }); + console.log(JSON.stringify(created, null, 2)); + break; + } + case "scim-groups-update": { + const client = await createScimClient(store, profileFlag); + let groupAddMemberIds: string[] | undefined; + let groupRemoveMemberIds: string[] | undefined; + if (config.addMemberIds !== undefined) { + groupAddMemberIds = config.addMemberIds.split(","); + } + if (config.removeMemberIds !== undefined) { + groupRemoveMemberIds = config.removeMemberIds.split(","); + } + const updated = await executeScimGroupsUpdate(client, { + id: config.id, + displayName: config.displayName, + addMemberIds: groupAddMemberIds, + removeMemberIds: groupRemoveMemberIds, + }); + console.log(JSON.stringify(updated, null, 2)); + break; + } + case "scim-groups-delete": { + const client = await createScimClient(store, profileFlag); + await executeScimGroupsDelete(client, { id: config.id }); + console.log(`Group '${config.id}' deleted.`); + break; + } default: { const _exhaustive: never = config; throw new Error(`Unknown command`); From c423f73ea00b3933d8a75b04c205f2b443957218 Mon Sep 17 00:00:00 2001 From: Mitsuki Ogasahara Date: Thu, 9 Apr 2026 23:44:52 +0900 Subject: [PATCH 10/11] docs: add SCIM commands to README, README_ja, CLAUDE.md Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 6 +++++- README.md | 23 ++++++++++++++++++++++- README_ja.md | 23 ++++++++++++++++++++++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index de925d1..da83647 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,8 @@ bun run lint # TypeScript 型チェック(tsc --noEmit) src/ ├── index.ts # CLI パーサー定義 + コマンドルーティング(switch文) ├── client.ts # WebClient ファクトリ +├── scim-client.ts # ScimClient ファクトリ(SCIM v2 API 用) +├── scim-types.ts # SCIM 型定義 ├── config.ts # プロファイル・トークン管理 ├── output.ts # 出力フォーマッタ(JSON / table / plain) └── commands/ # コマンド実装(グループ/サブコマンドごとにファイル分割) @@ -42,7 +44,9 @@ src/ ├── apps/ ├── invite-requests/ ├── workflows/ - └── functions/ + ├── functions/ + ├── scim-users/ + └── scim-groups/ tests/ # テスト(commands/ 以下と同構造) skills/ # Agent Skill 定義 ``` diff --git a/README.md b/README.md index bc91193..e4be6f9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ CLI & Agent Skill for managing Slack Enterprise Grid / Business+ workspaces via ## Features -- **73+ admin commands** covering 8 API groups: teams, users, conversations, apps, invite-requests, workflows, functions, and token management +- **83+ admin commands** covering 10 API groups: teams, users, conversations, apps, invite-requests, workflows, functions, scim-users, scim-groups, and token management - **Agent Skill** — ships with a Claude Code / Codex skill so AI agents can drive Slack admin tasks using the CLI as a tool - **Bulk operations** — archive, delete, or move hundreds of channels at once with `conversations bulk-*` - **Output formats** — table (human), JSON (programmatic), TSV (pipe-friendly) @@ -182,6 +182,26 @@ sladm teams list --plain # TSV (for scripting) | `conversations restrict-access remove-group` | Remove access group | | `conversations ekm list-original-connected-channel-info` | EKM channel info | +### SCIM Users + +| Command | Description | +|---------|-------------| +| `scim-users list` | List users (SCIM) | +| `scim-users get` | Get user details (SCIM) | +| `scim-users create` | Create user (SCIM) | +| `scim-users update` | Update user attributes (SCIM) | +| `scim-users deactivate` | Deactivate user (SCIM) | + +### SCIM Groups + +| Command | Description | +|---------|-------------| +| `scim-groups list` | List groups (SCIM) | +| `scim-groups get` | Get group details (SCIM) | +| `scim-groups create` | Create group (SCIM) | +| `scim-groups update` | Update group (SCIM) | +| `scim-groups delete` | Delete group (SCIM) | + ### Apps | Command | Description | @@ -242,6 +262,7 @@ sladm teams list --plain # TSV (for scripting) | `admin.invites:write` | Approve/deny invite requests | | `admin.workflows:read` | List workflows and functions | | `admin.workflows:write` | Manage workflows, set permissions | +| `admin` | SCIM user and group management | ## Development diff --git a/README_ja.md b/README_ja.md index df54363..07afd8c 100644 --- a/README_ja.md +++ b/README_ja.md @@ -6,7 +6,7 @@ Slack Enterprise Grid / Business+ ワークスペースの `admin.*` API を操 ## Features -- **73以上の管理コマンド** — teams, users, conversations, apps, invite-requests, workflows, functions, token の8グループをカバー +- **83以上の管理コマンド** — teams, users, conversations, apps, invite-requests, workflows, functions, scim-users, scim-groups, token の10グループをカバー - **Agent Skill** — Claude Code / Codex のスキルとして動作し、AI エージェントが CLI 経由で Slack 管理操作を実行可能 - **一括操作** — `conversations bulk-*` で数百チャンネルのアーカイブ・削除・移動を一発実行 - **出力形式** — テーブル(人間向け)、JSON(プログラム連携)、TSV(パイプ向け) @@ -182,6 +182,26 @@ sladm teams list --plain # TSV 形式(スクリプト連携向け) | `conversations restrict-access remove-group` | アクセスグループ削除 | | `conversations ekm list-original-connected-channel-info` | EKM チャンネル情報 | +### SCIM Users + +| コマンド | 説明 | +|---------|------| +| `scim-users list` | ユーザー一覧(SCIM) | +| `scim-users get` | ユーザー詳細取得(SCIM) | +| `scim-users create` | ユーザー作成(SCIM) | +| `scim-users update` | ユーザー属性更新(SCIM) | +| `scim-users deactivate` | ユーザー無効化(SCIM) | + +### SCIM Groups + +| コマンド | 説明 | +|---------|------| +| `scim-groups list` | グループ一覧(SCIM) | +| `scim-groups get` | グループ詳細取得(SCIM) | +| `scim-groups create` | グループ作成(SCIM) | +| `scim-groups update` | グループ更新(SCIM) | +| `scim-groups delete` | グループ削除(SCIM) | + ### Apps | コマンド | 説明 | @@ -242,6 +262,7 @@ sladm teams list --plain # TSV 形式(スクリプト連携向け) | `admin.invites:write` | 招待リクエスト承認・拒否 | | `admin.workflows:read` | ワークフロー・関数一覧 | | `admin.workflows:write` | ワークフロー管理・権限設定 | +| `admin` | SCIM ユーザー・グループ管理 | ## Development From 4fb240fdfef945faf49d8d2e1c59773ed0d21ba6 Mon Sep 17 00:00:00 2001 From: Mitsuki Ogasahara Date: Thu, 9 Apr 2026 23:45:17 +0900 Subject: [PATCH 11/11] feat: add SCIM skill recipes for scim-users and scim-groups Co-Authored-By: Claude Sonnet 4.6 --- skills/slack-admin-cli-skill/SKILL.md | 6 ++- .../recipes/scim-groups.md | 44 ++++++++++++++++ .../recipes/scim-users.md | 50 +++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 skills/slack-admin-cli-skill/recipes/scim-groups.md create mode 100644 skills/slack-admin-cli-skill/recipes/scim-users.md diff --git a/skills/slack-admin-cli-skill/SKILL.md b/skills/slack-admin-cli-skill/SKILL.md index 034fefa..198dd24 100644 --- a/skills/slack-admin-cli-skill/SKILL.md +++ b/skills/slack-admin-cli-skill/SKILL.md @@ -1,8 +1,8 @@ --- name: slack-admin-cli-skill description: >- - Slack Admin CLI (`sladm`) を使って Slack Admin API を操作する。 - チーム管理・ユーザー管理・チャンネル管理・アプリ管理・ワークフロー管理・招待リクエスト管理・関数管理など、 + Slack Admin CLI (`sladm`) を使って Slack Admin API / SCIM API を操作する。 + チーム管理・ユーザー管理・チャンネル管理・アプリ管理・ワークフロー管理・招待リクエスト管理・関数管理・SCIMユーザー/グループ管理など、 Slack ワークスペースの管理操作を行いたいときに使う。 --- @@ -73,6 +73,8 @@ sladm --profile staging users list | `invite-requests` | ワークスペース招待の承認・拒否 | [invite-requests](recipes/invite-requests.md) | | `workflows` | ワークフロー検索・権限・コラボレーター | [workflows](recipes/workflows.md) | | `functions` | カスタム関数の一覧・権限設定 | [functions](recipes/functions.md) | +| `scim-users` | SCIM ユーザー管理(作成・更新・無効化) | [scim-users](recipes/scim-users.md) | +| `scim-groups` | SCIM グループ管理(作成・更新・削除) | [scim-groups](recipes/scim-groups.md) | ## トラブルシューティング diff --git a/skills/slack-admin-cli-skill/recipes/scim-groups.md b/skills/slack-admin-cli-skill/recipes/scim-groups.md new file mode 100644 index 0000000..0e42b39 --- /dev/null +++ b/skills/slack-admin-cli-skill/recipes/scim-groups.md @@ -0,0 +1,44 @@ +# SCIM Groups 操作 + +SCIM v2.0 API 経由のグループ(IDP グループ)管理。 + +## コマンド一覧 + +| コマンド | 説明 | 必須スコープ | +|---------|------|-------------| +| `scim-groups list` | グループ一覧 | `admin` | +| `scim-groups get` | グループ詳細取得 | `admin` | +| `scim-groups create` | グループ作成 | `admin` | +| `scim-groups update` | グループ更新 | `admin` | +| `scim-groups delete` | グループ削除 | `admin` | + +## 使用例 + +```bash +# グループ一覧 +sladm scim-groups list + +# グループ詳細(メンバー一覧含む) +sladm scim-groups get --id G12345 + +# グループ作成(メンバー付き) +sladm scim-groups create --display-name "Engineering" --member-ids U001,U002,U003 + +# グループ名変更 +sladm scim-groups update --id G12345 --display-name "Platform Engineering" + +# メンバー追加 +sladm scim-groups update --id G12345 --add-member-ids U004,U005 + +# メンバー削除 +sladm scim-groups update --id G12345 --remove-member-ids U001 + +# グループ削除(メンバーはアクティブのまま) +sladm scim-groups delete --id G12345 +``` + +## Tips + +- `delete` はグループのみ削除。メンバーのアカウントには影響しない +- `update` の `--add-member-ids` と `--remove-member-ids` は同時に指定可能 +- 大量メンバー追加は 5,000 件以下に分割することを推奨(Slack 制限) diff --git a/skills/slack-admin-cli-skill/recipes/scim-users.md b/skills/slack-admin-cli-skill/recipes/scim-users.md new file mode 100644 index 0000000..0366362 --- /dev/null +++ b/skills/slack-admin-cli-skill/recipes/scim-users.md @@ -0,0 +1,50 @@ +# SCIM Users 操作 + +SCIM v2.0 API 経由のユーザー管理。組織全体(Org レベル)でのユーザー作成・更新・無効化が可能。 + +## コマンド一覧 + +| コマンド | 説明 | 必須スコープ | +|---------|------|-------------| +| `scim-users list` | ユーザー一覧 | `admin` | +| `scim-users get` | ユーザー詳細取得 | `admin` | +| `scim-users create` | ユーザー作成 | `admin` | +| `scim-users update` | ユーザー属性更新 | `admin` | +| `scim-users deactivate` | ユーザー無効化 | `admin` | + +## 使用例 + +```bash +# ユーザー一覧(最初の50件) +sladm scim-users list --count 50 + +# フィルターで検索 +sladm scim-users list --filter 'userName eq "alice"' + +# ユーザー詳細 +sladm scim-users get --id U12345 + +# ユーザー作成 +sladm scim-users create --user-name alice --email alice@example.com --given-name Alice --family-name Smith + +# ユーザー属性更新 +sladm scim-users update --id U12345 --title "Senior Engineer" --display-name "Alice S." + +# ユーザー無効化(組織全体から deactivate) +sladm scim-users deactivate --id U12345 +``` + +## admin.users との違い + +| 操作 | admin.users | scim-users | +|------|------------|------------| +| ユーザー一覧 | ワークスペース単位(`--team-id` 必須) | 組織全体 | +| ユーザー作成 | `invite`(招待) | `create`(直接作成) | +| ユーザー無効化 | `remove`(ワークスペースから除外のみ) | `deactivate`(組織全体で無効化) | +| プロフィール更新 | 不可 | `update` で可能 | + +## Tips + +- `--filter` は SCIM フィルター構文: `userName eq "alice"`, `active eq "true"` 等 +- `deactivate` は完全削除ではなく無効化。ユーザーレコードは残る +- `update` は指定したフィールドのみ更新(PATCH)。省略したフィールドは変更されない