From 6f1f032c6393f2dbb61452f56b331bbc05c1f051 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 24 Mar 2026 14:38:00 -0400 Subject: [PATCH] feat(channels): support telegram and imessage - replace the Discord-only config with multi-channel official channels selection - add Telegram token handling and iMessage platform-aware runtime gating - expand CLI, dashboard, and tests for Telegram, Discord, and iMessage Refs #783 --- src/ccs.ts | 67 ++- src/channels/discord-channels-runtime.ts | 107 ----- src/channels/official-channels-runtime.ts | 409 ++++++++++++++++++ ...ls-store.ts => official-channels-store.ts} | 112 +++-- src/commands/config-channels-command.ts | 298 +++++++++---- src/commands/config-command-options.ts | 19 +- src/commands/help-command.ts | 19 +- src/config/unified-config-loader.ts | 67 ++- src/config/unified-config-types.ts | 28 +- src/web-server/routes/channels-routes.ts | 106 +++-- .../channels/discord-channels-runtime.test.ts | 133 ------ .../channels/discord-channels-store.test.ts | 98 ----- .../official-channels-runtime.test.ts | 164 +++++++ .../channels/official-channels-store.test.ts | 114 +++++ .../commands/config-channels-command.test.ts | 30 +- tests/unit/unified-config.test.ts | 6 +- ...fig.ts => use-official-channels-config.ts} | 73 ++-- ui/src/pages/settings/sections/channels.tsx | 263 +++++------ ui/src/pages/settings/types.ts | 28 +- 19 files changed, 1416 insertions(+), 725 deletions(-) delete mode 100644 src/channels/discord-channels-runtime.ts create mode 100644 src/channels/official-channels-runtime.ts rename src/channels/{discord-channels-store.ts => official-channels-store.ts} (58%) delete mode 100644 tests/unit/channels/discord-channels-runtime.test.ts delete mode 100644 tests/unit/channels/discord-channels-store.test.ts create mode 100644 tests/unit/channels/official-channels-runtime.test.ts create mode 100644 tests/unit/channels/official-channels-store.test.ts rename ui/src/pages/settings/hooks/{use-discord-channels-config.ts => use-official-channels-config.ts} (57%) diff --git a/src/ccs.ts b/src/ccs.ts index e9361348..637235ea 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -29,20 +29,24 @@ import { getWebSearchHookEnv, ensureProfileHooks, } from './utils/websearch-manager'; -import { getGlobalEnvConfig, getDiscordChannelsConfig } from './config/unified-config-loader'; +import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector'; import { getImageAnalysisHookEnv } from './utils/hooks'; import { fail, info, warn } from './utils/ui'; import { isCopilotSubcommandToken } from './copilot/constants'; import { + buildOfficialChannelsArgs, + getOfficialChannelDisplayName, + getOfficialChannelTokenIds, isBunAvailable, - resolveDiscordChannelsSyncConfigDir, - resolveDiscordChannelsLaunchPlan, -} from './channels/discord-channels-runtime'; + officialChannelRequiresMacOS, + resolveOfficialChannelsLaunchPlan, + resolveOfficialChannelsSyncConfigDir, +} from './channels/official-channels-runtime'; import { - hasConfiguredDiscordBotToken, - syncDiscordChannelsEnvToConfigDir, -} from './channels/discord-channels-store'; + getOfficialChannelReadiness, + syncOfficialChannelEnvToConfigDir, +} from './channels/official-channels-store'; // Import centralized error handling import { handleError, runCleanup } from './errors'; @@ -144,39 +148,56 @@ function resolveNativeClaudeLaunchArgs( profileType: 'default' | 'account', targetConfigDir?: string ): string[] { - const config = getDiscordChannelsConfig(); - const plan = resolveDiscordChannelsLaunchPlan({ + const config = getOfficialChannelsConfig(); + const channelReadiness = { + telegram: getOfficialChannelReadiness('telegram'), + discord: getOfficialChannelReadiness('discord'), + imessage: !officialChannelRequiresMacOS('imessage') || process.platform === 'darwin', + }; + const plan = resolveOfficialChannelsLaunchPlan({ args, config, target: 'claude', profileType, bunAvailable: isBunAvailable(), - tokenConfigured: hasConfiguredDiscordBotToken(), + channelReadiness, }); - if (plan.skipMessage) { - console.error(warn(plan.skipMessage)); + for (const message of plan.skippedMessages) { + console.error(warn(message)); } if (!plan.applied) { return args; } - const activeConfigDir = resolveDiscordChannelsSyncConfigDir(targetConfigDir); + const activeConfigDir = resolveOfficialChannelsSyncConfigDir(targetConfigDir); + const syncedChannels = [...plan.appliedChannels]; + if (activeConfigDir) { - const syncResult = syncDiscordChannelsEnvToConfigDir(activeConfigDir); - if (!syncResult.synced && syncResult.reason !== 'already_current') { - const suffix = syncResult.error ? ` (${syncResult.error})` : ''; - console.error( - warn( - `Discord Channels auto-enable skipped: failed to sync token env to ${syncResult.targetPath}${suffix}` - ) - ); - return args; + for (const channelId of [...syncedChannels]) { + if (!getOfficialChannelTokenIds().includes(channelId)) { + continue; + } + + const syncResult = syncOfficialChannelEnvToConfigDir(channelId, activeConfigDir); + if (!syncResult.synced && syncResult.reason !== 'already_current') { + const suffix = syncResult.error ? ` (${syncResult.error})` : ''; + console.error( + warn( + `${getOfficialChannelDisplayName(channelId)} auto-enable skipped: failed to sync channel env to ${syncResult.targetPath}${suffix}` + ) + ); + syncedChannels.splice(syncedChannels.indexOf(channelId), 1); + } } } - return plan.args; + if (syncedChannels.length === 0) { + return args; + } + + return buildOfficialChannelsArgs(args, syncedChannels, plan.wantsPermissionBypass); } async function main(): Promise { diff --git a/src/channels/discord-channels-runtime.ts b/src/channels/discord-channels-runtime.ts deleted file mode 100644 index e2bccc0b..00000000 --- a/src/channels/discord-channels-runtime.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { spawnSync } from 'child_process'; -import type { DiscordChannelsConfig } from '../config/unified-config-types'; -import type { TargetType } from '../targets/target-adapter'; -import type { ProfileType } from '../types/profile'; - -export const DISCORD_CHANNEL_PLUGIN_SPEC = 'plugin:discord@claude-plugins-official'; - -export interface DiscordChannelsLaunchPlan { - args: string[]; - applied: boolean; - appliedPermissionBypass: boolean; - skipMessage?: string; -} - -interface DiscordChannelsLaunchInput { - args: string[]; - config: DiscordChannelsConfig; - target: TargetType; - profileType: ProfileType; - bunAvailable: boolean; - tokenConfigured: boolean; -} - -export function isBunAvailable(): boolean { - const result = spawnSync('bun', ['--version'], { stdio: 'ignore' }); - return result.status === 0; -} - -export function isDiscordChannelsSessionSupported( - target: TargetType, - profileType: ProfileType -): boolean { - return target === 'claude' && (profileType === 'default' || profileType === 'account'); -} - -export function hasExplicitChannelsFlag(args: string[]): boolean { - return args.some((arg) => arg === '--channels' || arg.startsWith('--channels=')); -} - -export function hasExplicitPermissionOverride(args: string[]): boolean { - return args.some( - (arg) => - arg === '--dangerously-skip-permissions' || - arg === '--permission-mode' || - arg.startsWith('--permission-mode=') - ); -} - -export function resolveDiscordChannelsSyncConfigDir(targetConfigDir?: string): string | undefined { - return targetConfigDir ?? process.env.CLAUDE_CONFIG_DIR; -} - -export function resolveDiscordChannelsLaunchPlan( - input: DiscordChannelsLaunchInput -): DiscordChannelsLaunchPlan { - const { args, config, target, profileType, bunAvailable, tokenConfigured } = input; - - if (!config.enabled) { - return { args, applied: false, appliedPermissionBypass: false }; - } - - if (!isDiscordChannelsSessionSupported(target, profileType)) { - return { - args, - applied: false, - appliedPermissionBypass: false, - skipMessage: - 'Discord Channels auto-enable only applies to native Claude default/account sessions.', - }; - } - - if (hasExplicitChannelsFlag(args)) { - return { args, applied: false, appliedPermissionBypass: false }; - } - - if (!bunAvailable) { - return { - args, - applied: false, - appliedPermissionBypass: false, - skipMessage: 'Discord Channels auto-enable skipped because Bun is not installed.', - }; - } - - if (!tokenConfigured) { - return { - args, - applied: false, - appliedPermissionBypass: false, - skipMessage: - 'Discord Channels auto-enable skipped because DISCORD_BOT_TOKEN is not configured.', - }; - } - - const nextArgs = [...args, '--channels', DISCORD_CHANNEL_PLUGIN_SPEC]; - const canApplyPermissionBypass = config.unattended && !hasExplicitPermissionOverride(args); - - if (canApplyPermissionBypass) { - nextArgs.push('--dangerously-skip-permissions'); - } - - return { - args: nextArgs, - applied: true, - appliedPermissionBypass: canApplyPermissionBypass, - }; -} diff --git a/src/channels/official-channels-runtime.ts b/src/channels/official-channels-runtime.ts new file mode 100644 index 00000000..bf3472aa --- /dev/null +++ b/src/channels/official-channels-runtime.ts @@ -0,0 +1,409 @@ +import { spawnSync } from 'child_process'; +import type { TargetType } from '../targets/target-adapter'; +import type { ProfileType } from '../types/profile'; +import type { + OfficialChannelId, + OfficialChannelsConfig, +} from '../config/unified-config-types'; + +export interface OfficialChannelDefinition { + id: OfficialChannelId; + displayName: string; + pluginSpec: string; + envKey?: string; + envDir: string; + requiresMacOS?: boolean; + manualSetupCommands: string[]; +} + +export const OFFICIAL_CHANNELS: Record = { + telegram: { + id: 'telegram', + displayName: 'Telegram', + pluginSpec: 'plugin:telegram@claude-plugins-official', + envKey: 'TELEGRAM_BOT_TOKEN', + envDir: 'telegram', + manualSetupCommands: [ + '/plugin install telegram@claude-plugins-official', + '/telegram:configure ', + '/telegram:access pair ', + '/telegram:access policy allowlist', + ], + }, + discord: { + id: 'discord', + displayName: 'Discord', + pluginSpec: 'plugin:discord@claude-plugins-official', + envKey: 'DISCORD_BOT_TOKEN', + envDir: 'discord', + manualSetupCommands: [ + '/plugin install discord@claude-plugins-official', + '/discord:configure ', + '/discord:access pair ', + '/discord:access policy allowlist', + ], + }, + imessage: { + id: 'imessage', + displayName: 'iMessage', + pluginSpec: 'plugin:imessage@claude-plugins-official', + envDir: 'imessage', + requiresMacOS: true, + manualSetupCommands: [ + '/plugin install imessage@claude-plugins-official', + '/imessage:access allow +15551234567', + ], + }, +}; + +export const OFFICIAL_CHANNEL_IDS = Object.keys(OFFICIAL_CHANNELS) as OfficialChannelId[]; + +export interface DiscordChannelsLaunchPlan { + applied: boolean; + wantsPermissionBypass: boolean; + appliedChannels: OfficialChannelId[]; + skippedMessages: string[]; +} + +interface DiscordChannelsLaunchInput { + args: string[]; + config: OfficialChannelsConfig; + target: TargetType; + profileType: ProfileType; + bunAvailable: boolean; + channelReadiness: Record; +} + +export function isBunAvailable(): boolean { + const result = spawnSync('bun', ['--version'], { stdio: 'ignore' }); + return result.status === 0; +} + +export function isMacOS(): boolean { + return process.platform === 'darwin'; +} + +export function isDiscordChannelsSessionSupported( + target: TargetType, + profileType: ProfileType +): boolean { + return target === 'claude' && (profileType === 'default' || profileType === 'account'); +} + +export function isOfficialChannelId(value: string): value is OfficialChannelId { + return value in OFFICIAL_CHANNELS; +} + +export function normalizeOfficialChannelIds(values: readonly string[]): OfficialChannelId[] { + const seen = new Set(); + const normalized: OfficialChannelId[] = []; + + for (const channelId of OFFICIAL_CHANNEL_IDS) { + if (!values.includes(channelId) || seen.has(channelId)) { + continue; + } + + seen.add(channelId); + normalized.push(channelId); + } + + return normalized; +} + +export function hasExplicitChannelsFlag(args: string[]): boolean { + return args.some((arg) => arg === '--channels' || arg.startsWith('--channels=')); +} + +export function hasExplicitPermissionOverride(args: string[]): boolean { + return args.some( + (arg) => + arg === '--dangerously-skip-permissions' || + arg === '--permission-mode' || + arg.startsWith('--permission-mode=') + ); +} + +export function resolveOfficialChannelsSyncConfigDir(targetConfigDir?: string): string | undefined { + return targetConfigDir ?? process.env.CLAUDE_CONFIG_DIR; +} + +export function buildOfficialChannelsArgs( + args: string[], + channels: OfficialChannelId[], + includePermissionBypass: boolean +): string[] { + const nextArgs = [...args, '--channels', ...channels.map((channel) => OFFICIAL_CHANNELS[channel].pluginSpec)]; + + if (includePermissionBypass) { + nextArgs.push('--dangerously-skip-permissions'); + } + + return nextArgs; +} + +export function resolveOfficialChannelsLaunchPlan( + input: DiscordChannelsLaunchInput +): DiscordChannelsLaunchPlan { + const { args, config, target, profileType, bunAvailable, channelReadiness } = input; + const skippedMessages: string[] = []; + + if (config.selected.length === 0) { + return { + applied: false, + wantsPermissionBypass: false, + appliedChannels: [], + skippedMessages, + }; + } + + if (!isDiscordChannelsSessionSupported(target, profileType)) { + return { + applied: false, + wantsPermissionBypass: false, + appliedChannels: [], + skippedMessages: [ + 'Official Channels auto-enable only applies to native Claude default/account sessions.', + ], + }; + } + + if (hasExplicitChannelsFlag(args)) { + return { + applied: false, + wantsPermissionBypass: false, + appliedChannels: [], + skippedMessages, + }; + } + + if (!bunAvailable) { + return { + applied: false, + wantsPermissionBypass: false, + appliedChannels: [], + skippedMessages: ['Official Channels auto-enable skipped because Bun is not installed.'], + }; + } + + const appliedChannels: OfficialChannelId[] = []; + + for (const channelId of normalizeOfficialChannelIds(config.selected)) { + const channel = OFFICIAL_CHANNELS[channelId]; + + if (channel.requiresMacOS && !isMacOS()) { + skippedMessages.push( + `${channel.displayName} auto-enable skipped because it requires macOS.` + ); + continue; + } + + if (!channelReadiness[channelId]) { + skippedMessages.push( + channel.envKey + ? `${channel.displayName} auto-enable skipped because ${channel.envKey} is not configured.` + : `${channel.displayName} auto-enable skipped because it is not ready on this machine.` + ); + continue; + } + + appliedChannels.push(channelId); + } + + return { + applied: appliedChannels.length > 0, + wantsPermissionBypass: config.unattended && !hasExplicitPermissionOverride(args), + appliedChannels, + skippedMessages, + }; +} + +export function getOfficialChannelTokenIds(): OfficialChannelId[] { + return OFFICIAL_CHANNEL_IDS.filter((channelId) => Boolean(OFFICIAL_CHANNELS[channelId].envKey)); +} + +export function getOfficialChannelManualSetupCommands(channelId: OfficialChannelId): string[] { + return OFFICIAL_CHANNELS[channelId].manualSetupCommands; +} + +export function getOfficialChannelDisplayName(channelId: OfficialChannelId): string { + return OFFICIAL_CHANNELS[channelId].displayName; +} + +export function getOfficialChannelPluginSpec(channelId: OfficialChannelId): string { + return OFFICIAL_CHANNELS[channelId].pluginSpec; +} + +export function getOfficialChannelEnvKey(channelId: OfficialChannelId): string | undefined { + return OFFICIAL_CHANNELS[channelId].envKey; +} + +export function officialChannelRequiresMacOS(channelId: OfficialChannelId): boolean { + return Boolean(OFFICIAL_CHANNELS[channelId].requiresMacOS); +} + +export function getOfficialChannelEnvDir(channelId: OfficialChannelId): string { + return OFFICIAL_CHANNELS[channelId].envDir; +} + +export function getOfficialChannelSummary(channelId: OfficialChannelId): string { + if (channelId === 'telegram') { + return 'Bot token required. Polls your Telegram bot while Claude is running.'; + } + if (channelId === 'discord') { + return 'Bot token required. Receives DMs and allowed server messages while Claude is running.'; + } + + return 'macOS-only. No bot token required, but Messages permissions are required.'; +} + +export function getOfficialChannelUnavailableReason(channelId: OfficialChannelId): string | undefined { + if (channelId === 'imessage' && !isMacOS()) { + return 'Requires macOS.'; + } + + return undefined; +} + +export function getOfficialChannelReadyMessage(channelId: OfficialChannelId): string { + if (channelId === 'imessage') { + return isMacOS() + ? 'Ready after Claude-side install and macOS permissions.' + : 'Unavailable on this platform.'; + } + + const envKey = getOfficialChannelEnvKey(channelId); + return envKey + ? `${envKey} must be configured before CCS can auto-enable this channel.` + : 'Ready.'; +} + +export function expandOfficialChannelSelection(selection: string): OfficialChannelId[] { + if (selection.trim().toLowerCase() === 'all') { + return [...OFFICIAL_CHANNEL_IDS]; + } + + return normalizeOfficialChannelIds( + selection + .split(',') + .map((value) => value.trim().toLowerCase()) + .filter(Boolean) + ); +} + +export function getOfficialChannelChoices(): string { + return OFFICIAL_CHANNEL_IDS.join(', '); +} + +export function isOfficialChannelSelectionValid(selection: string): boolean { + const parsed = selection + .split(',') + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + + return parsed.length > 0 && parsed.every((value) => value === 'all' || isOfficialChannelId(value)); +} + +export function resolveLegacyDiscordSelection(enabled: boolean | undefined): OfficialChannelId[] { + return enabled ? ['discord'] : []; +} + +export function getOfficialChannelsSupportedProfiles(): string[] { + return ['default', 'account']; +} + +export function getChannelConfigSelectionLabel(selected: OfficialChannelId[]): string { + if (selected.length === 0) { + return 'None'; + } + + return selected.map((channelId) => getOfficialChannelDisplayName(channelId)).join(', '); +} + +export function getTokenValueLabel(channelId: OfficialChannelId): string { + return getOfficialChannelEnvKey(channelId) ?? ''; +} + +export function isOfficialChannelTokenRequired(channelId: OfficialChannelId): boolean { + return Boolean(getOfficialChannelEnvKey(channelId)); +} + +export function getOfficialChannelDefaultTokenPlaceholder(channelId: OfficialChannelId): string { + const envKey = getOfficialChannelEnvKey(channelId); + return envKey ? `Paste ${envKey}` : ''; +} + +export function getOfficialChannelConfiguredPlaceholder(channelId: OfficialChannelId): string { + const envKey = getOfficialChannelEnvKey(channelId); + return envKey ? `Configured. Enter a new ${envKey} to replace it.` : ''; +} + +export function getOfficialChannelsSectionDescription(): string { + return 'Auto-enable Anthropic official channels for compatible Claude sessions. Tokens stay in Claude channel env files rather than config.yaml.'; +} + +export function getOfficialChannelsRuntimeNote(): string { + return 'CCS does not persist a global Claude channels default. It only injects runtime flags when the selected channels are supported and ready.'; +} + +export function getOfficialChannelsSetHelp(): string { + return `Set selected channels with --set . Supported values: ${getOfficialChannelChoices()}, or all.`; +} + +export function getOfficialChannelsLegacyEnableHelp(): string { + return 'Legacy aliases: --enable adds Discord, --disable removes Discord.'; +} + +export function getOfficialChannelTokenHelp(): string { + return 'Use --set-token =. If no channel is provided, Discord is assumed for backward compatibility.'; +} + +export function getOfficialChannelClearTokenHelp(): string { + return 'Use --clear-token to clear all saved bot tokens, or --clear-token to clear one token.'; +} + +export function getOfficialChannelMacOSHelp(): string { + return 'iMessage needs macOS Full Disk Access plus the Messages automation prompt on first reply.'; +} + +export function getOfficialChannelsDocsSummary(): string { + return 'Supported official channels are Telegram, Discord, and iMessage.'; +} + +export function getOfficialChannelSyncFailureMessage(channelId: OfficialChannelId, targetPath: string): string { + return `${getOfficialChannelDisplayName(channelId)} auto-enable skipped: failed to sync channel env to ${targetPath}`; +} + +export function getOfficialChannelSyncSkipReason(channelId: OfficialChannelId): string { + return `${getOfficialChannelDisplayName(channelId)} auto-enable skipped.`; +} + +export function getOfficialChannelsExplicitOverrideMessage(): string | undefined { + return undefined; +} + +export function getOfficialChannelTokenMissingMessage(channelId: OfficialChannelId): string { + const envKey = getOfficialChannelEnvKey(channelId); + return envKey + ? `${getOfficialChannelDisplayName(channelId)} auto-enable skipped because ${envKey} is not configured.` + : `${getOfficialChannelDisplayName(channelId)} auto-enable skipped because it is not ready.`; +} + +export function getOfficialChannelsBunMissingMessage(): string { + return 'Official Channels auto-enable skipped because Bun is not installed.'; +} + +export function getOfficialChannelsCompatibilityMessage(): string { + return 'Official Channels auto-enable only applies to native Claude default/account sessions.'; +} + +export function getOfficialChannelsNoSelectionMessage(): string { + return 'No official channels selected.'; +} + +export function getOfficialChannelsPermissionBypassMessage(): string { + return '--dangerously-skip-permissions'; +} + +export function getOfficialChannelsSelectionSummary(selected: OfficialChannelId[]): string[] { + return selected.map((channelId) => getOfficialChannelDisplayName(channelId)); +} diff --git a/src/channels/discord-channels-store.ts b/src/channels/official-channels-store.ts similarity index 58% rename from src/channels/discord-channels-store.ts rename to src/channels/official-channels-store.ts index 4472da94..432fca15 100644 --- a/src/channels/discord-channels-store.ts +++ b/src/channels/official-channels-store.ts @@ -2,8 +2,13 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir } from '../utils/config-manager'; import { getDefaultClaudeConfigDir } from '../utils/claude-config-path'; - -export const DISCORD_BOT_TOKEN_ENV_KEY = 'DISCORD_BOT_TOKEN'; +import type { OfficialChannelId } from '../config/unified-config-types'; +import { + getOfficialChannelEnvDir, + getOfficialChannelEnvKey, + getOfficialChannelTokenIds, + isOfficialChannelTokenRequired, +} from './official-channels-runtime'; export interface DiscordChannelsSyncResult { synced: boolean; @@ -12,8 +17,11 @@ export interface DiscordChannelsSyncResult { error?: string; } -export function getDiscordChannelsEnvPath(configDir = getDefaultClaudeConfigDir()): string { - return path.join(configDir, 'channels', 'discord', '.env'); +export function getOfficialChannelEnvPath( + channelId: OfficialChannelId, + configDir = getDefaultClaudeConfigDir() +): string { + return path.join(configDir, 'channels', getOfficialChannelEnvDir(channelId), '.env'); } function readFileIfExists(filePath: string): string | null { @@ -85,14 +93,19 @@ function writeSecureFile(filePath: string, content: string): void { fs.chmodSync(filePath, 0o600); } -function clearDiscordBotTokenAtPath(filePath: string): boolean { +function clearOfficialChannelTokenAtPath(channelId: OfficialChannelId, filePath: string): boolean { + const envKey = getOfficialChannelEnvKey(channelId); + if (!envKey) { + return false; + } + const currentContent = readFileIfExists(filePath); if (currentContent === null) { return false; } - const nextContent = removeEnvValue(currentContent, DISCORD_BOT_TOKEN_ENV_KEY); + const nextContent = removeEnvValue(currentContent, envKey); if (nextContent.length === 0) { fs.rmSync(filePath, { force: true }); return true; @@ -132,9 +145,17 @@ export function normalizeDiscordBotToken(value: string): string | null { return normalized; } -export function readDiscordBotTokenFromEnvContent(content: string): string | null { +export function readOfficialChannelTokenFromEnvContent( + channelId: OfficialChannelId, + content: string +): string | null { + const envKey = getOfficialChannelEnvKey(channelId); + if (!envKey) { + return null; + } + for (const line of content.split(/\r?\n/)) { - const match = line.match(/^\s*DISCORD_BOT_TOKEN\s*=\s*(.*)\s*$/); + const match = line.match(new RegExp(`^\\s*${envKey}\\s*=\\s*(.*)\\s*$`)); if (!match) { continue; } @@ -145,52 +166,75 @@ export function readDiscordBotTokenFromEnvContent(content: string): string | nul return null; } -export function readConfiguredDiscordBotToken(): string | null { - const content = readFileIfExists(getDiscordChannelsEnvPath()); - return content ? readDiscordBotTokenFromEnvContent(content) : null; +export function readConfiguredOfficialChannelToken(channelId: OfficialChannelId): string | null { + const content = readFileIfExists(getOfficialChannelEnvPath(channelId)); + return content ? readOfficialChannelTokenFromEnvContent(channelId, content) : null; } -export function hasConfiguredDiscordBotToken(): boolean { - return readConfiguredDiscordBotToken() !== null; +export function hasConfiguredOfficialChannelToken(channelId: OfficialChannelId): boolean { + return readConfiguredOfficialChannelToken(channelId) !== null; } -export function setConfiguredDiscordBotToken(token: string): string { - const normalized = normalizeDiscordBotToken(token); - if (!normalized) { - throw new Error('Discord bot token cannot be empty or multiline.'); +export function setConfiguredOfficialChannelToken( + channelId: OfficialChannelId, + token: string +): string { + const envKey = getOfficialChannelEnvKey(channelId); + if (!envKey) { + throw new Error(`${channelId} does not use a bot token.`); } - const envPath = getDiscordChannelsEnvPath(); + const normalized = normalizeDiscordBotToken(token); + if (!normalized) { + throw new Error(`${envKey} cannot be empty or multiline.`); + } + + const envPath = getOfficialChannelEnvPath(channelId); const currentContent = readFileIfExists(envPath) ?? ''; - writeSecureFile(envPath, upsertEnvValue(currentContent, DISCORD_BOT_TOKEN_ENV_KEY, normalized)); + writeSecureFile(envPath, upsertEnvValue(currentContent, envKey, normalized)); return envPath; } -export function clearConfiguredDiscordBotToken(): string { - const envPath = getDiscordChannelsEnvPath(); - clearDiscordBotTokenAtPath(envPath); +export function clearConfiguredOfficialChannelToken(channelId: OfficialChannelId): string { + const envPath = getOfficialChannelEnvPath(channelId); + clearOfficialChannelTokenAtPath(channelId, envPath); return envPath; } -export function clearConfiguredDiscordBotTokenEverywhere(): string[] { +export function clearConfiguredOfficialChannelTokensEverywhere( + channelId?: OfficialChannelId +): string[] { const clearedPaths: string[] = []; + const channels = channelId ? [channelId] : getOfficialChannelTokenIds(); for (const configDir of listManagedClaudeConfigDirs()) { - const envPath = getDiscordChannelsEnvPath(configDir); - if (clearDiscordBotTokenAtPath(envPath)) { - clearedPaths.push(envPath); + for (const tokenChannelId of channels) { + const envPath = getOfficialChannelEnvPath(tokenChannelId, configDir); + if (clearOfficialChannelTokenAtPath(tokenChannelId, envPath)) { + clearedPaths.push(envPath); + } } } return clearedPaths; } -export function syncDiscordChannelsEnvToConfigDir( +export function syncOfficialChannelEnvToConfigDir( + channelId: OfficialChannelId, targetConfigDir: string ): DiscordChannelsSyncResult { - const sourcePath = getDiscordChannelsEnvPath(); - const targetPath = getDiscordChannelsEnvPath(targetConfigDir); - const token = readConfiguredDiscordBotToken(); + const envKey = getOfficialChannelEnvKey(channelId); + if (!envKey) { + return { + synced: false, + targetPath: getOfficialChannelEnvPath(channelId, targetConfigDir), + reason: 'missing_token', + }; + } + + const sourcePath = getOfficialChannelEnvPath(channelId); + const targetPath = getOfficialChannelEnvPath(channelId, targetConfigDir); + const token = readConfiguredOfficialChannelToken(channelId); if (!fs.existsSync(sourcePath)) { return { synced: false, targetPath, reason: 'missing_env' }; @@ -206,7 +250,7 @@ export function syncDiscordChannelsEnvToConfigDir( try { const targetContent = readFileIfExists(targetPath) ?? ''; - writeSecureFile(targetPath, upsertEnvValue(targetContent, DISCORD_BOT_TOKEN_ENV_KEY, token)); + writeSecureFile(targetPath, upsertEnvValue(targetContent, envKey, token)); return { synced: true, targetPath }; } catch (error) { return { @@ -217,3 +261,9 @@ export function syncDiscordChannelsEnvToConfigDir( }; } } + +export function getOfficialChannelReadiness(channelId: OfficialChannelId): boolean { + return isOfficialChannelTokenRequired(channelId) + ? hasConfiguredOfficialChannelToken(channelId) + : true; +} diff --git a/src/commands/config-channels-command.ts b/src/commands/config-channels-command.ts index 12bad174..352c53d2 100644 --- a/src/commands/config-channels-command.ts +++ b/src/commands/config-channels-command.ts @@ -1,41 +1,125 @@ import { initUI, header, ok, info, warn, fail, subheader, color, dim } from '../utils/ui'; import { - getDiscordChannelsConfig, + getOfficialChannelsConfig, loadOrCreateUnifiedConfig, updateUnifiedConfig, } from '../config/unified-config-loader'; -import { DEFAULT_DISCORD_CHANNELS_CONFIG } from '../config/unified-config-types'; +import type { OfficialChannelId } from '../config/unified-config-types'; +import { DEFAULT_OFFICIAL_CHANNELS_CONFIG } from '../config/unified-config-types'; import { - clearConfiguredDiscordBotTokenEverywhere, - getDiscordChannelsEnvPath, - hasConfiguredDiscordBotToken, - setConfiguredDiscordBotToken, -} from '../channels/discord-channels-store'; -import { DISCORD_CHANNEL_PLUGIN_SPEC, isBunAvailable } from '../channels/discord-channels-runtime'; + clearConfiguredOfficialChannelTokensEverywhere, + getOfficialChannelEnvPath, + hasConfiguredOfficialChannelToken, + setConfiguredOfficialChannelToken, +} from '../channels/official-channels-store'; +import { + expandOfficialChannelSelection, + getChannelConfigSelectionLabel, + getOfficialChannelChoices, + getOfficialChannelDisplayName, + getOfficialChannelEnvKey, + getOfficialChannelManualSetupCommands, + getOfficialChannelReadyMessage, + getOfficialChannelsCompatibilityMessage, + getOfficialChannelsDocsSummary, + getOfficialChannelsLegacyEnableHelp, + getOfficialChannelsSetHelp, + getOfficialChannelTokenHelp, + getOfficialChannelClearTokenHelp, + getOfficialChannelMacOSHelp, + getOfficialChannelSummary, + getOfficialChannelsRuntimeNote, + getOfficialChannelsSectionDescription, + getOfficialChannelsSupportedProfiles, + getOfficialChannelUnavailableReason, + getOfficialChannelTokenIds, + isBunAvailable, + isOfficialChannelId, + isOfficialChannelSelectionValid, +} from '../channels/official-channels-runtime'; import { extractOption, hasAnyFlag } from './arg-extractor'; interface ChannelsCommandOptions { enable: boolean; disable: boolean; + clear: boolean; unattended: boolean; noUnattended: boolean; - clearToken: boolean; - setToken?: string; + setSelection?: string; + setSelectionMissing: boolean; + clearTokenAll: boolean; + clearTokenChannel?: OfficialChannelId; + setToken?: { channelId: OfficialChannelId; token: string }; setTokenMissing: boolean; + clearTokenInvalid?: string; + setTokenInvalid?: string; help: boolean; } +function parseTokenAssignment(value: string): { + channelId: OfficialChannelId; + token: string; +} | null { + const separatorIndex = value.indexOf('='); + if (separatorIndex === -1) { + return value.trim() + ? { channelId: 'discord', token: value.trim() } + : null; + } + + const channelId = value.slice(0, separatorIndex).trim().toLowerCase(); + const token = value.slice(separatorIndex + 1).trim(); + if (!isOfficialChannelId(channelId) || !token) { + return null; + } + + return { channelId, token }; +} + export function parseChannelsCommandArgs(args: string[]): ChannelsCommandOptions { + const setSelection = extractOption(args, ['--set']); const setToken = extractOption(args, ['--set-token']); + const clearToken = extractOption(args, ['--clear-token']); + + let clearTokenAll = false; + let clearTokenChannel: OfficialChannelId | undefined; + let clearTokenInvalid: string | undefined; + if (clearToken.found) { + if (clearToken.missingValue) { + clearTokenAll = true; + } else if (clearToken.value) { + const channelId = clearToken.value.trim().toLowerCase(); + if (isOfficialChannelId(channelId)) { + clearTokenChannel = channelId; + } else { + clearTokenInvalid = clearToken.value; + } + } + } + + let parsedSetToken: { channelId: OfficialChannelId; token: string } | undefined; + let setTokenInvalid: string | undefined; + if (setToken.found && !setToken.missingValue && setToken.value) { + parsedSetToken = parseTokenAssignment(setToken.value) ?? undefined; + if (!parsedSetToken) { + setTokenInvalid = setToken.value; + } + } return { enable: hasAnyFlag(args, ['--enable']), disable: hasAnyFlag(args, ['--disable']), + clear: hasAnyFlag(args, ['--clear']), unattended: hasAnyFlag(args, ['--unattended']), noUnattended: hasAnyFlag(args, ['--no-unattended']), - clearToken: hasAnyFlag(args, ['--clear-token']), - setToken: setToken.found ? setToken.value : undefined, + setSelection: setSelection.found ? setSelection.value : undefined, + setSelectionMissing: setSelection.found && setSelection.missingValue, + clearTokenAll, + clearTokenChannel, + clearTokenInvalid, + setToken: parsedSetToken, setTokenMissing: setToken.found && setToken.missingValue, + setTokenInvalid, help: hasAnyFlag(args, ['--help', '-h']), }; } @@ -44,72 +128,106 @@ function showHelp(): void { console.log(''); console.log(header('ccs config channels')); console.log(''); - console.log( - ' Configure Anthropic official Discord Channels auto-enable for native Claude sessions.' - ); + console.log(` ${getOfficialChannelsSectionDescription()}`); + console.log(` ${dim(getOfficialChannelsDocsSummary())}`); console.log(''); console.log(subheader('Usage:')); console.log(` ${color('ccs config channels', 'command')} [options]`); console.log(''); console.log(subheader('Options:')); - console.log(` ${color('--enable', 'command')} Enable auto-adding Discord Channels`); - console.log( - ` ${color('--disable', 'command')} Disable auto-adding Discord Channels` - ); - console.log( - ` ${color('--unattended', 'command')} Also add --dangerously-skip-permissions` - ); + console.log(` ${color('--set ', 'command')} ${getOfficialChannelsSetHelp()}`); + console.log(` ${color('--clear', 'command')} Clear all selected channels`); + console.log(` ${color('--enable', 'command')} Legacy alias: add Discord`); + console.log(` ${color('--disable', 'command')} Legacy alias: remove Discord`); + console.log(` ${color('--unattended', 'command')} Also add --dangerously-skip-permissions`); console.log(` ${color('--no-unattended', 'command')} Disable unattended runtime flag`); - console.log(` ${color('--set-token ', 'command')} Save DISCORD_BOT_TOKEN`); - console.log(` ${color('--clear-token', 'command')} Remove saved DISCORD_BOT_TOKEN`); + console.log(` ${color('--set-token ', 'command')} ${getOfficialChannelTokenHelp()}`); + console.log(` ${color('--clear-token [channel]', 'command')} ${getOfficialChannelClearTokenHelp()}`); console.log(` ${color('--help, -h', 'command')} Show this help`); console.log(''); console.log(subheader('Examples:')); + console.log(` $ ${color('ccs config channels', 'command')} ${dim('# Show status')}`); console.log( - ` $ ${color('ccs config channels', 'command')} ${dim('# Show status')}` + ` $ ${color('ccs config channels --set telegram,discord', 'command')} ${dim('# Enable Telegram + Discord')}` ); console.log( - ` $ ${color('ccs config channels --enable', 'command')} ${dim('# Auto-enable Discord Channels')}` + ` $ ${color('ccs config channels --set all', 'command')} ${dim('# Enable all official channels')}` ); console.log( - ` $ ${color('ccs config channels --unattended', 'command')} ${dim('# Also skip permissions prompts')}` + ` $ ${color('ccs config channels --set-token telegram=123:abc', 'command')} ${dim('# Save TELEGRAM_BOT_TOKEN')}` ); console.log( - ` $ ${color('ccs config channels --set-token xxxxxx', 'command')} ${dim('# Save bot token')}` + ` $ ${color('ccs config channels --clear-token discord', 'command')} ${dim('# Clear one token')}` ); console.log(''); } function showStatus(): void { - const config = getDiscordChannelsConfig(); + const config = getOfficialChannelsConfig(); + const selected = config.selected; const bunReady = isBunAvailable(); - const tokenConfigured = hasConfiguredDiscordBotToken(); console.log(''); - console.log(header('Discord Channels Configuration')); + console.log(header('Official Channels Configuration')); console.log(''); - console.log(` Status: ${config.enabled ? ok('Enabled') : warn('Disabled')}`); + console.log(` Channels: ${selected.length > 0 ? ok(getChannelConfigSelectionLabel(selected)) : warn('Disabled')}`); console.log(` Unattended: ${config.unattended ? warn('Enabled') : info('Disabled')}`); console.log(` Bun: ${bunReady ? ok('Installed') : warn('Missing')}`); - console.log(` Token: ${tokenConfigured ? ok('Configured') : warn('Not configured')}`); - console.log(` Plugin: ${color(DISCORD_CHANNEL_PLUGIN_SPEC, 'command')}`); console.log(''); console.log(subheader('Applies To:')); - console.log(` ${dim('Native Claude target only: default and account sessions.')}`); - console.log(` ${dim('Not applied to CLIProxy, API-key, Copilot, or Droid flows.')}`); + console.log(` ${dim(getOfficialChannelsCompatibilityMessage())}`); + console.log(` ${dim(`Supported profiles: ${getOfficialChannelsSupportedProfiles().join(', ')}`)}`); console.log(''); - console.log(subheader('Files:')); - console.log(` Config: ${color('~/.ccs/config.yaml', 'path')}`); - console.log(` Token: ${color(getDiscordChannelsEnvPath(), 'path')}`); + console.log(subheader('Channels:')); + for (const channelId of expandOfficialChannelSelection('all')) { + const displayName = getOfficialChannelDisplayName(channelId); + const enabled = selected.includes(channelId); + const envKey = getOfficialChannelEnvKey(channelId); + const tokenConfigured = envKey ? hasConfiguredOfficialChannelToken(channelId) : true; + const unavailableReason = getOfficialChannelUnavailableReason(channelId); + const status = unavailableReason + ? warn(unavailableReason) + : envKey + ? tokenConfigured + ? ok('Ready') + : warn(`${envKey} missing`) + : ok('Ready'); + console.log(` ${enabled ? '[x]' : '[ ]'} ${displayName}: ${status}`); + console.log(` ${dim(getOfficialChannelSummary(channelId))}`); + if (envKey) { + console.log(` ${dim(`${envKey}: ${tokenConfigured ? 'configured' : 'not configured'}`)}`); + console.log(` ${dim(getOfficialChannelEnvPath(channelId))}`); + } + console.log(` ${dim(getOfficialChannelReadyMessage(channelId))}`); + } + console.log(''); + console.log(subheader('Notes:')); + console.log(` ${dim(getOfficialChannelsLegacyEnableHelp())}`); + console.log(` ${dim(getOfficialChannelMacOSHelp())}`); + console.log(` ${dim(getOfficialChannelsRuntimeNote())}`); console.log(''); console.log(subheader('Manual Claude Setup:')); - console.log(` ${color('/plugin install discord@claude-plugins-official', 'command')}`); - console.log(` ${color('/discord:configure ', 'command')}`); - console.log(` ${color('/discord:access pair ', 'command')}`); - console.log(` ${color('/discord:access policy allowlist', 'command')}`); + for (const channelId of expandOfficialChannelSelection('all')) { + console.log(` ${dim(`${getOfficialChannelDisplayName(channelId)}:`)}`); + for (const command of getOfficialChannelManualSetupCommands(channelId)) { + console.log(` ${color(command, 'command')}`); + } + } console.log(''); } +function resolveNextSelection(args: ChannelsCommandOptions): OfficialChannelId[] | null { + if (args.setSelection !== undefined) { + return expandOfficialChannelSelection(args.setSelection); + } + + if (args.clear) { + return []; + } + + return null; +} + export async function handleConfigChannelsCommand(args: string[]): Promise { await initUI(); @@ -119,68 +237,96 @@ export async function handleConfigChannelsCommand(args: string[]): Promise return; } - if (options.enable && options.disable) { - console.error(fail('Cannot use --enable and --disable together')); + if (options.setSelectionMissing) { + console.error(fail(`--set requires a value (${getOfficialChannelChoices()} or all)`)); process.exitCode = 1; return; } - if (options.unattended && options.noUnattended) { - console.error(fail('Cannot use --unattended and --no-unattended together')); - process.exitCode = 1; - return; - } - if (options.setToken !== undefined && options.clearToken) { - console.error(fail('Cannot use --set-token and --clear-token together')); + if (options.setSelection !== undefined && !isOfficialChannelSelectionValid(options.setSelection)) { + console.error(fail(`Invalid --set value: ${options.setSelection} (${getOfficialChannelChoices()} or all)`)); process.exitCode = 1; return; } if (options.setTokenMissing) { - console.error(fail('--set-token requires a token value')); + console.error(fail('--set-token requires a value')); + process.exitCode = 1; + return; + } + if (options.setTokenInvalid) { + console.error( + fail(`Invalid --set-token value: ${options.setTokenInvalid} (use =)`) + ); + process.exitCode = 1; + return; + } + if (options.clearTokenInvalid) { + console.error( + fail(`Invalid --clear-token value: ${options.clearTokenInvalid} (use ${getOfficialChannelChoices()})`) + ); process.exitCode = 1; return; } const config = loadOrCreateUnifiedConfig(); const nextConfig = { - ...(config.discord_channels ?? DEFAULT_DISCORD_CHANNELS_CONFIG), + ...(config.channels ?? DEFAULT_OFFICIAL_CHANNELS_CONFIG), + selected: [...(config.channels?.selected ?? DEFAULT_OFFICIAL_CHANNELS_CONFIG.selected)], }; - let updated = false; - if (options.enable) { - nextConfig.enabled = true; - updated = true; + const explicitSelection = resolveNextSelection(options); + const hasConfigMutation = + explicitSelection !== null || + options.enable || + options.disable || + options.unattended || + options.noUnattended; + if (explicitSelection) { + nextConfig.selected = explicitSelection; + } + if (options.enable && !nextConfig.selected.includes('discord')) { + nextConfig.selected.push('discord'); } if (options.disable) { - nextConfig.enabled = false; - updated = true; + nextConfig.selected = nextConfig.selected.filter((channelId) => channelId !== 'discord'); } if (options.unattended) { nextConfig.unattended = true; - updated = true; } if (options.noUnattended) { nextConfig.unattended = false; - updated = true; } try { - if (updated) { - updateUnifiedConfig({ discord_channels: nextConfig }); + if (hasConfigMutation) { + updateUnifiedConfig({ channels: nextConfig }); + } + + if (options.setToken) { + if (!getOfficialChannelTokenIds().includes(options.setToken.channelId)) { + throw new Error(`${options.setToken.channelId} does not use a bot token.`); + } + setConfiguredOfficialChannelToken(options.setToken.channelId, options.setToken.token); + console.log(ok(`${getOfficialChannelDisplayName(options.setToken.channelId)} token saved`)); + console.log(''); + } + + if (options.clearTokenChannel) { + if (!getOfficialChannelTokenIds().includes(options.clearTokenChannel)) { + throw new Error(`${options.clearTokenChannel} does not use a bot token.`); + } + clearConfiguredOfficialChannelTokensEverywhere(options.clearTokenChannel); + console.log(ok(`${getOfficialChannelDisplayName(options.clearTokenChannel)} token cleared`)); + console.log(''); + } else if (options.clearTokenAll) { + clearConfiguredOfficialChannelTokensEverywhere(); + console.log(ok('All saved channel tokens cleared')); + console.log(''); + } + + if (hasConfigMutation) { console.log(ok('Configuration updated')); console.log(''); } - - if (options.setToken !== undefined) { - setConfiguredDiscordBotToken(options.setToken); - console.log(ok('Discord bot token saved')); - console.log(''); - } - - if (options.clearToken) { - clearConfiguredDiscordBotTokenEverywhere(); - console.log(ok('Discord bot token cleared')); - console.log(''); - } } catch (error) { console.error(fail((error as Error).message)); process.exitCode = 1; diff --git a/src/commands/config-command-options.ts b/src/commands/config-command-options.ts index 488bcdb3..7c0e25e9 100644 --- a/src/commands/config-command-options.ts +++ b/src/commands/config-command-options.ts @@ -83,12 +83,15 @@ export function showConfigCommandHelp(): void { console.log('Includes a dedicated Claude IDE Extension page for VS Code-compatible hosts.'); console.log(''); console.log('Commands:'); - console.log(' channels Manage Discord Channels auto-enable + bot token'); - console.log(' --enable Enable runtime auto-add for compatible Claude sessions'); - console.log(' --disable Disable runtime auto-add'); + console.log(' channels Manage official Claude channels (Telegram, Discord, iMessage)'); + console.log(' --set Select channels to auto-enable at runtime'); + console.log(' --clear Clear all selected channels'); + console.log(' --enable Legacy alias: add Discord'); + console.log(' --disable Legacy alias: remove Discord'); console.log(' --unattended Also add --dangerously-skip-permissions at runtime'); - console.log(' --set-token Save DISCORD_BOT_TOKEN to Claude channels env'); - console.log(' --clear-token Remove saved DISCORD_BOT_TOKEN'); + console.log(' --set-token Save channel token (telegram= or discord=)'); + console.log(' --clear-token Remove all saved channel tokens'); + console.log(' --clear-token Remove one saved channel token'); console.log(''); console.log(' auth Manage dashboard authentication'); console.log(' auth setup Configure username and password'); @@ -127,9 +130,9 @@ export function showConfigCommandHelp(): void { console.log(' ccs config --host 127.0.0.1 Restrict dashboard to this machine'); console.log(' ccs config --dev Development mode with hot reload'); console.log(' ccs config auth setup Configure dashboard login'); - console.log(' ccs config channels Show Discord Channels status'); - console.log(' ccs config channels --enable Enable runtime auto-add'); - console.log(' ccs config channels --set-token xxx Save DISCORD_BOT_TOKEN'); + console.log(' ccs config channels Show Official Channels status'); + console.log(' ccs config channels --set telegram,discord Enable Telegram + Discord'); + console.log(' ccs config channels --set-token telegram=xxx Save TELEGRAM_BOT_TOKEN'); console.log(' ccs config image-analysis Show image settings'); console.log(' ccs config image-analysis --enable Enable feature'); console.log(' ccs config thinking Show thinking settings'); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 73bc63e8..70a7532e 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -311,9 +311,9 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs config', 'Open web dashboard (includes Claude IDE Extension setup page)'], ['ccs config auth setup', 'Configure dashboard login'], ['ccs config auth show', 'Show dashboard auth status'], - ['ccs config channels', 'Show Discord Channels status'], - ['ccs config channels --enable', 'Auto-enable Discord Channels on native Claude sessions'], - ['ccs config channels --set-token ', 'Save DISCORD_BOT_TOKEN for Discord Channels'], + ['ccs config channels', 'Show Official Channels status'], + ['ccs config channels --set telegram,discord', 'Auto-enable Telegram + Discord'], + ['ccs config channels --set-token telegram=', 'Save TELEGRAM_BOT_TOKEN'], ['ccs config image-analysis', 'Show image analysis settings'], ['ccs config image-analysis --enable', 'Enable image analysis'], ['ccs config thinking', 'Show thinking/reasoning settings'], @@ -469,15 +469,18 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['', 'providers (agy, gemini, codex, kiro, ghcp).'], ]); - printSubSection('Discord Channels (official Claude plugin)', [ + printSubSection('Official Channels (official Claude plugins)', [ ['ccs config channels', 'Show current status'], - ['ccs config channels --enable', 'Auto-add Discord Channels on native Claude sessions'], + ['ccs config channels --set telegram,discord', 'Auto-add selected channels on native Claude sessions'], + ['ccs config channels --set all', 'Enable Telegram, Discord, and iMessage'], ['ccs config channels --unattended', 'Also add --dangerously-skip-permissions'], - ['ccs config channels --set-token ', 'Save DISCORD_BOT_TOKEN'], - ['ccs config channels --clear-token', 'Remove saved token'], + ['ccs config channels --set-token telegram=', 'Save TELEGRAM_BOT_TOKEN'], + ['ccs config channels --set-token discord=', 'Save DISCORD_BOT_TOKEN'], + ['ccs config channels --clear-token [channel]', 'Remove one or all saved channel tokens'], ['', ''], ['Note:', 'Runtime-only. Applies to native Claude default/account sessions.'], - ['', 'CCS stores the token in ~/.claude/channels/discord/.env.'], + ['', 'Telegram/Discord tokens live in ~/.claude/channels//.env.'], + ['', 'iMessage is macOS-only and requires local OS permissions instead of a bot token.'], ]); // CCS Environment Variables diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 0b922386..eb5bf431 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -21,7 +21,7 @@ import { DEFAULT_CLIPROXY_SAFETY_CONFIG, DEFAULT_QUOTA_MANAGEMENT_CONFIG, DEFAULT_THINKING_CONFIG, - DEFAULT_DISCORD_CHANNELS_CONFIG, + DEFAULT_OFFICIAL_CHANNELS_CONFIG, DEFAULT_DASHBOARD_AUTH_CONFIG, DEFAULT_IMAGE_ANALYSIS_CONFIG, } from './unified-config-types'; @@ -30,7 +30,8 @@ import type { CLIProxySafetyConfig, GlobalEnvConfig, ThinkingConfig, - DiscordChannelsConfig, + OfficialChannelsConfig, + OfficialChannelId, DashboardAuthConfig, ImageAnalysisConfig, CursorConfig, @@ -38,6 +39,11 @@ import type { } from './unified-config-types'; import { validateCompositeTiers } from '../cliproxy/composite-validator'; import { isUnifiedConfigEnabled } from './feature-flags'; +import { + isOfficialChannelId, + normalizeOfficialChannelIds, + resolveLegacyDiscordSelection, +} from '../channels/official-channels-runtime'; const CONFIG_YAML = 'config.yaml'; const CONFIG_JSON = 'config.json'; @@ -290,6 +296,30 @@ function normalizeContinuityConfig(partial: Partial): ContinuityC }; } +interface LegacyDiscordChannelsConfig { + enabled?: boolean; + unattended?: boolean; +} + +function normalizeOfficialChannelsConfig( + partial: Partial & { discord_channels?: LegacyDiscordChannelsConfig } +): OfficialChannelsConfig { + const rawSelected = Array.isArray(partial.channels?.selected) + ? partial.channels.selected.filter((value): value is OfficialChannelId => isOfficialChannelId(value)) + : []; + + return { + selected: + rawSelected.length > 0 + ? normalizeOfficialChannelIds(rawSelected) + : resolveLegacyDiscordSelection(partial.discord_channels?.enabled), + unattended: + partial.channels?.unattended ?? + partial.discord_channels?.unattended ?? + DEFAULT_OFFICIAL_CHANNELS_CONFIG.unattended, + }; +} + /** * Merge partial config with defaults. * Preserves existing data while filling in missing sections. @@ -501,11 +531,9 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { provider_overrides: partial.thinking?.provider_overrides, show_warnings: partial.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings, }, - discord_channels: { - enabled: partial.discord_channels?.enabled ?? DEFAULT_DISCORD_CHANNELS_CONFIG.enabled, - unattended: - partial.discord_channels?.unattended ?? DEFAULT_DISCORD_CHANNELS_CONFIG.unattended, - }, + channels: normalizeOfficialChannelsConfig( + partial as Partial & { discord_channels?: LegacyDiscordChannelsConfig } + ), // Dashboard auth config - disabled by default dashboard_auth: { enabled: partial.dashboard_auth?.enabled ?? DEFAULT_DASHBOARD_AUTH_CONFIG.enabled, @@ -770,20 +798,22 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } - // Discord Channels section - if (config.discord_channels) { + // Official Channels section + if (config.channels) { lines.push('# ----------------------------------------------------------------------------'); - lines.push('# Discord Channels: Runtime auto-enable for Anthropic official Discord plugin'); + lines.push('# Official Channels: Runtime auto-enable for Anthropic official channel plugins'); + lines.push('# Supported channels: telegram, discord, imessage'); lines.push('# Runtime-only: CCS injects --channels at launch for compatible Claude sessions.'); - lines.push('# Token storage lives in ~/.claude/channels/discord/.env, not in config.yaml.'); - lines.push('# unattended adds --dangerously-skip-permissions only when auto-enable is active.'); + lines.push('# Bot tokens live in Claude channel env files, not in config.yaml.'); + lines.push('# Use selected: [telegram, discord, imessage] to choose channels.'); + lines.push('# unattended adds --dangerously-skip-permissions only when channel auto-enable is active.'); lines.push('# Compatible sessions: native Claude default/account profiles only.'); lines.push('# Configure via: ccs config channels or the Settings > Channels dashboard tab.'); lines.push('# ----------------------------------------------------------------------------'); lines.push( yaml .dump( - { discord_channels: config.discord_channels }, + { channels: config.channels }, { indent: 2, lineWidth: -1, quotingType: '"' } ) .trim() @@ -1167,15 +1197,18 @@ export function getThinkingConfig(): ThinkingConfig { } /** - * Get Discord Channels configuration. + * Get Official Channels configuration. * Returns defaults if not configured. */ -export function getDiscordChannelsConfig(): DiscordChannelsConfig { +export function getOfficialChannelsConfig(): OfficialChannelsConfig { const config = loadOrCreateUnifiedConfig(); return { - enabled: config.discord_channels?.enabled ?? DEFAULT_DISCORD_CHANNELS_CONFIG.enabled, - unattended: config.discord_channels?.unattended ?? DEFAULT_DISCORD_CHANNELS_CONFIG.unattended, + selected: + config.channels?.selected && config.channels.selected.length > 0 + ? normalizeOfficialChannelIds(config.channels.selected) + : DEFAULT_OFFICIAL_CHANNELS_CONFIG.selected, + unattended: config.channels?.unattended ?? DEFAULT_OFFICIAL_CHANNELS_CONFIG.unattended, }; } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index acaeaeae..8ff88160 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -25,8 +25,9 @@ import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; * Version 9 = Real WebSearch backends (DuckDuckGo/Brave) with legacy CLI fallback * Version 10 = Exa + Tavily WebSearch backends * Version 11 = Discord Channels runtime auto-enable preferences + * Version 12 = Official Channels multi-provider support (Telegram, Discord, iMessage) */ -export const UNIFIED_CONFIG_VERSION = 11; +export const UNIFIED_CONFIG_VERSION = 12; /** * Supported CLIProxy providers. @@ -696,22 +697,27 @@ export const DEFAULT_THINKING_CONFIG: ThinkingConfig = { }; /** - * Discord Channels configuration. - * Controls runtime-only injection of Anthropic's official Discord channel plugin. + * Supported Anthropic official channel IDs. */ -export interface DiscordChannelsConfig { - /** Enable auto-adding the official Discord channel for compatible sessions */ - enabled: boolean; +export type OfficialChannelId = 'telegram' | 'discord' | 'imessage'; + +/** + * Official Channels configuration. + * Controls runtime-only injection of Anthropic's official channel plugins. + */ +export interface OfficialChannelsConfig { + /** Selected official channels to auto-enable for compatible sessions */ + selected: OfficialChannelId[]; /** Also add --dangerously-skip-permissions when auto-enable is active */ unattended: boolean; } /** - * Default Discord Channels configuration. + * Default Official Channels configuration. * Disabled by default because the feature requires explicit user setup. */ -export const DEFAULT_DISCORD_CHANNELS_CONFIG: DiscordChannelsConfig = { - enabled: false, +export const DEFAULT_OFFICIAL_CHANNELS_CONFIG: OfficialChannelsConfig = { + selected: [], unattended: false, }; @@ -812,7 +818,7 @@ export interface UnifiedConfig { /** Thinking/reasoning budget configuration (v8+) */ thinking?: ThinkingConfig; /** Discord Channels runtime auto-enable preferences (v11+) */ - discord_channels?: DiscordChannelsConfig; + channels?: OfficialChannelsConfig; /** Dashboard authentication configuration (optional) */ dashboard_auth?: DashboardAuthConfig; /** Image analysis configuration (vision via CLIProxy) */ @@ -939,7 +945,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG }, quota_management: { ...DEFAULT_QUOTA_MANAGEMENT_CONFIG }, thinking: { ...DEFAULT_THINKING_CONFIG }, - discord_channels: { ...DEFAULT_DISCORD_CHANNELS_CONFIG }, + channels: { ...DEFAULT_OFFICIAL_CHANNELS_CONFIG }, dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG }, image_analysis: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG }, }; diff --git a/src/web-server/routes/channels-routes.ts b/src/web-server/routes/channels-routes.ts index 87aae579..92507cf6 100644 --- a/src/web-server/routes/channels-routes.ts +++ b/src/web-server/routes/channels-routes.ts @@ -1,25 +1,58 @@ import { Router, type Request, type Response } from 'express'; -import { getDiscordChannelsConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; +import { getOfficialChannelsConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; import { - clearConfiguredDiscordBotTokenEverywhere, - getDiscordChannelsEnvPath, - hasConfiguredDiscordBotToken, - setConfiguredDiscordBotToken, -} from '../../channels/discord-channels-store'; + clearConfiguredOfficialChannelTokensEverywhere, + getOfficialChannelEnvPath, + getOfficialChannelReadiness, + hasConfiguredOfficialChannelToken, + setConfiguredOfficialChannelToken, +} from '../../channels/official-channels-store'; import { - DISCORD_CHANNEL_PLUGIN_SPEC, + expandOfficialChannelSelection, + getOfficialChannelDisplayName, + getOfficialChannelEnvKey, + getOfficialChannelPluginSpec, + getOfficialChannelSummary, + getOfficialChannelUnavailableReason, + getOfficialChannelsSupportedProfiles, + getOfficialChannelManualSetupCommands, + getOfficialChannelTokenIds, isBunAvailable, -} from '../../channels/discord-channels-runtime'; + isOfficialChannelId, +} from '../../channels/official-channels-runtime'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; const router = Router(); +function buildChannelsStatus() { + return { + bunInstalled: isBunAvailable(), + supportedProfiles: getOfficialChannelsSupportedProfiles(), + channels: expandOfficialChannelSelection('all').map((channelId) => ({ + id: channelId, + displayName: getOfficialChannelDisplayName(channelId), + pluginSpec: getOfficialChannelPluginSpec(channelId), + summary: getOfficialChannelSummary(channelId), + requiresToken: getOfficialChannelTokenIds().includes(channelId), + envKey: getOfficialChannelEnvKey(channelId), + tokenConfigured: getOfficialChannelTokenIds().includes(channelId) + ? hasConfiguredOfficialChannelToken(channelId) + : getOfficialChannelReadiness(channelId), + tokenPath: getOfficialChannelTokenIds().includes(channelId) + ? getOfficialChannelEnvPath(channelId) + : undefined, + unavailableReason: getOfficialChannelUnavailableReason(channelId), + manualSetupCommands: getOfficialChannelManualSetupCommands(channelId), + })), + }; +} + router.use((req: Request, res: Response, next) => { if ( requireLocalAccessWhenAuthDisabled( req, res, - 'Discord Channels settings require localhost access when dashboard auth is disabled.' + 'Official Channels settings require localhost access when dashboard auth is disabled.' ) ) { next(); @@ -28,28 +61,19 @@ router.use((req: Request, res: Response, next) => { router.get('/', (_req: Request, res: Response): void => { res.json({ - config: getDiscordChannelsConfig(), - status: { - bunInstalled: isBunAvailable(), - tokenConfigured: hasConfiguredDiscordBotToken(), - tokenPath: getDiscordChannelsEnvPath(), - pluginSpec: DISCORD_CHANNEL_PLUGIN_SPEC, - supportedProfiles: ['default', 'account'], - manualSetupCommands: [ - '/plugin install discord@claude-plugins-official', - '/discord:configure ', - '/discord:access pair ', - '/discord:access policy allowlist', - ], - }, + config: getOfficialChannelsConfig(), + status: buildChannelsStatus(), }); }); router.put('/', (req: Request, res: Response): void => { - const { enabled, unattended } = req.body as { enabled?: unknown; unattended?: unknown }; + const { selected, unattended } = req.body as { selected?: unknown; unattended?: unknown }; - if (enabled !== undefined && typeof enabled !== 'boolean') { - res.status(400).json({ error: 'enabled must be a boolean' }); + if ( + selected !== undefined && + (!Array.isArray(selected) || selected.some((value) => typeof value !== 'string' || !isOfficialChannelId(value))) + ) { + res.status(400).json({ error: 'selected must be an array of official channel IDs' }); return; } if (unattended !== undefined && typeof unattended !== 'boolean') { @@ -59,28 +83,33 @@ router.put('/', (req: Request, res: Response): void => { try { const updated = mutateUnifiedConfig((config) => { - config.discord_channels = { - enabled: enabled ?? config.discord_channels?.enabled ?? false, - unattended: unattended ?? config.discord_channels?.unattended ?? false, + config.channels = { + selected: selected ? [...new Set(selected)] : config.channels?.selected ?? [], + unattended: unattended ?? config.channels?.unattended ?? false, }; }); - res.json({ success: true, config: updated.discord_channels }); + res.json({ success: true, config: updated.channels }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } }); -router.put('/discord/token', (req: Request, res: Response): void => { +router.put('/:channelId/token', (req: Request, res: Response): void => { + const { channelId } = req.params; const { token } = req.body as { token?: unknown }; + if (!isOfficialChannelId(channelId) || !getOfficialChannelTokenIds().includes(channelId)) { + res.status(400).json({ error: 'channelId must be a token-based official channel' }); + return; + } if (typeof token !== 'string') { res.status(400).json({ error: 'token must be a string' }); return; } try { - const tokenPath = setConfiguredDiscordBotToken(token); + const tokenPath = setConfiguredOfficialChannelToken(channelId, token); res.json({ success: true, tokenConfigured: true, tokenPath }); } catch (error) { const message = (error as Error).message; @@ -89,13 +118,20 @@ router.put('/discord/token', (req: Request, res: Response): void => { } }); -router.delete('/discord/token', (_req: Request, res: Response): void => { +router.delete('/:channelId/token', (req: Request, res: Response): void => { + const { channelId } = req.params; + + if (!isOfficialChannelId(channelId) || !getOfficialChannelTokenIds().includes(channelId)) { + res.status(400).json({ error: 'channelId must be a token-based official channel' }); + return; + } + try { - const clearedPaths = clearConfiguredDiscordBotTokenEverywhere(); + const clearedPaths = clearConfiguredOfficialChannelTokensEverywhere(channelId); res.json({ success: true, tokenConfigured: false, - tokenPath: getDiscordChannelsEnvPath(), + tokenPath: getOfficialChannelEnvPath(channelId), clearedPaths, }); } catch (error) { diff --git a/tests/unit/channels/discord-channels-runtime.test.ts b/tests/unit/channels/discord-channels-runtime.test.ts deleted file mode 100644 index 7d19024a..00000000 --- a/tests/unit/channels/discord-channels-runtime.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, it } from 'bun:test'; -import { - DISCORD_CHANNEL_PLUGIN_SPEC, - hasExplicitChannelsFlag, - hasExplicitPermissionOverride, - isDiscordChannelsSessionSupported, - resolveDiscordChannelsSyncConfigDir, - resolveDiscordChannelsLaunchPlan, -} from '../../../src/channels/discord-channels-runtime'; - -describe('discord channels runtime planning', () => { - it('supports only native Claude default/account sessions', () => { - expect(isDiscordChannelsSessionSupported('claude', 'default')).toBe(true); - expect(isDiscordChannelsSessionSupported('claude', 'account')).toBe(true); - expect(isDiscordChannelsSessionSupported('claude', 'settings')).toBe(false); - expect(isDiscordChannelsSessionSupported('droid', 'default')).toBe(false); - }); - - it('detects explicit channel and permission overrides', () => { - expect(hasExplicitChannelsFlag(['--channels', 'plugin:other'])).toBe(true); - expect(hasExplicitChannelsFlag([`--channels=${DISCORD_CHANNEL_PLUGIN_SPEC}`])).toBe(true); - expect(hasExplicitChannelsFlag(['--permission-mode', 'acceptEdits'])).toBe(false); - - expect(hasExplicitPermissionOverride(['--dangerously-skip-permissions'])).toBe(true); - expect(hasExplicitPermissionOverride(['--permission-mode', 'acceptEdits'])).toBe(true); - expect(hasExplicitPermissionOverride(['--permission-mode=acceptEdits'])).toBe(true); - }); - - it('adds the official plugin flag and optional permission bypass when eligible', () => { - const plan = resolveDiscordChannelsLaunchPlan({ - args: ['--verbose'], - config: { enabled: true, unattended: true }, - target: 'claude', - profileType: 'default', - bunAvailable: true, - tokenConfigured: true, - }); - - expect(plan.applied).toBe(true); - expect(plan.args).toEqual([ - '--verbose', - '--channels', - DISCORD_CHANNEL_PLUGIN_SPEC, - '--dangerously-skip-permissions', - ]); - expect(plan.appliedPermissionBypass).toBe(true); - }); - - it('keeps explicit permission choice and still adds the official channel when possible', () => { - const plan = resolveDiscordChannelsLaunchPlan({ - args: ['--permission-mode', 'acceptEdits'], - config: { enabled: true, unattended: true }, - target: 'claude', - profileType: 'account', - bunAvailable: true, - tokenConfigured: true, - }); - - expect(plan.applied).toBe(true); - expect(plan.args).toEqual([ - '--permission-mode', - 'acceptEdits', - '--channels', - DISCORD_CHANNEL_PLUGIN_SPEC, - ]); - expect(plan.appliedPermissionBypass).toBe(false); - }); - - it('skips when the session is incompatible or prerequisites are missing', () => { - const incompatible = resolveDiscordChannelsLaunchPlan({ - args: [], - config: { enabled: true, unattended: false }, - target: 'claude', - profileType: 'settings', - bunAvailable: true, - tokenConfigured: true, - }); - const missingBun = resolveDiscordChannelsLaunchPlan({ - args: [], - config: { enabled: true, unattended: false }, - target: 'claude', - profileType: 'default', - bunAvailable: false, - tokenConfigured: true, - }); - const missingToken = resolveDiscordChannelsLaunchPlan({ - args: [], - config: { enabled: true, unattended: false }, - target: 'claude', - profileType: 'default', - bunAvailable: true, - tokenConfigured: false, - }); - - expect(incompatible.applied).toBe(false); - expect(incompatible.skipMessage).toContain('native Claude default/account sessions'); - expect(missingBun.applied).toBe(false); - expect(missingBun.skipMessage).toContain('Bun is not installed'); - expect(missingToken.applied).toBe(false); - expect(missingToken.skipMessage).toContain('DISCORD_BOT_TOKEN is not configured'); - }); - - it('leaves explicit channel arguments untouched', () => { - const plan = resolveDiscordChannelsLaunchPlan({ - args: ['--channels', 'plugin:custom'], - config: { enabled: true, unattended: true }, - target: 'claude', - profileType: 'default', - bunAvailable: true, - tokenConfigured: true, - }); - - expect(plan.applied).toBe(false); - expect(plan.args).toEqual(['--channels', 'plugin:custom']); - expect(plan.skipMessage).toBeUndefined(); - }); - - it('falls back to process.env.CLAUDE_CONFIG_DIR for sync when no explicit dir is passed', () => { - const originalConfigDir = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = '/tmp/external-claude-config'; - - try { - expect(resolveDiscordChannelsSyncConfigDir()).toBe('/tmp/external-claude-config'); - expect(resolveDiscordChannelsSyncConfigDir('/tmp/explicit')).toBe('/tmp/explicit'); - } finally { - if (originalConfigDir !== undefined) { - process.env.CLAUDE_CONFIG_DIR = originalConfigDir; - } else { - delete process.env.CLAUDE_CONFIG_DIR; - } - } - }); -}); diff --git a/tests/unit/channels/discord-channels-store.test.ts b/tests/unit/channels/discord-channels-store.test.ts deleted file mode 100644 index fdb10717..00000000 --- a/tests/unit/channels/discord-channels-store.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { - clearConfiguredDiscordBotTokenEverywhere, - clearConfiguredDiscordBotToken, - getDiscordChannelsEnvPath, - hasConfiguredDiscordBotToken, - readConfiguredDiscordBotToken, - readDiscordBotTokenFromEnvContent, - setConfiguredDiscordBotToken, - syncDiscordChannelsEnvToConfigDir, -} from '../../../src/channels/discord-channels-store'; - -describe('discord channels token store', () => { - let tempHome = ''; - let originalHome: string | undefined; - let originalCcsHome: string | undefined; - - beforeEach(() => { - tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-discord-channels-')); - originalHome = process.env.HOME; - originalCcsHome = process.env.CCS_HOME; - process.env.HOME = tempHome; - process.env.CCS_HOME = tempHome; - }); - - afterEach(() => { - if (originalHome !== undefined) process.env.HOME = originalHome; - else delete process.env.HOME; - - if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; - else delete process.env.CCS_HOME; - - fs.rmSync(tempHome, { recursive: true, force: true }); - }); - - it('writes and reads DISCORD_BOT_TOKEN from the canonical Claude channels env file', () => { - const envPath = setConfiguredDiscordBotToken('discord-secret'); - - expect(envPath).toBe(path.join(tempHome, '.claude', 'channels', 'discord', '.env')); - expect(hasConfiguredDiscordBotToken()).toBe(true); - expect(readConfiguredDiscordBotToken()).toBe('discord-secret'); - expect(readDiscordBotTokenFromEnvContent(fs.readFileSync(envPath, 'utf8'))).toBe( - 'discord-secret' - ); - }); - - it('removes only the token entry and deletes the file when nothing remains', () => { - const envPath = getDiscordChannelsEnvPath(); - fs.mkdirSync(path.dirname(envPath), { recursive: true }); - fs.writeFileSync(envPath, '# comment\nDISCORD_BOT_TOKEN=secret\nOTHER_KEY=value\n', 'utf8'); - - clearConfiguredDiscordBotToken(); - expect(fs.readFileSync(envPath, 'utf8')).toBe('# comment\nOTHER_KEY=value\n'); - - clearConfiguredDiscordBotToken(); - fs.writeFileSync(envPath, 'DISCORD_BOT_TOKEN=secret\n', 'utf8'); - clearConfiguredDiscordBotToken(); - expect(fs.existsSync(envPath)).toBe(false); - }); - - it('syncs the canonical env file into an alternate CLAUDE_CONFIG_DIR for account sessions', () => { - setConfiguredDiscordBotToken('discord-secret'); - - const targetConfigDir = path.join(tempHome, '.ccs', 'instances', 'work'); - const targetPath = path.join(targetConfigDir, 'channels', 'discord', '.env'); - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.writeFileSync(targetPath, '# keep\nOTHER_KEY=value\n', 'utf8'); - - const result = syncDiscordChannelsEnvToConfigDir(targetConfigDir); - - expect(result.synced).toBe(true); - expect(result.targetPath).toBe(targetPath); - expect(fs.readFileSync(targetPath, 'utf8')).toBe( - '# keep\nOTHER_KEY=value\n\nDISCORD_BOT_TOKEN=discord-secret\n' - ); - expect(fs.statSync(targetPath).mode & 0o777).toBe(0o600); - }); - - it('clears previously synced copies across managed Claude config dirs', () => { - setConfiguredDiscordBotToken('discord-secret'); - - const instanceConfigDir = path.join(tempHome, '.ccs', 'instances', 'work'); - const instanceEnvPath = path.join(instanceConfigDir, 'channels', 'discord', '.env'); - - syncDiscordChannelsEnvToConfigDir(instanceConfigDir); - expect(fs.existsSync(instanceEnvPath)).toBe(true); - - const clearedPaths = clearConfiguredDiscordBotTokenEverywhere(); - - expect(clearedPaths).toContain(getDiscordChannelsEnvPath()); - expect(clearedPaths).toContain(instanceEnvPath); - expect(fs.existsSync(getDiscordChannelsEnvPath())).toBe(false); - expect(fs.existsSync(instanceEnvPath)).toBe(false); - }); -}); diff --git a/tests/unit/channels/official-channels-runtime.test.ts b/tests/unit/channels/official-channels-runtime.test.ts new file mode 100644 index 00000000..b3b0b814 --- /dev/null +++ b/tests/unit/channels/official-channels-runtime.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'bun:test'; +import { + OFFICIAL_CHANNELS, + buildOfficialChannelsArgs, + expandOfficialChannelSelection, + hasExplicitChannelsFlag, + hasExplicitPermissionOverride, + isDiscordChannelsSessionSupported, + resolveOfficialChannelsLaunchPlan, + resolveOfficialChannelsSyncConfigDir, +} from '../../../src/channels/official-channels-runtime'; + +describe('official channels runtime planning', () => { + it('supports only native Claude default/account sessions', () => { + expect(isDiscordChannelsSessionSupported('claude', 'default')).toBe(true); + expect(isDiscordChannelsSessionSupported('claude', 'account')).toBe(true); + expect(isDiscordChannelsSessionSupported('claude', 'settings')).toBe(false); + expect(isDiscordChannelsSessionSupported('droid', 'default')).toBe(false); + }); + + it('detects explicit channel and permission overrides', () => { + expect(hasExplicitChannelsFlag(['--channels', 'plugin:other'])).toBe(true); + expect( + hasExplicitChannelsFlag([`--channels=${OFFICIAL_CHANNELS.discord.pluginSpec}`]) + ).toBe(true); + expect(hasExplicitChannelsFlag(['--permission-mode', 'acceptEdits'])).toBe(false); + + expect(hasExplicitPermissionOverride(['--dangerously-skip-permissions'])).toBe(true); + expect(hasExplicitPermissionOverride(['--permission-mode', 'acceptEdits'])).toBe(true); + expect(hasExplicitPermissionOverride(['--permission-mode=acceptEdits'])).toBe(true); + }); + + it('expands channel selection and builds runtime argv in stable order', () => { + expect(expandOfficialChannelSelection('all')).toEqual(['telegram', 'discord', 'imessage']); + expect(expandOfficialChannelSelection('discord,telegram')).toEqual(['telegram', 'discord']); + expect(buildOfficialChannelsArgs(['--verbose'], ['telegram', 'discord'], true)).toEqual([ + '--verbose', + '--channels', + OFFICIAL_CHANNELS.telegram.pluginSpec, + OFFICIAL_CHANNELS.discord.pluginSpec, + '--dangerously-skip-permissions', + ]); + }); + + it('adds all ready selected channels and optional permission bypass when eligible', () => { + const plan = resolveOfficialChannelsLaunchPlan({ + args: ['--verbose'], + config: { selected: ['telegram', 'discord'], unattended: true }, + target: 'claude', + profileType: 'default', + bunAvailable: true, + channelReadiness: { + telegram: true, + discord: true, + imessage: true, + }, + }); + + expect(plan.applied).toBe(true); + expect(plan.appliedChannels).toEqual(['telegram', 'discord']); + expect(plan.wantsPermissionBypass).toBe(true); + }); + + it('keeps explicit permission choice and still returns ready channels', () => { + const plan = resolveOfficialChannelsLaunchPlan({ + args: ['--permission-mode', 'acceptEdits'], + config: { selected: ['discord'], unattended: true }, + target: 'claude', + profileType: 'account', + bunAvailable: true, + channelReadiness: { + telegram: false, + discord: true, + imessage: true, + }, + }); + + expect(plan.applied).toBe(true); + expect(plan.appliedChannels).toEqual(['discord']); + expect(plan.wantsPermissionBypass).toBe(false); + }); + + it('skips incompatible sessions and reports per-channel readiness problems', () => { + const incompatible = resolveOfficialChannelsLaunchPlan({ + args: [], + config: { selected: ['discord'], unattended: false }, + target: 'claude', + profileType: 'settings', + bunAvailable: true, + channelReadiness: { + telegram: true, + discord: true, + imessage: true, + }, + }); + const missingBun = resolveOfficialChannelsLaunchPlan({ + args: [], + config: { selected: ['discord'], unattended: false }, + target: 'claude', + profileType: 'default', + bunAvailable: false, + channelReadiness: { + telegram: true, + discord: true, + imessage: true, + }, + }); + const missingToken = resolveOfficialChannelsLaunchPlan({ + args: [], + config: { selected: ['telegram', 'discord'], unattended: false }, + target: 'claude', + profileType: 'default', + bunAvailable: true, + channelReadiness: { + telegram: false, + discord: true, + imessage: true, + }, + }); + + expect(incompatible.applied).toBe(false); + expect(incompatible.skippedMessages.join(' ')).toContain('native Claude default/account sessions'); + expect(missingBun.applied).toBe(false); + expect(missingBun.skippedMessages.join(' ')).toContain('Bun is not installed'); + expect(missingToken.applied).toBe(true); + expect(missingToken.appliedChannels).toEqual(['discord']); + expect(missingToken.skippedMessages.join(' ')).toContain('TELEGRAM_BOT_TOKEN is not configured'); + }); + + it('leaves explicit channel arguments untouched', () => { + const plan = resolveOfficialChannelsLaunchPlan({ + args: ['--channels', 'plugin:custom'], + config: { selected: ['discord'], unattended: true }, + target: 'claude', + profileType: 'default', + bunAvailable: true, + channelReadiness: { + telegram: true, + discord: true, + imessage: true, + }, + }); + + expect(plan.applied).toBe(false); + expect(plan.appliedChannels).toEqual([]); + expect(plan.skippedMessages).toEqual([]); + }); + + it('falls back to process.env.CLAUDE_CONFIG_DIR for sync when no explicit dir is passed', () => { + const originalConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = '/tmp/external-claude-config'; + + try { + expect(resolveOfficialChannelsSyncConfigDir()).toBe('/tmp/external-claude-config'); + expect(resolveOfficialChannelsSyncConfigDir('/tmp/explicit')).toBe('/tmp/explicit'); + } finally { + if (originalConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.CLAUDE_CONFIG_DIR; + } + } + }); +}); diff --git a/tests/unit/channels/official-channels-store.test.ts b/tests/unit/channels/official-channels-store.test.ts new file mode 100644 index 00000000..854629b6 --- /dev/null +++ b/tests/unit/channels/official-channels-store.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + clearConfiguredOfficialChannelToken, + clearConfiguredOfficialChannelTokensEverywhere, + getOfficialChannelEnvPath, + hasConfiguredOfficialChannelToken, + readConfiguredOfficialChannelToken, + readOfficialChannelTokenFromEnvContent, + setConfiguredOfficialChannelToken, + syncOfficialChannelEnvToConfigDir, +} from '../../../src/channels/official-channels-store'; + +describe('official channels token store', () => { + let tempHome = ''; + let originalHome: string | undefined; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-discord-channels-')); + originalHome = process.env.HOME; + originalCcsHome = process.env.CCS_HOME; + process.env.HOME = tempHome; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('writes and reads DISCORD_BOT_TOKEN from the canonical Discord env file', () => { + const envPath = setConfiguredOfficialChannelToken('discord', 'discord-secret'); + + expect(envPath).toBe(path.join(tempHome, '.claude', 'channels', 'discord', '.env')); + expect(hasConfiguredOfficialChannelToken('discord')).toBe(true); + expect(readConfiguredOfficialChannelToken('discord')).toBe('discord-secret'); + expect(readOfficialChannelTokenFromEnvContent('discord', fs.readFileSync(envPath, 'utf8'))).toBe( + 'discord-secret' + ); + }); + + it('writes and reads TELEGRAM_BOT_TOKEN from the canonical Telegram env file', () => { + const envPath = setConfiguredOfficialChannelToken('telegram', 'telegram-secret'); + + expect(envPath).toBe(path.join(tempHome, '.claude', 'channels', 'telegram', '.env')); + expect(hasConfiguredOfficialChannelToken('telegram')).toBe(true); + expect(readConfiguredOfficialChannelToken('telegram')).toBe('telegram-secret'); + }); + + it('removes only the channel token entry and deletes the file when nothing remains', () => { + const envPath = getOfficialChannelEnvPath('discord'); + fs.mkdirSync(path.dirname(envPath), { recursive: true }); + fs.writeFileSync(envPath, '# comment\nDISCORD_BOT_TOKEN=secret\nOTHER_KEY=value\n', 'utf8'); + + clearConfiguredOfficialChannelToken('discord'); + expect(fs.readFileSync(envPath, 'utf8')).toBe('# comment\nOTHER_KEY=value\n'); + + clearConfiguredOfficialChannelToken('discord'); + fs.writeFileSync(envPath, 'DISCORD_BOT_TOKEN=secret\n', 'utf8'); + clearConfiguredOfficialChannelToken('discord'); + expect(fs.existsSync(envPath)).toBe(false); + }); + + it('syncs the canonical env file into an alternate CLAUDE_CONFIG_DIR for account sessions', () => { + setConfiguredOfficialChannelToken('discord', 'discord-secret'); + + const targetConfigDir = path.join(tempHome, '.ccs', 'instances', 'work'); + const targetPath = path.join(targetConfigDir, 'channels', 'discord', '.env'); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, '# keep\nOTHER_KEY=value\n', 'utf8'); + + const result = syncOfficialChannelEnvToConfigDir('discord', targetConfigDir); + + expect(result.synced).toBe(true); + expect(result.targetPath).toBe(targetPath); + expect(fs.readFileSync(targetPath, 'utf8')).toBe( + '# keep\nOTHER_KEY=value\n\nDISCORD_BOT_TOKEN=discord-secret\n' + ); + expect(fs.statSync(targetPath).mode & 0o777).toBe(0o600); + }); + + it('clears previously synced copies across managed Claude config dirs', () => { + setConfiguredOfficialChannelToken('discord', 'discord-secret'); + setConfiguredOfficialChannelToken('telegram', 'telegram-secret'); + + const instanceConfigDir = path.join(tempHome, '.ccs', 'instances', 'work'); + const instanceEnvPath = path.join(instanceConfigDir, 'channels', 'discord', '.env'); + const telegramEnvPath = path.join(instanceConfigDir, 'channels', 'telegram', '.env'); + + syncOfficialChannelEnvToConfigDir('discord', instanceConfigDir); + syncOfficialChannelEnvToConfigDir('telegram', instanceConfigDir); + expect(fs.existsSync(instanceEnvPath)).toBe(true); + expect(fs.existsSync(telegramEnvPath)).toBe(true); + + const clearedPaths = clearConfiguredOfficialChannelTokensEverywhere(); + + expect(clearedPaths).toContain(getOfficialChannelEnvPath('discord')); + expect(clearedPaths).toContain(getOfficialChannelEnvPath('telegram')); + expect(clearedPaths).toContain(instanceEnvPath); + expect(clearedPaths).toContain(telegramEnvPath); + expect(fs.existsSync(getOfficialChannelEnvPath('discord'))).toBe(false); + expect(fs.existsSync(getOfficialChannelEnvPath('telegram'))).toBe(false); + expect(fs.existsSync(instanceEnvPath)).toBe(false); + expect(fs.existsSync(telegramEnvPath)).toBe(false); + }); +}); diff --git a/tests/unit/commands/config-channels-command.test.ts b/tests/unit/commands/config-channels-command.test.ts index 712ee5bf..a8ea3af8 100644 --- a/tests/unit/commands/config-channels-command.test.ts +++ b/tests/unit/commands/config-channels-command.test.ts @@ -2,26 +2,36 @@ import { describe, expect, it } from 'bun:test'; import { parseChannelsCommandArgs } from '../../../src/commands/config-channels-command'; describe('config channels command parser', () => { - it('parses toggles and token input', () => { + it('parses selection, unattended mode, and token input', () => { const result = parseChannelsCommandArgs([ - '--enable', + '--set', + 'telegram,discord', '--unattended', '--set-token', - 'discord-secret', + 'telegram=telegram-secret', ]); - expect(result.enable).toBe(true); + expect(result.setSelection).toBe('telegram,discord'); expect(result.unattended).toBe(true); - expect(result.setToken).toBe('discord-secret'); + expect(result.setToken).toEqual({ + channelId: 'telegram', + token: 'telegram-secret', + }); }); - it('supports inline token assignment and clear flags', () => { - const result = parseChannelsCommandArgs(['--disable', '--no-unattended', '--set-token=abc']); - const clearResult = parseChannelsCommandArgs(['--clear-token']); + it('supports inline token assignment, legacy flags, and clear-token variants', () => { + const result = parseChannelsCommandArgs([ + '--disable', + '--no-unattended', + '--set-token=abc', + ]); + const clearAll = parseChannelsCommandArgs(['--clear-token']); + const clearOne = parseChannelsCommandArgs(['--clear-token', 'discord']); expect(result.disable).toBe(true); expect(result.noUnattended).toBe(true); - expect(result.setToken).toBe('abc'); - expect(clearResult.clearToken).toBe(true); + expect(result.setToken).toEqual({ channelId: 'discord', token: 'abc' }); + expect(clearAll.clearTokenAll).toBe(true); + expect(clearOne.clearTokenChannel).toBe('discord'); }); }); diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index 2c85bdd1..aeb3a062 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -106,10 +106,10 @@ describe('unified-config-types', () => { expect(config.preferences.auto_update).toBe(true); }); - it('should default Discord Channels to disabled and attended mode', () => { + it('should default Official Channels to disabled and attended mode', () => { const config = createEmptyUnifiedConfig(); - expect(config.discord_channels?.enabled).toBe(false); - expect(config.discord_channels?.unattended).toBe(false); + expect(config.channels?.selected).toEqual([]); + expect(config.channels?.unattended).toBe(false); }); it('should have CLIProxy providers list', () => { diff --git a/ui/src/pages/settings/hooks/use-discord-channels-config.ts b/ui/src/pages/settings/hooks/use-official-channels-config.ts similarity index 57% rename from ui/src/pages/settings/hooks/use-discord-channels-config.ts rename to ui/src/pages/settings/hooks/use-official-channels-config.ts index 44e1b9ee..da182578 100644 --- a/ui/src/pages/settings/hooks/use-discord-channels-config.ts +++ b/ui/src/pages/settings/hooks/use-official-channels-config.ts @@ -1,14 +1,14 @@ import { useCallback, useState } from 'react'; -import type { DiscordChannelsConfig, DiscordChannelsStatus } from '../types'; +import type { OfficialChannelId, OfficialChannelsConfig, OfficialChannelsStatus } from '../types'; -const DEFAULT_CONFIG: DiscordChannelsConfig = { - enabled: false, +const DEFAULT_CONFIG: OfficialChannelsConfig = { + selected: [], unattended: false, }; -export function useDiscordChannelsConfig() { - const [config, setConfig] = useState(DEFAULT_CONFIG); - const [status, setStatus] = useState(null); +export function useOfficialChannelsConfig() { + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -25,12 +25,12 @@ export function useDiscordChannelsConfig() { setError(null); const res = await fetch('/api/channels'); if (!res.ok) { - throw new Error('Failed to load Discord Channels settings'); + throw new Error('Failed to load Official Channels settings'); } const data = (await res.json()) as { - config?: DiscordChannelsConfig; - status?: DiscordChannelsStatus; + config?: OfficialChannelsConfig; + status?: OfficialChannelsStatus; }; setConfig(data.config ?? DEFAULT_CONFIG); @@ -43,7 +43,7 @@ export function useDiscordChannelsConfig() { }, []); const updateConfig = useCallback( - async (updates: Partial, successMessage = 'Settings saved') => { + async (updates: Partial, successMessage = 'Settings saved') => { try { setSaving(true); setError(null); @@ -56,10 +56,10 @@ export function useDiscordChannelsConfig() { if (!res.ok) { const data = (await res.json()) as { error?: string }; - throw new Error(data.error || 'Failed to save Discord Channels settings'); + throw new Error(data.error || 'Failed to save Official Channels settings'); } - const data = (await res.json()) as { config?: DiscordChannelsConfig }; + const data = (await res.json()) as { config?: OfficialChannelsConfig }; setConfig(data.config ?? { ...config, ...updates }); flashSuccess(successMessage); } catch (err) { @@ -72,12 +72,12 @@ export function useDiscordChannelsConfig() { ); const saveToken = useCallback( - async (token: string) => { + async (channelId: OfficialChannelId, token: string) => { try { setSaving(true); setError(null); - const res = await fetch('/api/channels/discord/token', { + const res = await fetch(`/api/channels/${channelId}/token`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }), @@ -85,11 +85,11 @@ export function useDiscordChannelsConfig() { if (!res.ok) { const data = (await res.json()) as { error?: string }; - throw new Error(data.error || 'Failed to save Discord bot token'); + throw new Error(data.error || `Failed to save ${channelId} token`); } await fetchConfig(); - flashSuccess('Discord bot token saved'); + flashSuccess(`${channelId} token saved`); } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); } finally { @@ -99,28 +99,31 @@ export function useDiscordChannelsConfig() { [fetchConfig, flashSuccess] ); - const clearToken = useCallback(async () => { - try { - setSaving(true); - setError(null); + const clearToken = useCallback( + async (channelId: OfficialChannelId) => { + try { + setSaving(true); + setError(null); - const res = await fetch('/api/channels/discord/token', { - method: 'DELETE', - }); + const res = await fetch(`/api/channels/${channelId}/token`, { + method: 'DELETE', + }); - if (!res.ok) { - const data = (await res.json()) as { error?: string }; - throw new Error(data.error || 'Failed to clear Discord bot token'); + if (!res.ok) { + const data = (await res.json()) as { error?: string }; + throw new Error(data.error || `Failed to clear ${channelId} token`); + } + + await fetchConfig(); + flashSuccess(`${channelId} token cleared`); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setSaving(false); } - - await fetchConfig(); - flashSuccess('Discord bot token cleared'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setSaving(false); - } - }, [fetchConfig, flashSuccess]); + }, + [fetchConfig, flashSuccess] + ); return { config, diff --git a/ui/src/pages/settings/sections/channels.tsx b/ui/src/pages/settings/sections/channels.tsx index b3a744e5..695390e9 100644 --- a/ui/src/pages/settings/sections/channels.tsx +++ b/ui/src/pages/settings/sections/channels.tsx @@ -7,7 +7,6 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Switch } from '@/components/ui/switch'; import { AlertCircle, - Bot, CheckCircle2, MessageSquare, RefreshCw, @@ -15,8 +14,17 @@ import { ShieldAlert, Trash2, } from 'lucide-react'; -import { useDiscordChannelsConfig } from '../hooks/use-discord-channels-config'; +import { useOfficialChannelsConfig } from '../hooks/use-official-channels-config'; import { useRawConfig } from '../hooks'; +import type { OfficialChannelId } from '../types'; + +type TokenDrafts = Record; + +const EMPTY_DRAFTS: TokenDrafts = { + telegram: '', + discord: '', + imessage: '', +}; export default function ChannelsSection() { const { @@ -30,9 +38,9 @@ export default function ChannelsSection() { updateConfig, saveToken, clearToken, - } = useDiscordChannelsConfig(); + } = useOfficialChannelsConfig(); const { fetchRawConfig } = useRawConfig(); - const [tokenDraft, setTokenDraft] = useState(''); + const [tokenDrafts, setTokenDrafts] = useState(EMPTY_DRAFTS); useEffect(() => { void fetchConfig(); @@ -43,31 +51,39 @@ export default function ChannelsSection() { await Promise.all([fetchConfig(), fetchRawConfig()]); }; - const handleToggle = async ( - updates: Partial, - successMessage: string - ): Promise => { - await updateConfig(updates, successMessage); + const toggleChannel = async (channelId: OfficialChannelId, checked: boolean): Promise => { + const nextSelected = checked + ? [...new Set([...config.selected, channelId])] + : config.selected.filter((value) => value !== channelId); + + await updateConfig( + { selected: nextSelected }, + checked ? `${channelId} enabled` : `${channelId} disabled` + ); await fetchRawConfig(); }; - const handleSaveToken = async (): Promise => { - await saveToken(tokenDraft); - setTokenDraft(''); + const updateTokenDraft = (channelId: OfficialChannelId, value: string) => { + setTokenDrafts((current) => ({ ...current, [channelId]: value })); + }; + + const handleSaveToken = async (channelId: OfficialChannelId): Promise => { + await saveToken(channelId, tokenDrafts[channelId]); + setTokenDrafts((current) => ({ ...current, [channelId]: '' })); await fetchRawConfig(); }; - const handleClearToken = async (): Promise => { - await clearToken(); - setTokenDraft(''); + const handleClearToken = async (channelId: OfficialChannelId): Promise => { + await clearToken(channelId); + setTokenDrafts((current) => ({ ...current, [channelId]: '' })); await fetchRawConfig(); }; if (loading) { return ( -
+
- + Loading
@@ -79,8 +95,8 @@ export default function ChannelsSection() {
{error && ( @@ -90,7 +106,7 @@ export default function ChannelsSection() { )} {success && ( -
+
{success}
@@ -98,20 +114,22 @@ export default function ChannelsSection() {
-
+
- +

- Auto-enable Anthropic's official Discord Channels plugin for compatible Claude - sessions. CCS stores only the booleans in config.yaml; the bot token - stays in Claude's official channels env file. + Auto-enable Anthropic's official Claude channels for compatible native Claude + sessions. CCS stores only channel selection in config.yaml; bot tokens + stay in Claude's per-channel env files.

-

Runtime

-

{status?.pluginSpec ?? 'Unknown plugin'}

+

Selected

+

+ {config.selected.length > 0 ? config.selected.join(', ') : 'None'} +

Applies only to native Claude default and account{' '} sessions. @@ -125,48 +143,23 @@ export default function ChannelsSection() {

- Bot token - - {status?.tokenConfigured ? 'Configured' : 'Not configured'} - + Supported profiles + {status?.supportedProfiles.join(', ')}
-
{status?.tokenPath}
-
-
-
- -

- When enabled, CCS appends the official Discord Channels plugin at runtime unless - you already passed your own --channels flag. -

-
- - void handleToggle( - { enabled: checked }, - checked - ? 'Discord Channels auto-enable enabled' - : 'Discord Channels auto-enable disabled' - ) - } - /> -
- +
- +
-

- Opt-in only. CCS adds the bypass flag only when it is auto-enabling Discord - Channels and you did not already set a permission flag yourself. +

+ Opt-in only. CCS adds the bypass flag once when at least one selected channel + is being auto-enabled and you did not already pass a permission flag yourself.

@@ -174,83 +167,109 @@ export default function ChannelsSection() { checked={config.unattended} disabled={saving} onCheckedChange={(checked) => - void handleToggle( + void updateConfig( { unattended: checked }, - checked - ? 'Unattended Discord Channels enabled' - : 'Unattended Discord Channels disabled' + checked ? 'Unattended mode enabled' : 'Unattended mode disabled' ) } />
-
-
- - -
-

- Save DISCORD_BOT_TOKEN into Claude's official Discord channel env - file. The dashboard never reads the token value back after save. -

- setTokenDraft(event.target.value)} - placeholder={ - status?.tokenConfigured - ? 'Configured. Enter a new token to replace it.' - : 'Paste DISCORD_BOT_TOKEN' - } - disabled={saving} - /> -
- - - -
+
+ {status?.channels.map((channel) => { + const enabled = config.selected.includes(channel.id); + const tokenDraft = tokenDrafts[channel.id]; + + return ( +
+
+
+ +

{channel.summary}

+

+ {channel.pluginSpec} +

+ {channel.unavailableReason && ( +

{channel.unavailableReason}

+ )} +
+ void toggleChannel(channel.id, checked)} + /> +
+ + {channel.requiresToken && ( +
+

+ Save {channel.envKey} in Claude's official channel env + file. The dashboard never reads the token value back after save. +

+ updateTokenDraft(channel.id, event.target.value)} + placeholder={ + channel.tokenConfigured + ? `Configured. Enter a new ${channel.envKey} to replace it.` + : `Paste ${channel.envKey}` + } + disabled={saving} + /> +
+ {channel.tokenPath} +
+
+ + +
+
+ )} + +
+

Claude-side setup

+ {(channel.manualSetupCommands ?? []).map((command) => ( +
+ {command} +
+ ))} +
+
+ ); + })}
- CCS does not persist a global Claude setting for channels. It only prepares the token - file and injects runtime flags when the session is compatible and the prerequisites - are present. + CCS does not persist a global Claude setting for channels. It only prepares channel + env files and injects runtime flags when the selected channels are compatible and + ready. -
- -

- If the plugin is not ready yet, complete the official setup once inside Claude. -

-
- {(status?.manualSetupCommands ?? []).map((command) => ( -
- {command} -
- ))} -
+
+
diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index b3fbd88c..d37d018d 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -60,22 +60,34 @@ export interface GlobalEnvConfig { env: Record; } -// === Discord Channels Types === +// === Official Channels Types === -export interface DiscordChannelsConfig { - enabled: boolean; +export type OfficialChannelId = 'telegram' | 'discord' | 'imessage'; + +export interface OfficialChannelsConfig { + selected: OfficialChannelId[]; unattended: boolean; } -export interface DiscordChannelsStatus { - bunInstalled: boolean; - tokenConfigured: boolean; - tokenPath: string; +export interface OfficialChannelStatus { + id: OfficialChannelId; + displayName: string; pluginSpec: string; - supportedProfiles: string[]; + summary: string; + requiresToken: boolean; + envKey?: string; + tokenConfigured: boolean; + tokenPath?: string; + unavailableReason?: string; manualSetupCommands: string[]; } +export interface OfficialChannelsStatus { + bunInstalled: boolean; + supportedProfiles: string[]; + channels: OfficialChannelStatus[]; +} + // === Tab Types === export type SettingsTab =