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
This commit is contained in:
Tam Nhu Tran
2026-03-25 16:31:55 -04:00
parent 4bce1559dd
commit 6f1f032c63
19 changed files with 1416 additions and 725 deletions
+44 -23
View File
@@ -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<void> {
-107
View File
@@ -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,
};
}
+409
View File
@@ -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<OfficialChannelId, OfficialChannelDefinition> = {
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 <token>',
'/telegram:access pair <code>',
'/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 <token>',
'/discord:access pair <code>',
'/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<OfficialChannelId, boolean>;
}
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<OfficialChannelId>();
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 <csv>. 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 <channel>=<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 <channel> 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));
}
@@ -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;
}
+222 -76
View File
@@ -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 <csv|all>', '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 <token>', 'command')} Save DISCORD_BOT_TOKEN`);
console.log(` ${color('--clear-token', 'command')} Remove saved DISCORD_BOT_TOKEN`);
console.log(` ${color('--set-token <spec>', '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 <DISCORD_BOT_TOKEN>', 'command')}`);
console.log(` ${color('/discord:access pair <code>', '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<void> {
await initUI();
@@ -119,68 +237,96 @@ export async function handleConfigChannelsCommand(args: string[]): Promise<void>
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 <channel>=<token>)`)
);
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;
+11 -8
View File
@@ -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 <csv|all> 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 <t> Save DISCORD_BOT_TOKEN to Claude channels env');
console.log(' --clear-token Remove saved DISCORD_BOT_TOKEN');
console.log(' --set-token <s> Save channel token (telegram=<t> or discord=<t>)');
console.log(' --clear-token Remove all saved channel tokens');
console.log(' --clear-token <c> 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');
+11 -8
View File
@@ -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 <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=<token>', '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 <token>', 'Save DISCORD_BOT_TOKEN'],
['ccs config channels --clear-token', 'Remove saved token'],
['ccs config channels --set-token telegram=<token>', 'Save TELEGRAM_BOT_TOKEN'],
['ccs config channels --set-token discord=<token>', '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/<channel>/.env.'],
['', 'iMessage is macOS-only and requires local OS permissions instead of a bot token.'],
]);
// CCS Environment Variables
+50 -17
View File
@@ -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<UnifiedConfig>): ContinuityC
};
}
interface LegacyDiscordChannelsConfig {
enabled?: boolean;
unattended?: boolean;
}
function normalizeOfficialChannelsConfig(
partial: Partial<UnifiedConfig> & { 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>): 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<UnifiedConfig> & { 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,
};
}
+17 -11
View File
@@ -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 },
};
+71 -35
View File
@@ -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_BOT_TOKEN>',
'/discord:access pair <code>',
'/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) {
@@ -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;
}
}
});
});
@@ -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);
});
});
@@ -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;
}
}
});
});
@@ -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);
});
});
@@ -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');
});
});
+3 -3
View File
@@ -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', () => {
@@ -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<DiscordChannelsConfig>(DEFAULT_CONFIG);
const [status, setStatus] = useState<DiscordChannelsStatus | null>(null);
export function useOfficialChannelsConfig() {
const [config, setConfig] = useState<OfficialChannelsConfig>(DEFAULT_CONFIG);
const [status, setStatus] = useState<OfficialChannelsStatus | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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<DiscordChannelsConfig>, successMessage = 'Settings saved') => {
async (updates: Partial<OfficialChannelsConfig>, 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,
+141 -122
View File
@@ -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<OfficialChannelId, string>;
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<TokenDrafts>(EMPTY_DRAFTS);
useEffect(() => {
void fetchConfig();
@@ -43,31 +51,39 @@ export default function ChannelsSection() {
await Promise.all([fetchConfig(), fetchRawConfig()]);
};
const handleToggle = async (
updates: Partial<typeof config>,
successMessage: string
): Promise<void> => {
await updateConfig(updates, successMessage);
const toggleChannel = async (channelId: OfficialChannelId, checked: boolean): Promise<void> => {
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<void> => {
await saveToken(tokenDraft);
setTokenDraft('');
const updateTokenDraft = (channelId: OfficialChannelId, value: string) => {
setTokenDrafts((current) => ({ ...current, [channelId]: value }));
};
const handleSaveToken = async (channelId: OfficialChannelId): Promise<void> => {
await saveToken(channelId, tokenDrafts[channelId]);
setTokenDrafts((current) => ({ ...current, [channelId]: '' }));
await fetchRawConfig();
};
const handleClearToken = async (): Promise<void> => {
await clearToken();
setTokenDraft('');
const handleClearToken = async (channelId: OfficialChannelId): Promise<void> => {
await clearToken(channelId);
setTokenDrafts((current) => ({ ...current, [channelId]: '' }));
await fetchRawConfig();
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-1 items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<RefreshCw className="h-5 w-5 animate-spin" />
<span>Loading</span>
</div>
</div>
@@ -79,8 +95,8 @@ export default function ChannelsSection() {
<div
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
error || success
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-2 pointer-events-none'
? 'translate-y-0 opacity-100'
: 'pointer-events-none -translate-y-2 opacity-0'
}`}
>
{error && (
@@ -90,7 +106,7 @@ export default function ChannelsSection() {
</Alert>
)}
{success && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
<div className="flex items-center gap-2 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">{success}</span>
</div>
@@ -98,20 +114,22 @@ export default function ChannelsSection() {
</div>
<ScrollArea className="flex-1">
<div className="p-5 space-y-6">
<div className="space-y-6 p-5">
<div className="flex items-center gap-3">
<MessageSquare className="w-5 h-5 text-primary" />
<MessageSquare className="h-5 w-5 text-primary" />
<p className="text-sm text-muted-foreground">
Auto-enable Anthropic&apos;s official Discord Channels plugin for compatible Claude
sessions. CCS stores only the booleans in <code>config.yaml</code>; the bot token
stays in Claude&apos;s official channels env file.
Auto-enable Anthropic&apos;s official Claude channels for compatible native Claude
sessions. CCS stores only channel selection in <code>config.yaml</code>; bot tokens
stay in Claude&apos;s per-channel env files.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border bg-muted/30 p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Runtime</p>
<p className="mt-1 font-medium">{status?.pluginSpec ?? 'Unknown plugin'}</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Selected</p>
<p className="mt-1 font-medium">
{config.selected.length > 0 ? config.selected.join(', ') : 'None'}
</p>
<p className="mt-2 text-sm text-muted-foreground">
Applies only to native Claude <code>default</code> and <code>account</code>{' '}
sessions.
@@ -125,48 +143,23 @@ export default function ChannelsSection() {
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span>Bot token</span>
<span className={status?.tokenConfigured ? 'text-green-600' : 'text-amber-600'}>
{status?.tokenConfigured ? 'Configured' : 'Not configured'}
</span>
<span>Supported profiles</span>
<span>{status?.supportedProfiles.join(', ')}</span>
</div>
<div className="text-xs text-muted-foreground break-all">{status?.tokenPath}</div>
</div>
</div>
<div className="rounded-lg border p-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<Label className="text-base font-medium">Enable Discord Channels on launch</Label>
<p className="text-sm text-muted-foreground mt-1">
When enabled, CCS appends the official Discord Channels plugin at runtime unless
you already passed your own <code>--channels</code> flag.
</p>
</div>
<Switch
checked={config.enabled}
disabled={saving}
onCheckedChange={(checked) =>
void handleToggle(
{ enabled: checked },
checked
? 'Discord Channels auto-enable enabled'
: 'Discord Channels auto-enable disabled'
)
}
/>
</div>
<div className="rounded-lg border p-4">
<div className="flex items-start justify-between gap-4 rounded-lg bg-muted/30 p-4">
<div className="flex gap-3">
<ShieldAlert className="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<div>
<Label className="text-sm font-medium">
Also add <code>--dangerously-skip-permissions</code>
</Label>
<p className="text-sm text-muted-foreground mt-1">
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.
<p className="mt-1 text-sm text-muted-foreground">
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.
</p>
</div>
</div>
@@ -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'
)
}
/>
</div>
</div>
<div className="rounded-lg border p-4 space-y-4">
<div className="flex items-center gap-2">
<Bot className="w-4 h-4 text-primary" />
<Label className="text-base font-medium">Discord bot token</Label>
</div>
<p className="text-sm text-muted-foreground">
Save <code>DISCORD_BOT_TOKEN</code> into Claude&apos;s official Discord channel env
file. The dashboard never reads the token value back after save.
</p>
<Input
type="password"
value={tokenDraft}
onChange={(event) => setTokenDraft(event.target.value)}
placeholder={
status?.tokenConfigured
? 'Configured. Enter a new token to replace it.'
: 'Paste DISCORD_BOT_TOKEN'
}
disabled={saving}
/>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => void handleSaveToken()}
disabled={saving || !tokenDraft.trim()}
>
<Save className="w-4 h-4 mr-2" />
Save Token
</Button>
<Button
variant="outline"
onClick={() => void handleClearToken()}
disabled={saving || !status?.tokenConfigured}
>
<Trash2 className="w-4 h-4 mr-2" />
Clear Token
</Button>
<Button variant="outline" onClick={() => void refreshAll()} disabled={saving}>
<RefreshCw className={`w-4 h-4 mr-2 ${saving ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
<div className="space-y-4">
{status?.channels.map((channel) => {
const enabled = config.selected.includes(channel.id);
const tokenDraft = tokenDrafts[channel.id];
return (
<div key={channel.id} className="rounded-lg border p-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<Label className="text-base font-medium">{channel.displayName}</Label>
<p className="mt-1 text-sm text-muted-foreground">{channel.summary}</p>
<p className="mt-2 font-mono text-xs text-muted-foreground">
{channel.pluginSpec}
</p>
{channel.unavailableReason && (
<p className="mt-2 text-sm text-amber-600">{channel.unavailableReason}</p>
)}
</div>
<Switch
checked={enabled}
disabled={saving || Boolean(channel.unavailableReason)}
onCheckedChange={(checked) => void toggleChannel(channel.id, checked)}
/>
</div>
{channel.requiresToken && (
<div className="space-y-3 rounded-lg bg-muted/30 p-4">
<p className="text-sm text-muted-foreground">
Save <code>{channel.envKey}</code> in Claude&apos;s official channel env
file. The dashboard never reads the token value back after save.
</p>
<Input
type="password"
value={tokenDraft}
onChange={(event) => updateTokenDraft(channel.id, event.target.value)}
placeholder={
channel.tokenConfigured
? `Configured. Enter a new ${channel.envKey} to replace it.`
: `Paste ${channel.envKey}`
}
disabled={saving}
/>
<div className="text-xs text-muted-foreground break-all">
{channel.tokenPath}
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => void handleSaveToken(channel.id)}
disabled={saving || !tokenDraft.trim()}
>
<Save className="mr-2 h-4 w-4" />
Save Token
</Button>
<Button
variant="outline"
onClick={() => void handleClearToken(channel.id)}
disabled={saving || !channel.tokenConfigured}
>
<Trash2 className="mr-2 h-4 w-4" />
Clear Token
</Button>
</div>
</div>
)}
<div className="space-y-2">
<p className="text-sm font-medium">Claude-side setup</p>
{(channel.manualSetupCommands ?? []).map((command) => (
<div
key={command}
className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all"
>
{command}
</div>
))}
</div>
</div>
);
})}
</div>
<Alert>
<AlertDescription>
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.
</AlertDescription>
</Alert>
<div className="rounded-lg border p-4 space-y-3">
<Label className="text-base font-medium">Claude-side setup</Label>
<p className="text-sm text-muted-foreground">
If the plugin is not ready yet, complete the official setup once inside Claude.
</p>
<div className="space-y-2">
{(status?.manualSetupCommands ?? []).map((command) => (
<div
key={command}
className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all"
>
{command}
</div>
))}
</div>
<div className="flex justify-end">
<Button variant="outline" onClick={() => void refreshAll()} disabled={saving}>
<RefreshCw className={`mr-2 h-4 w-4 ${saving ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
</ScrollArea>
+20 -8
View File
@@ -60,22 +60,34 @@ export interface GlobalEnvConfig {
env: Record<string, string>;
}
// === 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 =