CtrlK
BlogDocsLog inGet started
Tessl Logo

ai-ecoverse/advanced-skills

Quarantined high-risk skills (browser session capture, WebSocket interception) for Slack and Microsoft Teams. Install only after reviewing the security implications.

76

Quality

96%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

slack-ext-grid.jsskills/slack/scripts/

// slack-ext-grid.js — Pure logic for slack-ext Enterprise Grid admin commands.
//
// This module contains NO `fs`, `path`, or `sliccy:*` imports so it runs in
// the SLICC tst test realm as a plain CommonJS module. All functions are pure
// (no side effects, no I/O) and are consumed by slack-ext.jsh at runtime.
//
// ── Wire facts (verified live 2026-09-22) ─────────────────────────────────────
//
// ENTERPRISE USER METHODS — all in `enterprise.users.admin.*`:
//   enterprise.users.admin.setRestricted  → make MULTI-CHANNEL GUEST
//     verified: ok:true observed from UI; bogus user → user_not_found
//   enterprise.users.admin.setRegular     → make FULL MEMBER
//     verified: ok:true observed from UI; bogus user → user_not_found
//   enterprise.users.admin.setStatus (status=delete) → DEACTIVATE
//     TRAP: status=delete means deactivate (reversible), NOT permanent delete.
//     verified: ok:true observed from UI; bogus user → invalid_user (different
//     from user_not_found — the field is named `user` but Slack validates it
//     differently for this endpoint). Observed _x_reason=deactivateMembers.
//   users.admin.profileDeidentify → GDPR FORGET (IRREVERSIBLE)
//     Real name becomes "Deactivated User", handle becomes deactivateduser<N>,
//     guest flags cleared. CANNOT BE UNDONE. Observed _x_reason=forget-user.
//     This method is in the WORKSPACE-LEVEL `users.admin.*` namespace despite
//     being an org-wide operation.
//   enterprise.users.admin.setUltraRestricted → SINGLE-CHANNEL GUEST
//     ** UNVERIFIED: method exists (bogus user → user_not_found, confirming
//     the endpoint is real) but ok:true was NEVER OBSERVED from the UI.
//     Treat as best-effort; marked UNVERIFIED in SKILL.md and in the code. **
//
// TOKEN: enterprise methods use the ORG-LEVEL xoxc token, found in
//   localStorage['localConfig_v2'].teams['E06V3987PMY'].token
//   (vs workspace-level commands that use teams['T0385CHDU9E'].token).
//   Both are xoxc tokens — same auth mechanism, different org/ws scopes.
//
// AUDIT ATTRIBUTION — IMPORTANT NUANCE:
//   Session-token (xoxc) calls are INDISTINGUISHABLE FROM THE HUMAN'S OWN
//   DIRECT ACTIONS in Slack's channel event history. A concrete case:
//   #aem-fedex (C0C2CUUDWLE) was archived by Zapier at 2026-09-17T00:17:04Z;
//   the channel event log records Lars Trieloff as the actor, not Zapier,
//   because Zapier ran on his user OAuth token. These commands do the same.
//   The Enterprise Audit Logs API (auditlogs:read scope) WOULD record the
//   acting app and distinguish automation from a human click — but
//   admin.audit.* methods return unknown_method (six variants probed, all
//   unknown). There is currently no working API call that distinguishes an
//   xoxc-based script from a human in the channel event log.
//
// CHANNEL COMMANDS — `admin.conversations.*`:
//   admin.conversations.convertToPublic   takes channel_id (verified real)
//   admin.conversations.convertToPrivate  takes channel_id (verified real)
//   PRIVATE CHANNEL INVISIBILITY: after converting to private, the channel
//   becomes invisible to non-members. conversations.info returns channel_not_found,
//   conversations.genericInfo returns ok:true with an EMPTY array, and the edge
//   cache returns the id under failed_ids. This is not an error — handle it by
//   reporting "channel is now private and not visible to this caller".
//
// CHANNEL SEARCH — `admin.conversations.search`:
//   MEASURED DEFECT: the `channel_ids` parameter is SILENTLY IGNORED.
//   Passing channel_ids=C0634KMGW2G (bare string) AND channel_ids=["C0634KMGW2G"]
//   (JSON array) both returned ok:true with the UNFILTERED full list. NEVER use
//   channel_ids as a filter — it silently answers about the wrong channel.
//   Instead: enumerate (with cursor) and filter locally. This code enforces it.
//
// SLACK CONNECT APPROVALS — `conversations.sharedApprovals.list`:
//   Returns pending (and other) Slack Connect invite approvals.
//   Verified: 977 total approvals in the org at time of writing.
//   Paginates via response_metadata.next_cursor.
//
// APP GOVERNANCE:
//   admin.apps.approve / admin.apps.restrict:
//     Either app_id + enterprise_id, OR request_id for a pending install request.
//     SINGLE-USE request_id: approving then restricting the same request_id returns
//     request_already_resolved. To reverse a resolved request, use app_id + enterprise_id.
//   admin.apps.clearResolution  — app_id + enterprise_id
//   admin.apps.permissions.set  — app_id + permission_type (no_one|everyone|named_entities)
//   admin.apps.approved.list / admin.apps.restricted.list — enterprise_id + limit

'use strict';

// ── Parameter builders ─────────────────────────────────────────────────────────
//
// All return plain objects — the caller turns them into FormData / URLSearchParams.
// These are the ONLY call sites that know the exact wire parameter names.

// enterprise.users.admin.setRestricted
// Makes the user a multi-channel guest at the org level.
function buildEgSetRestrictedParams(userId) {
  return { user: userId };
}

// enterprise.users.admin.setRegular
// Promotes the user to a full member at the org level.
function buildEgSetRegularParams(userId) {
  return { user: userId };
}

// enterprise.users.admin.setStatus
// status='delete' = DEACTIVATE (reversible). 'delete' here does NOT mean delete.
// The reactivation status value is NOT KNOWN — do not guess it.
function buildEgSetStatusParams(userId, status) {
  return { user: userId, status: status };
}

// users.admin.profileDeidentify
// IRREVERSIBLE GDPR forget. Real name → "Deactivated User", handle →
// deactivateduser<N>, guest flags cleared. CANNOT BE UNDONE.
function buildDeidentifyParams(userId) {
  return { user: userId };
}

// enterprise.users.admin.setUltraRestricted
// ** UNVERIFIED: endpoint is real (confirmed via probe — bogus user → user_not_found)
// but ok:true was NEVER OBSERVED from the live admin UI session. Do not call
// without confirming the wire format produces the expected result. **
function buildEgSetUltraRestrictedParams(userId) {
  return { user: userId };
}

// admin.conversations.convertToPublic / convertToPrivate
function buildConvertChannelParams(channelId) {
  return { channel_id: channelId };
}

// admin.conversations.search
//
// Observed required/significant parameters (2026-09-22):
//   search_channel_types — changes the result set materially:
//     'all'              — all channels including archived and private
//     'exclude_archived' — non-archived channels only (recommended default)
//     'private'          — private channels only
//     'private_exclude'  — similar to 'all' in observed behaviour
//     'archived'         — archived channels only
//     Note: Lars Trieloff observed omitting this returns invalid_arguments
//     in the UI path; live probe 2026-09-22 returned ok:true without it,
//     suggesting the API defaults internally. Include it explicitly anyway
//     because 'all' vs 'exclude_archived' yields 2072 vs 1515 results —
//     the difference is significant and silent.
//   sort     — valid observed values: 'name', 'member_count', 'created'
//              'last_activity_ts' and 'num_members' return invalid_sort
//   sort_dir — 'asc' | 'desc'
//   query    — may be empty string
//   cursor   — may be empty string
//   limit    — 2..20 (measured 2026-10-02 on E06V3987PMY, query=santander):
//              limit=21 and limit=50 -> invalid_arguments, response_metadata
//              .messages ["[ERROR] must be less than 20 [json-pointer:/limit]"]
//              (20 itself is accepted: the message is off by one. Re-measured
//              15:33-15:45 the same day with query='' so pages can fill:
//              limit=18/19/20 -> 18/19/20 rows + next_cursor; 21 and 100 ->
//              the same message. The max is 20, NOT 19 — do not tighten it to
//              match the wording.);
//              limit=0 -> "[ERROR] must be greater than 1"; limit=1 -> ok:true
//              with ZERO rows and no next_cursor, a silent empty result.
//              Every other param was accepted on its own at limit=5: query ''
//              and 'santander', cursor '', each sort, each sort_dir, each of
//              the 5 search_channel_types; the full CLI set at limit=5 paged
//              via next_cursor with no overlap. limit is the only rejected one.
//              The page size is clamped into 2..20 here. Clamping is safe
//              because the caller pages to completion, so it changes the
//              number of round trips, never the result set.
//
// QUERY SEMANTICS (measured 2026-10-02): the server-side query is a
// word-PREFIX match on the name, split on '-'. query=santander returned 8
// channels with search_channel_types=all but NOT #aem-gruposantander
// (C09U7MX5R7X; renamed #aem-santander later that day);
// query=antander returned 0, query=santander* returned the same 8, and
// *santander* returned 0. A local substring match over a full enumeration
// (empty query) is the only way to find infix matches. query=<channel id>
// finds that channel (40 of 40 on 2026-09-25; C09U7MX5R7X on 2026-10-02).
//
// MEASURED DEFECT: channel_ids is SILENTLY IGNORED. Never pass it.
// Filter results locally; see filterChannels() below.

const VALID_SEARCH_CHANNEL_TYPES = new Set(
  ['all', 'exclude_archived', 'private', 'private_exclude', 'archived']
);
const VALID_CHANNEL_SORT_FIELDS = new Set(['name', 'member_count', 'created']);

// Slack answers invalid_arguments outside 2..20, and a silent empty page at 1.
const CHANNEL_SEARCH_PAGE_MIN = 2;
const CHANNEL_SEARCH_PAGE_MAX = 20;

function clampChannelSearchLimit(limit) {
  const n = typeof limit === 'number' ? limit : parseInt(String(limit === undefined || limit === null ? '' : limit), 10);
  if (!Number.isFinite(n) || n <= 0) return CHANNEL_SEARCH_PAGE_MAX;
  return Math.min(CHANNEL_SEARCH_PAGE_MAX, Math.max(CHANNEL_SEARCH_PAGE_MIN, Math.floor(n)));
}

function buildChannelSearchParams(query, limit, cursor, searchChannelTypes, sort, sortDir) {
  const p = {
    // Empty string is accepted; must be present.
    query: query || '',
    // 2..20 only. The old default of 50 made every call fail (invalid_arguments).
    limit: String(clampChannelSearchLimit(limit)),
    // Observed in UI; omitting silently defaults to something — include explicitly.
    search_channel_types: searchChannelTypes || 'exclude_archived',
    sort: sort || 'name',
    sort_dir: sortDir || 'asc',
    // Empty-string cursor is accepted and must be present once pagination starts.
    cursor: cursor || '',
  };
  return p;
}

// "error (message; message)" from a failed Slack body. Slack explains an
// invalid_arguments in response_metadata.messages (e.g. "[ERROR] must be less
// than 20 [json-pointer:/limit]"); dropping it leaves the error unexplained.
function slackErrorDetail(body) {
  if (!body || typeof body !== 'object') return 'no_response';
  const error = body.error ? String(body.error) : body.ok === true ? 'ok' : 'unknown_error';
  const meta = body.response_metadata || {};
  const notes = []
    .concat(Array.isArray(meta.messages) ? meta.messages : [])
    .concat(Array.isArray(meta.warnings) ? meta.warnings : [])
    .filter((m) => typeof m === 'string' && m);
  return notes.length ? error + ' (' + notes.join('; ') + ')' : error;
}

const CHANNEL_ID_RE = /^C[A-Z0-9]+$/i;

// What to send as the server-side query for a channel-search.
//   - channel id: the id itself (the server finds a channel by its own id);
//   - name text: '' (full enumeration, matched locally as a substring), unless
//     serverQuery is set, which trades infix matches for speed (the server
//     matches word prefixes only, see QUERY SEMANTICS above).
function channelSearchApiQuery(query, serverQuery) {
  if (!query) return '';
  if (CHANNEL_ID_RE.test(query)) return query;
  return serverQuery ? query : '';
}

// Page admin.conversations.search to completion and filter locally.
// `call(method, params)` resolves to a Slack body.
// opts: { query, limit, types, sort, sortDir, serverQuery, max, maxPages, onPage }
//   max      — stop once this many MATCHES are collected (never caps rows
//              fetched before the local match: that dropped matches).
//   maxPages — hard safety stop (default 500 pages = 10,000 channels).
// Returns { matches, fetched, pages, totalCount, complete, error }.
// complete is true only when Slack answered an empty next_cursor.
async function searchChannels(call, opts) {
  const o = opts || {};
  const query = o.query || '';
  const apiQuery = channelSearchApiQuery(query, !!o.serverQuery);
  const max = o.max > 0 ? o.max : 0;
  const maxPages = o.maxPages > 0 ? o.maxPages : 500;
  const matches = [];
  let fetched = 0;
  let pages = 0;
  let totalCount = null;
  let cursor = '';
  while (pages < maxPages) {
    const params = buildChannelSearchParams(apiQuery, o.limit, cursor, o.types, o.sort, o.sortDir);
    const r = await call('admin.conversations.search', params);
    if (!r || typeof r !== 'object' || r.ok !== true || !Array.isArray(r.conversations)) {
      const error =
        r && typeof r === 'object' && r.ok === true ? 'malformed_response' : slackErrorDetail(r);
      return { matches, fetched, pages, totalCount, complete: false, error };
    }
    pages += 1;
    if (totalCount === null && typeof r.total_count === 'number') totalCount = r.total_count;
    fetched += r.conversations.length;
    for (const ch of filterChannels(r.conversations, query)) matches.push(ch);
    const meta = r.response_metadata || {};
    cursor = r.next_cursor || meta.next_cursor || '';
    if (typeof o.onPage === 'function') o.onPage({ pages, fetched, totalCount, matches: matches.length });
    if (!cursor) {
      return { matches: max > 0 ? matches.slice(0, max) : matches, fetched, pages, totalCount, complete: true, error: null };
    }
    if (max > 0 && matches.length >= max) break;
  }
  return { matches: max > 0 ? matches.slice(0, max) : matches, fetched, pages, totalCount, complete: false, error: null };
}

// conversations.sharedApprovals.list
function buildApprovalsListParams(limit, query, cursor) {
  const p = { limit: String(limit || 25), sort: 'date_expire', sort_dir: 'desc' };
  if (query) p.query = query;
  if (cursor) p.cursor = cursor;
  return p;
}

// admin.apps.approve / admin.apps.restrict
// Accepts either a pending-install request_id (I…) or app_id + enterprise_id.
// SINGLE-USE request_id: once resolved, further attempts with the same request_id
// return request_already_resolved. Use app_id + enterprise_id to reverse.
function buildAppApproveRestrictParams(opts) {
  if (opts && opts.requestId) {
    return { request_id: opts.requestId };
  }
  return { app_id: opts.appId, enterprise_id: opts.enterpriseId };
}

// admin.apps.clearResolution
function buildAppClearResolutionParams(appId, enterpriseId) {
  return { app_id: appId, enterprise_id: enterpriseId };
}

// admin.apps.permissions.set
function buildAppPermissionsParams(appId, permissionType) {
  return { app_id: appId, permission_type: permissionType };
}

// admin.apps.approved.list / admin.apps.restricted.list
function buildAppListParams(enterpriseId, limit, cursor) {
  const p = { enterprise_id: enterpriseId, limit: String(limit || 50) };
  if (cursor) p.cursor = cursor;
  return p;
}

// ── ID classifiers ─────────────────────────────────────────────────────────────

// Determine whether input looks like a Slack install-request ID (Ixxxxxxxx)
// or an app ID (Axxxxxxxx). Returns { requestId } or { appId } or null.
function resolveAppOrRequestId(input) {
  if (!input || typeof input !== 'string') return null;
  if (/^I[A-Z0-9]{6,}$/i.test(input)) return { requestId: input };
  if (/^A[A-Z0-9]{6,}$/i.test(input)) return { appId: input };
  return null;
}

// Validate a permission_type value for admin.apps.permissions.set.
// Returns true if the value is one of the observed valid options.
const VALID_PERMISSION_TYPES = new Set(['no_one', 'everyone', 'named_entities']);
function isValidPermissionType(t) {
  return VALID_PERMISSION_TYPES.has(t);
}

// ── Channel search helpers ─────────────────────────────────────────────────────

// Filter a list of channel objects by name substring or exact ID match.
// IMPORTANT: do NOT pass channel IDs to admin.conversations.search (silent defect).
// Use this function to filter the locally-collected results instead.
function filterChannels(channels, query) {
  if (!query) return channels.slice();
  const lq = query.toLowerCase();
  return channels.filter(function (c) {
    return (
      (c.id && c.id.toLowerCase() === lq) ||
      (c.name && c.name.toLowerCase().includes(lq))
    );
  });
}

// Summarize a channel object from admin.conversations.search into a concise form.
function summarizeChannel(c) {
  return {
    id: c.id,
    name: c.name,
    is_private: c.is_private,
    is_archived: c.is_archived,
    member_count: c.member_count,
    external_user_count: c.external_user_count,
    purpose: c.purpose,
    created: c.created,
    creator_id: c.creator_id,
    last_activity_ts: c.last_activity_ts,
  };
}

// ── Approval helpers ──────────────────────────────────────────────────────────

// Summarize a sharedApprovals entry into display form.
function summarizeApproval(a) {
  return {
    id: a.id,
    partner_org: a.connecting_team ? a.connecting_team.name : '',
    channel: a.channel ? a.channel.name : '',
    status: a.status,
    expires: a.date_expire,
  };
}

// ── Slack Connect invite diagnostics and guest invites ────────────────────────
//
// Wire facts, measured 2026-09-29 on an Enterprise Grid, ORG-level xoxc token
// (localConfig_v2.teams[<E id>]), XHR from the app.slack.com page:
//
//   users.admin.inviteBulk — invite a guest to a WORKSPACE.
//     team_id = the workspace id (T…), required on a grid.
//     invites = JSON string [{"email":…,"type":"ultra_restricted","mode":"manual"}]
//     channels = channel id (the web client sends a comma list)
//     ultra_restricted=true, source=invite_modal, mode=manual
//     -> {ok:true, invites:[{email, ok, invite_id:"I…", expiration_ts}]}
//     Top-level ok:true does NOT mean every invite succeeded: check each
//     per-invite ok. Only the single-channel guest type was tested. What
//     expiration_ts expires (the invite, or the guest account) is unverified.
//
//   conversations.revokeSharedInvite — invite_id=I…, channel=C…
//     ORG token -> {ok:true}; sharedApprovals.list then shows the invite with
//     status:"expired" and date_expire ~ now. A WORKSPACE-scoped token (the one
//     in <workspace>.slack.com/admin boot_data) -> team_is_restricted, and
//     nothing changes.
//
//   conversations.sharedApprovals.list rows carry home_user (inviter),
//   away_user (invitee, incl. team_id and profile.email), connecting_team
//   {id, name, domain, requires_sponsorship}, home_date_approve,
//   away_date_approve (0 = that side has NOT approved), approving_user_id,
//   invite_date_created, date_expire, connection_status, status.

// users.admin.inviteBulk, single-channel guest. The only invite type tested.
function buildGuestInviteParams(email, channelId, teamId) {
  return {
    team_id: teamId,
    invites: JSON.stringify([{ email: email, type: 'ultra_restricted', mode: 'manual' }]),
    channels: channelId,
    ultra_restricted: 'true',
    source: 'invite_modal',
    mode: 'manual',
  };
}

// Per-invite results of an inviteBulk response. `failed` counts entries whose
// own ok is not true, plus requested emails the response does not mention.
function summarizeInviteResults(resp, requestedEmails) {
  const rows = (resp && Array.isArray(resp.invites) ? resp.invites : []).map(function (i) {
    return {
      email: i.email,
      ok: i.ok === true,
      invite_id: i.invite_id || null,
      expires: i.expiration_ts || null,
      error: i.ok === true ? null : i.error || 'unknown_error',
    };
  });
  const seen = new Set(rows.map(function (r) { return String(r.email || '').toLowerCase(); }));
  for (const e of requestedEmails || []) {
    if (!seen.has(String(e).toLowerCase())) {
      rows.push({ email: e, ok: false, invite_id: null, expires: null, error: 'missing_from_response' });
    }
  }
  const failed = rows.filter(function (r) { return !r.ok; }).length;
  return { invites: rows, failed: failed, ok: rows.length > 0 && failed === 0 };
}

// conversations.revokeSharedInvite
function buildRevokeSharedInviteParams(inviteId, channelId) {
  return { invite_id: inviteId, channel: channelId };
}

function findApproval(rows, inviteId) {
  return (rows || []).find(function (a) { return a && (a.id === inviteId || a.invite_id === inviteId); }) || null;
}

// Page through sharedApprovals.list until the row for inviteId turns up.
// fetchPage: async (cursor) => list response body. Stops at the first match,
// at the last page, or after maxPages. Returns { row, scanned, pages, error }.
async function findApprovalPaged(fetchPage, inviteId, maxPages) {
  const cap = maxPages || 200;
  let cursor = '';
  let scanned = 0;
  let pages = 0;
  do {
    const r = await fetchPage(cursor);
    if (!r || !r.ok) return { row: null, scanned: scanned, pages: pages, error: (r && r.error) || 'no_response' };
    pages += 1;
    const chunk = Array.isArray(r.approvals) ? r.approvals : [];
    scanned += chunk.length;
    const hit = findApproval(chunk, inviteId);
    if (hit) return { row: hit, scanned: scanned, pages: pages, error: null };
    const meta = r.response_metadata || {};
    cursor = meta.next_cursor || r.next_cursor || '';
  } while (cursor && pages < cap);
  return { row: null, scanned: scanned, pages: pages, error: null };
}

// Diagnostic view of one sharedApprovals row. Built from an allowlist, so
// icon/avatar fields (connecting_team.icon, profile.image_*) never pass through.
function summarizeApprovalDetail(a) {
  const home = a.home_user || {};
  const away = a.away_user || {};
  const awayProfile = away.profile || {};
  const team = a.connecting_team || {};
  const ch = a.channel || {};
  const homeTs = Number(a.home_date_approve) || 0;
  const awayTs = Number(a.away_date_approve) || 0;
  let waitingOn = 'nobody';
  if (!homeTs && !awayTs) waitingOn = 'both sides';
  else if (!awayTs) waitingOn = 'other org';
  else if (!homeTs) waitingOn = 'our side';
  return {
    id: a.id,
    status: a.status,
    connection_status: a.connection_status,
    channel: { id: ch.id || null, name: ch.name || null },
    inviter: { id: home.id || null, real_name: home.real_name || null },
    invitee: {
      id: away.id || null,
      real_name: away.real_name || null,
      email: awayProfile.email || null,
      team_id: away.team_id || null,
    },
    connecting_team: {
      id: team.id || null,
      name: team.name || null,
      domain: team.domain || null,
      requires_sponsorship: team.requires_sponsorship === undefined ? null : !!team.requires_sponsorship,
    },
    home_approved: homeTs || 0,
    away_approved: awayTs || 0,
    approving_user_id: a.approving_user_id || null,
    created: a.invite_date_created || null,
    expires: a.date_expire || null,
    waiting_on: waitingOn,
  };
}

// ── User type classification ───────────────────────────────────────────────────
//
// Mirrors userTypeLabel() in slack-ext.jsh. Reproduced here so tests can exercise
// the flag interpretation without importing the main script (which has sliccy:* deps).

function classifyUser(user) {
  if (!user) return 'unknown';
  if (user.deleted) return 'deactivated';
  if (user.is_bot) return 'bot';
  if (user.is_ultra_restricted) return 'single-channel guest';
  if (user.is_restricted) return 'multi-channel guest';
  return 'regular';
}

// ── Pagination helper (pure, injectable fetch) ────────────────────────────────
//
// Collects all pages from a cursor-paginaged Slack endpoint.
// fetchPage: async (cursor) => { ok, items, next_cursor, error }
// itemsKey: the response field that holds the array (e.g. 'conversations', 'approvals')
// maxItems: hard cap (0 = unlimited)
//
// Returns { items, total_fetched, pages, error? }
async function collectPages(fetchPage, itemsKey, maxItems) {
  const all = [];
  let cursor = '';
  let pages = 0;
  const cap = maxItems || 0;
  do {
    const r = await fetchPage(cursor);
    if (!r.ok) {
      return { items: all, total_fetched: all.length, pages: pages, error: r.error };
    }
    pages += 1;
    const chunk = r[itemsKey] || [];
    for (const item of chunk) {
      all.push(item);
      if (cap > 0 && all.length >= cap) break;
    }
    // next_cursor may be in response_metadata or directly on the body
    const meta = r.response_metadata || {};
    cursor = meta.next_cursor || r.next_cursor || '';
    if (cap > 0 && all.length >= cap) break;
  } while (cursor);
  return { items: all, total_fetched: all.length, pages: pages };
}

// ── Channel archive / unarchive (`channel-archive`, `channel-unarchive`) ──────
//
// Wire facts, measured live 2026-09-25 on E06V3987PMY:
//
//   admin.conversations.archive / admin.conversations.unarchive take
//   `channel_id` and work with the ORG-level xoxc token (the same one
//   channel-to-public uses), including on PRIVATE channels the admin is not a
//   member of. They answer {ok:true} with no warning, so ok:true is not proof:
//   the state is read back afterwards.
//
//   State is read from admin.conversations.search, because conversations.info
//   answers channel_not_found for a private channel the admin is not in.
//     - limit must be <= 20. limit=21 answers invalid_arguments.
//     - search_channel_types=all covers public, private and archived channels.
//       private_archive answers invalid_search_channel_type.
//     - channel_ids is silently ignored (see CHANNEL SEARCH above).
//     - query=<channel id> finds the channel: measured on a 40-channel sample
//       (public, private, archived, ext-shared; private non-member included),
//       40 of 40 found, each as the only hit. A partial id matches nothing and
//       a bogus id answers zero results. The hit is still matched on `id`
//       locally, and a miss falls back to conversations.info -> name -> query.
//     - member_count is -1 for every archived channel in that sample, so -1 is
//       UNKNOWN, never a count. null/undefined are unknown too.
//     - last_activity_ts is MICROSECONDS (16 digits), e.g. 1686690712432979.
//     - conversation_host_id is only present on ext-shared channels. For a
//       channel this org hosts it equals the org id (E06V3987PMY).
//
//   THE SEARCH INDEX LAGS A WRITE. Right after an archive the channel was not
//   yet reported archived; about 30 s later it was. The live round trip for
//   this command (2026-09-25, C0634KMGW2G) measured both directions:
//     unarchive: index showed is_archived:false on the 2nd read, ~5 s after.
//     archive:   still is_archived:false, then MISSING from the index
//                entirely, then is_archived:true between 38 s and 51 s after
//                the write (conversations.info said archived at once).
//   The read-back therefore retries (READBACK_ATTEMPTS, READBACK_DELAY_MS
//   apart: t = 0..90 s), treats "not in the index" as a miss, and reports
//   "unconfirmed", not "failed", when it runs out.

const CHANNEL_SEARCH_MAX_LIMIT = 20;
const CHANNEL_LOOKUP_MAX_PAGES = 5;
const READBACK_ATTEMPTS = 10;
const READBACK_DELAY_MS = 10000;
const DAY_MS = 86400000;

// admin.conversations.archive / admin.conversations.unarchive
function buildArchiveChannelParams(channelId) {
  return { channel_id: channelId };
}

// admin.conversations.search for a single-channel state read. Never above the
// limit of 20, always search_channel_types=all so archived and private
// channels are both visible.
function buildChannelLookupParams(query, cursor) {
  return {
    query: query || '',
    limit: String(CHANNEL_SEARCH_MAX_LIMIT),
    search_channel_types: 'all',
    sort: 'name',
    sort_dir: 'asc',
    cursor: cursor || '',
  };
}

// last_activity_ts -> epoch milliseconds, or null when unknown.
// The measured wire format is microseconds. Seconds and milliseconds are
// recognised by magnitude so that a format change can never turn a recent
// channel into one that looks idle since 1970 (which would pass an idle guard).
function lastActivityToMs(ts) {
  let n = ts;
  if (typeof n === 'string') {
    if (!/^\d+(\.\d+)?$/.test(n.trim())) return null;
    n = Number(n);
  }
  if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return null;
  if (n >= 1e14) return Math.floor(n / 1000); // microseconds (measured)
  if (n >= 1e11) return Math.floor(n); // milliseconds
  if (n >= 1e8) return Math.floor(n * 1000); // seconds (Slack message ts form)
  return null;
}

// Whole days between lastMs and nowMs, or null when lastMs is unknown.
function idleDaysSince(lastMs, nowMs) {
  if (lastMs === null || lastMs === undefined) return null;
  const d = Math.floor((nowMs - lastMs) / DAY_MS);
  return d < 0 ? 0 : d;
}

// A member / external-user count is only a count when it is a non-negative
// integer. null, undefined, NaN, Infinity and ANY negative value are UNKNOWN
// and must never be read as zero. Measured 2026-09-25: an archived channel
// reports member_count: -1 (not null), so a plain `members <= max` check
// would PASS it. Everything downstream compares only the value returned here.
function knownCount(n) {
  return typeof n === 'number' && Number.isInteger(n) && n >= 0 ? n : null;
}

// Evidence of Slack Connect sharing in a row whose is_ext_shared and
// is_pending_ext_shared are both false. Measured: every non-ext-shared channel
// in a 40-channel sample had no conversation_host_id, and the non-shared
// channels read had connected_team_ids null and 0 external users, so on a
// consistent row this is empty. Returns a list of human-readable reasons.
function sharingConflicts(c, orgId) {
  const out = [];
  const ext = knownCount(c.external_user_count);
  if (ext !== null && ext > 0) out.push('external_user_count ' + ext);
  const connected = externalTeamIds(c.connected_team_ids, c, orgId);
  if (connected && connected.length) out.push('external connected_team_ids ' + connected.join(','));
  const pending = externalTeamIds(c.pending_connected_team_ids, c, orgId);
  if (pending && pending.length) out.push('external pending_connected_team_ids ' + pending.join(','));
  if (c.conversation_host_id) out.push('conversation_host_id ' + c.conversation_host_id);
  return out;
}

// 'not-shared' | 'us' | 'other' | 'unknown' | 'sharing-unknown'
// 'sharing-unknown': is_ext_shared or is_pending_ext_shared is missing or not a
// boolean. This is an undocumented endpoint; an absent flag must never be read
// as "not shared", or a confirmed archive would skip every Slack Connect guard
// and disconnect external organisations without --allow-shared.
function classifyChannelHost(c, orgId) {
  if (typeof c.is_ext_shared !== 'boolean' || typeof c.is_pending_ext_shared !== 'boolean') {
    return 'sharing-unknown';
  }
  const shared = c.is_ext_shared === true || c.is_pending_ext_shared === true;
  // Both flags false is trusted only if nothing else in the same row contradicts
  // it; otherwise a confirmed archive would skip --allow-shared and could cut
  // partner organisations off irreversibly.
  if (!shared) return sharingConflicts(c, orgId).length ? 'sharing-unknown' : 'not-shared';
  const host = c.conversation_host_id;
  if (!host) return 'unknown';
  return host === orgId ? 'us' : 'other';
}

// External organisations connected to a channel: connected_team_ids minus this
// org and its own workspaces (internal_team_ids, context_team_id). Measured:
// connected_team_ids is e.g. ["T038ARP0G", "E06V3987PMY"] for a channel we
// host with one partner.
// A missing or non-array list is UNKNOWN: returns null, never [] (an empty list
// would claim "0 external organisations" and understate what an archive cuts off).
// A malformed internal_team_ids can only overstate the external count.
function externalTeamIds(ids, c, orgId) {
  if (!Array.isArray(ids)) return null;
  const own = new Set([orgId]);
  for (const t of Array.isArray(c.internal_team_ids) ? c.internal_team_ids : []) own.add(t);
  if (c.context_team_id) own.add(c.context_team_id);
  const out = [];
  for (const t of ids) {
    if (t && !own.has(t) && !out.includes(t)) out.push(t);
  }
  return out;
}

// Normalise one admin.conversations.search entry into the fields the guards
// and the output need. Unknowns are null, never a default.
function normalizeChannelState(c, orgId, nowMs) {
  const lastMs = lastActivityToMs(c.last_activity_ts);
  return {
    id: c.id,
    name: c.name || null,
    is_private: c.is_private === true,
    is_archived: typeof c.is_archived === 'boolean' ? c.is_archived : null,
    member_count: knownCount(c.member_count),
    member_count_raw: c.member_count === undefined ? null : c.member_count,
    external_user_count: knownCount(c.external_user_count),
    is_ext_shared: c.is_ext_shared === true,
    is_pending_ext_shared: c.is_pending_ext_shared === true,
    is_org_shared: c.is_org_shared === true,
    conversation_host_id: c.conversation_host_id || null,
    host: classifyChannelHost(c, orgId),
    sharing_conflicts:
      typeof c.is_ext_shared === 'boolean' && typeof c.is_pending_ext_shared === 'boolean' &&
      !c.is_ext_shared && !c.is_pending_ext_shared
        ? sharingConflicts(c, orgId)
        : [],
    // Read from the RAW search entry: channel-search's summarizeChannel drops
    // is_ext_shared, is_pending_ext_shared, conversation_host_id and these.
    external_team_ids: externalTeamIds(c.connected_team_ids, c, orgId),
    pending_external_team_ids: externalTeamIds(c.pending_connected_team_ids, c, orgId),
    last_activity_ts: c.last_activity_ts === undefined ? null : c.last_activity_ts,
    last_activity_ms: lastMs,
    last_activity_date: lastMs === null ? null : new Date(lastMs).toISOString().slice(0, 10),
    idle_days: idleDaysSince(lastMs, nowMs),
  };
}

// What archiving a Slack Connect channel we host does to its partners, in
// plain words. Measured 2026-09-25 on 5 of 5 channels: after
// admin.conversations.archive the channel reads is_ext_shared:false,
// is_pending_ext_shared:false, external_user_count:0, connected_team_ids:[].
// Unarchiving is inferred (not tested) NOT to restore the connections; that
// takes a new Slack Connect invitation. Returns null for a channel that is not
// ext-shared or pending.
function sharedArchiveImpact(state) {
  if (!state || state.host === 'not-shared' || state.host === 'sharing-unknown') return null;
  const users = state.external_user_count;
  const orgs = state.external_team_ids; // null = unknown
  const pending = state.pending_external_team_ids; // null = unknown
  const usersText =
    users === null ? 'an unknown number of external users' : users + ' external user' + (users === 1 ? '' : 's');
  const orgsText =
    orgs === null
      ? 'an unknown number of external organisations (Slack did not report connected_team_ids; ' +
        'the count could not be determined)'
      : orgs.length + ' external organisation' + (orgs.length === 1 ? '' : 's') +
        (orgs.length ? ' (' + orgs.join(', ') + ')' : '');
  let text = 'Archiving will disconnect ' + usersText + ' from ' + orgsText + '.';
  if (pending === null) {
    text += ' It may also end pending Slack Connect invitations: Slack did not report ' +
      'pending_connected_team_ids, so their number could not be determined.';
  } else if (pending.length) {
    text += ' It also ends ' + pending.length + ' pending Slack Connect invitation' +
      (pending.length === 1 ? '' : 's') + ' (' + pending.join(', ') + ').';
  }
  text += ' Unarchiving will NOT reconnect them: that needs a new Slack Connect invitation.';
  return {
    external_users: users,
    external_team_ids: orgs === null ? null : orgs.slice(),
    pending_external_team_ids: pending === null ? null : pending.slice(),
    reversible: false,
    text: text,
  };
}

function guardResult(outcome, reason, detail) {
  return { outcome: outcome, reason: reason, detail: detail };
}

// The guards, in order. action is 'archive' or 'unarchive'.
// outcome: 'proceed' | 'noop' (exit 0, nothing to do) | 'refuse' (exit 1).
// opts: { orgId, maxMembers, minIdleDays, allowShared } — maxMembers and
// minIdleDays are null/undefined when the flag was not given.
function evaluateChannelGuards(action, state, opts) {
  const o = opts || {};
  if (!state) {
    return guardResult(
      'refuse',
      'not-found',
      'no channel with this id in admin.conversations.search (search_channel_types=all)'
    );
  }
  if (state.is_archived === null) {
    return guardResult('refuse', 'archived-unknown', 'Slack did not report is_archived');
  }
  if (action === 'archive' && state.is_archived) {
    return guardResult('noop', 'already-archived', 'the channel is already archived');
  }
  if (action === 'unarchive' && !state.is_archived) {
    return guardResult('noop', 'not-archived', 'the channel is not archived');
  }
  if (state.host === 'sharing-unknown') {
    const why = state.sharing_conflicts && state.sharing_conflicts.length
      ? 'is_ext_shared and is_pending_ext_shared are false, but the same row reports ' +
        state.sharing_conflicts.join('; ')
      : 'Slack did not report is_ext_shared / is_pending_ext_shared as booleans';
    return guardResult(
      'refuse',
      'sharing-unknown',
      why + '; cannot tell whether archiving would disconnect external organisations'
    );
  }
  if (state.host === 'other') {
    return guardResult(
      'refuse',
      'ext-shared-hosted-elsewhere',
      'ext-shared channel hosted by ' + state.conversation_host_id + ', not by ' + o.orgId
    );
  }
  if (state.host === 'unknown') {
    return guardResult(
      'refuse',
      'ext-shared-host-unknown',
      'ext-shared channel with no conversation_host_id; cannot tell who hosts it'
    );
  }
  if (action === 'archive') {
    if (state.host === 'us' && !o.allowShared) {
      return guardResult(
        'refuse',
        'ext-shared-requires-allow-shared',
        'Slack Connect channel hosted by this org. ' + sharedArchiveImpact(state).text +
          ' Pass --allow-shared to archive it anyway'
      );
    }
    if (o.maxMembers !== null && o.maxMembers !== undefined) {
      if (state.member_count === null) {
        return guardResult(
          'refuse',
          'members-unknown',
          'member count unknown (Slack reported ' + JSON.stringify(state.member_count_raw) +
            '); --max-members=' + o.maxMembers + ' cannot be checked'
        );
      }
      if (state.member_count > o.maxMembers) {
        return guardResult(
          'refuse',
          'members-over-limit',
          state.member_count + ' members > --max-members=' + o.maxMembers
        );
      }
    }
    if (o.minIdleDays !== null && o.minIdleDays !== undefined) {
      if (state.idle_days === null) {
        return guardResult(
          'refuse',
          'activity-unknown',
          'last activity unknown (last_activity_ts=' + JSON.stringify(state.last_activity_ts) +
            '); --min-idle-days=' + o.minIdleDays + ' cannot be checked'
        );
      }
      if (state.idle_days < o.minIdleDays) {
        return guardResult(
          'refuse',
          'active-recently',
          'idle ' + state.idle_days + ' days < --min-idle-days=' + o.minIdleDays
        );
      }
    }
  }
  return guardResult('proceed', null, 'all guards hold');
}

// Find one channel by id. `call(method, params)` resolves to a Slack body.
// 1. admin.conversations.search query=<id>, matched on id locally.
// 2. On a miss: take the name from opts.name, else from conversations.info
//    (public channels and channels the admin is in), and search by name.
// Returns { found, channel, via, error }. A miss is { found:false } and the
// caller refuses as not-found.
//
// "Not found" is only reported after a COMPLETE search. Anything short of that
// is an error, never a miss (channel-search has shipped both of these as a
// false "no channels matched": --max dropping matches, and --json printing no
// JSON on zero results):
//   - no body, ok:false, or ok:true without a conversations array
//     -> error (no_response / the Slack error / malformed_response)
//   - the page cap reached while a next_cursor is still pending
//     -> error lookup_truncated (the rest was never looked at)
//   - conversations.info failing with anything but channel_not_found
//     -> error (the name fallback never ran)
//   - conversations.info ok:true without this channel's id and a string name
//     -> error conversations.info: malformed_response
// This helper never calls channel-search and has no --max-style cap that can
// end a search early and call it empty.
async function lookupChannel(call, channelId, opts) {
  const o = opts || {};
  const maxPages = o.maxPages || CHANNEL_LOOKUP_MAX_PAGES;
  async function searchFor(query) {
    let cursor = '';
    for (let page = 0; page < maxPages; page += 1) {
      const r = await call('admin.conversations.search', buildChannelLookupParams(query, cursor));
      if (!r || typeof r !== 'object') return { error: 'no_response' };
      // Only the boolean true is success. A truthy non-boolean (e.g. ok:"false")
      // is malformed: never trust channel state from it (writes are strict too).
      if (r.ok === false) return { error: r.error || 'no_response' };
      if (r.ok !== true) return { error: 'malformed_response' };
      if (!Array.isArray(r.conversations)) return { error: 'malformed_response' };
      const hit = r.conversations.find((c) => c && c.id === channelId);
      if (hit) return { channel: hit };
      const meta = r.response_metadata || {};
      cursor = r.next_cursor || meta.next_cursor || '';
      if (!cursor) return { channel: null };
    }
    return { error: 'lookup_truncated' };
  }
  const byId = await searchFor(channelId);
  if (byId.error) return { found: false, channel: null, via: 'id', error: byId.error };
  if (byId.channel) return { found: true, channel: byId.channel, via: 'id' };
  let name = o.name || null;
  if (!name) {
    const info = await call('conversations.info', { channel: channelId });
    if (!info || typeof info !== 'object') {
      return { found: false, channel: null, via: 'id', error: 'no_response' };
    }
    if (info.ok === true) {
      // ok:true must carry THIS channel with a usable name; anything else is an
      // incomplete read, never a reason to report not-found.
      const ch = info.channel;
      if (!ch || typeof ch !== 'object' || ch.id !== channelId || typeof ch.name !== 'string' || !ch.name) {
        return { found: false, channel: null, via: 'id', error: 'conversations.info: malformed_response' };
      }
      name = ch.name;
    } else if (info.ok !== false) {
      // ok is neither true nor false: malformed, not a miss.
      return { found: false, channel: null, via: 'id', error: 'conversations.info: malformed_response' };
    } else if (info.error !== 'channel_not_found') {
      // channel_not_found is the expected answer for a private channel the
      // admin is not in. Anything else means the fallback did not run.
      return { found: false, channel: null, via: 'id', error: 'conversations.info: ' + (info.error || 'no_response') };
    }
  }
  if (!name) return { found: false, channel: null, via: 'id' };
  const byName = await searchFor(name);
  if (byName.error) return { found: false, channel: null, via: 'name', error: byName.error };
  if (byName.channel) return { found: true, channel: byName.channel, via: 'name' };
  return { found: false, channel: null, via: 'name' };
}

// Poll until the search index reports is_archived === wantArchived.
// Returns { confirmed, attempts, channel, error }.
async function readBackArchived(call, channelId, wantArchived, opts) {
  const o = opts || {};
  const attempts = o.attempts || READBACK_ATTEMPTS;
  const delayMs = o.delayMs === undefined ? READBACK_DELAY_MS : o.delayMs;
  const sleep = o.sleep;
  let last = null;
  for (let i = 1; i <= attempts; i += 1) {
    last = await lookupChannel(call, channelId, { name: o.name });
    if (last.found && last.channel.is_archived === wantArchived) {
      return { confirmed: true, attempts: i, channel: last.channel };
    }
    if (i < attempts) await sleep(delayMs);
  }
  return {
    confirmed: false,
    attempts: attempts,
    channel: last && last.found ? last.channel : null,
    error: last ? last.error || null : null,
  };
}

// The whole channel-archive / channel-unarchive flow, with every side effect
// injected: call(method, params), sleep(ms), now() -> epoch ms.
//
// Dry run:   read -> guards -> report. Never writes.
// --confirm: read (this IS the pre-write re-check: nothing sits between it and
//            the write) -> guards -> write -> read-back with retries.
//
// result.exitCode: 0 dry run / noop / confirmed, 1 refused / read or write
// error, 3 written but the read-back never saw the new state (unconfirmed).
async function runChannelArchiveFlow(spec) {
  const action = spec.action;
  const method = action === 'archive' ? 'admin.conversations.archive' : 'admin.conversations.unarchive';
  const wantArchived = action === 'archive';
  const guardOpts = {
    orgId: spec.orgId,
    maxMembers: spec.maxMembers,
    minIdleDays: spec.minIdleDays,
    allowShared: spec.allowShared === true,
  };
  const result = {
    action: action,
    channel_id: spec.channelId,
    org: spec.orgId,
    mode: spec.confirm ? 'confirm' : 'dry-run',
    method: method,
    state: null,
    lookup_via: null,
    decision: null,
    write: null,
    readback: null,
    status: null,
    exitCode: 0,
  };

  const found = await lookupChannel(spec.call, spec.channelId);
  if (found.error) {
    result.status = 'read-error';
    result.error = 'channel lookup failed (' + found.error + '): whether the channel exists is UNKNOWN, so nothing was changed';
    result.exitCode = 1;
    return result;
  }
  result.lookup_via = found.found ? found.via : null;
  result.state = found.found ? normalizeChannelState(found.channel, spec.orgId, spec.now()) : null;
  result.decision = evaluateChannelGuards(action, result.state, guardOpts);
  result.impact = action === 'archive' ? sharedArchiveImpact(result.state) : null;

  if (!spec.confirm) {
    result.status = 'dry-run';
    return result;
  }
  if (result.decision.outcome === 'noop') {
    result.status = result.decision.reason;
    return result;
  }
  if (result.decision.outcome === 'refuse') {
    result.status = 'refused';
    result.exitCode = 1;
    return result;
  }

  const w = await spec.call(method, buildArchiveChannelParams(spec.channelId));
  // Only ok === true is a successful write; a missing body, ok:false or a
  // non-boolean ok (e.g. "false") is an error.
  const wrote = !!w && typeof w === 'object' && w.ok === true;
  result.write = {
    method: method,
    ok: wrote,
    error: wrote ? null : !w || typeof w !== 'object' ? 'no_response' : w.ok === false ? w.error || 'no_response' : 'malformed_response',
  };
  if (!result.write.ok) {
    result.status = 'error';
    result.exitCode = 1;
    return result;
  }

  const rb = await readBackArchived(spec.call, spec.channelId, wantArchived, {
    attempts: spec.readbackAttempts,
    delayMs: spec.readbackDelayMs,
    sleep: spec.sleep,
    name: result.state.name,
  });
  const verb = action === 'archive' ? 'archived' : 'unarchived';
  result.readback = {
    confirmed: rb.confirmed,
    attempts: rb.attempts,
    state: rb.channel ? normalizeChannelState(rb.channel, spec.orgId, spec.now()) : null,
    error: rb.error || null,
  };
  if (rb.confirmed) {
    result.status = verb + ' (confirmed)';
  } else {
    result.status =
      verb + ' (unconfirmed: search index did not reflect it after ' + rb.attempts + ' attempts)';
    result.exitCode = 3;
  }
  return result;
}

// ── Argument validation for channel-archive / channel-unarchive ──────────────
//
// Fail CLOSED on anything the command does not understand. parseArgv keeps
// unknown flags, so a typo such as --max-member=2 used to leave the real guard
// unset (null) and let a confirmed archive proceed without the guard the
// operator believed was active. Every flag name is checked against an
// explicit allow-list, every guard value must parse, and a stray positional
// word (e.g. `max-members=2` without dashes) is refused too.

// Global flags this CLI reads (see main(), resolveWorkspace, resolveOrg).
const CHANNEL_GLOBAL_FLAGS = ['ws', 'workspace', 'org', 'json', 'confirm', 'help', 'h'];
const CHANNEL_COMMAND_FLAGS = {
  archive: ['max-members', 'min-idle-days', 'allow-shared'],
  unarchive: [],
};
const ARCHIVE_ONLY_FLAGS = CHANNEL_COMMAND_FLAGS.archive;

function editDistance(a, b) {
  const m = a.length;
  const n = b.length;
  const prev = [];
  for (let j = 0; j <= n; j += 1) prev[j] = j;
  for (let i = 1; i <= m; i += 1) {
    let diag = prev[0];
    prev[0] = i;
    for (let j = 1; j <= n; j += 1) {
      const tmp = prev[j];
      prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
      diag = tmp;
    }
  }
  return prev[n];
}

// Closest allowed flag, or null. Hyphens are ignored so --allowshared finds
// --allow-shared; otherwise at most 3 edits.
function suggestFlag(name, allowed) {
  const bare = (s) => String(s).toLowerCase().replace(/[-_]/g, '');
  let best = null;
  let bestD = Infinity;
  for (const cand of allowed) {
    if (cand.length < 2) continue;
    const d = bare(name) === bare(cand) ? 0 : editDistance(String(name).toLowerCase(), cand);
    if (d < bestD) {
      best = cand;
      bestD = d;
    }
  }
  return bestD <= 3 ? best : null;
}

// A guard count: a plain non-negative integer, nothing else.
// undefined -> { ok:true, value:null } (flag not given).
// true (valueless), '', 'abc', '-3', '2.5', '1e3', ' 2' -> { ok:false }.
function parseGuardCount(raw) {
  if (raw === undefined) return { ok: true, value: null };
  if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return { ok: false, value: null };
  const n = Number(raw);
  if (!Number.isSafeInteger(n)) return { ok: false, value: null };
  return { ok: true, value: n };
}

// Validate everything the command was given, before any Slack call.
// flags: parsed flag object; positional: all positional words, i.e.
// [command, channel_id, ...anything else]. Anything after the channel id is refused.
// Returns { ok, errors: [{ code, flag?, suggestion?, message }], maxMembers, minIdleDays }.
function checkChannelArchiveArgs(action, flags, positional) {
  const allowed = CHANNEL_GLOBAL_FLAGS.concat(CHANNEL_COMMAND_FLAGS[action] || []);
  const errors = [];
  for (const name of Object.keys(flags || {})) {
    if (allowed.includes(name)) continue;
    if (action === 'unarchive' && ARCHIVE_ONLY_FLAGS.includes(name)) {
      errors.push({
        code: 'archive-only-flag',
        flag: '--' + name,
        message: 'archive-only-flag: --' + name + ' applies to channel-archive only',
      });
      continue;
    }
    const suggestion = suggestFlag(name, allowed);
    errors.push({
      code: 'unknown-flag',
      flag: '--' + name,
      suggestion: suggestion ? '--' + suggestion : null,
      message:
        'unknown-flag: --' + name + (suggestion ? ' (did you mean --' + suggestion + '?)' : ''),
    });
  }
  const extra = (positional || []).slice(2);
  for (const word of extra) {
    errors.push({
      code: 'unexpected-argument',
      message: 'unexpected-argument: "' + word + '" (flags need a leading --)',
    });
  }
  let maxMembers = null;
  let minIdleDays = null;
  if (action === 'archive') {
    for (const name of ['max-members', 'min-idle-days']) {
      const r = parseGuardCount(flags ? flags[name] : undefined);
      if (!r.ok) {
        const shown = flags[name] === true ? '(no value)' : JSON.stringify(flags[name]);
        errors.push({
          code: 'invalid-value',
          flag: '--' + name,
          message: 'invalid-value: --' + name + ' needs a non-negative integer, got ' + shown,
        });
      } else if (name === 'max-members') {
        maxMembers = r.value;
      } else {
        minIdleDays = r.value;
      }
    }
  }
  return { ok: errors.length === 0, errors: errors, maxMembers: maxMembers, minIdleDays: minIdleDays };
}

// ── Exports ────────────────────────────────────────────────────────────────────

module.exports = {
  // Parameter builders
  buildEgSetRestrictedParams,
  buildEgSetRegularParams,
  buildEgSetStatusParams,
  buildDeidentifyParams,
  buildEgSetUltraRestrictedParams, // UNVERIFIED — see wire facts above
  buildConvertChannelParams,
  buildChannelSearchParams,
  clampChannelSearchLimit,
  CHANNEL_SEARCH_PAGE_MIN,
  CHANNEL_SEARCH_PAGE_MAX,
  slackErrorDetail,
  channelSearchApiQuery,
  searchChannels,
  buildApprovalsListParams,
  buildAppApproveRestrictParams,
  buildAppClearResolutionParams,
  buildAppPermissionsParams,
  buildAppListParams,
  // Classifiers and helpers
  resolveAppOrRequestId,
  isValidPermissionType,
  VALID_PERMISSION_TYPES,
  VALID_SEARCH_CHANNEL_TYPES,
  VALID_CHANNEL_SORT_FIELDS,
  filterChannels,
  summarizeChannel,
  summarizeApproval,
  classifyUser,
  collectPages,
  // Slack Connect invites and guest invites
  buildGuestInviteParams,
  summarizeInviteResults,
  buildRevokeSharedInviteParams,
  findApproval,
  findApprovalPaged,
  summarizeApprovalDetail,
  // Channel archive / unarchive
  CHANNEL_SEARCH_MAX_LIMIT,
  READBACK_ATTEMPTS,
  READBACK_DELAY_MS,
  buildArchiveChannelParams,
  buildChannelLookupParams,
  lastActivityToMs,
  idleDaysSince,
  knownCount,
  classifyChannelHost,
  normalizeChannelState,
  externalTeamIds,
  sharedArchiveImpact,
  evaluateChannelGuards,
  lookupChannel,
  readBackArchived,
  runChannelArchiveFlow,
  // Argument validation
  CHANNEL_GLOBAL_FLAGS,
  CHANNEL_COMMAND_FLAGS,
  editDistance,
  suggestFlag,
  parseGuardCount,
  checkChannelArchiveArgs,
};

tile.json