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, getWebSearchHookEnv,
ensureProfileHooks, ensureProfileHooks,
} from './utils/websearch-manager'; } 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 { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector';
import { getImageAnalysisHookEnv } from './utils/hooks'; import { getImageAnalysisHookEnv } from './utils/hooks';
import { fail, info, warn } from './utils/ui'; import { fail, info, warn } from './utils/ui';
import { isCopilotSubcommandToken } from './copilot/constants'; import { isCopilotSubcommandToken } from './copilot/constants';
import { import {
buildOfficialChannelsArgs,
getOfficialChannelDisplayName,
getOfficialChannelTokenIds,
isBunAvailable, isBunAvailable,
resolveDiscordChannelsSyncConfigDir, officialChannelRequiresMacOS,
resolveDiscordChannelsLaunchPlan, resolveOfficialChannelsLaunchPlan,
} from './channels/discord-channels-runtime'; resolveOfficialChannelsSyncConfigDir,
} from './channels/official-channels-runtime';
import { import {
hasConfiguredDiscordBotToken, getOfficialChannelReadiness,
syncDiscordChannelsEnvToConfigDir, syncOfficialChannelEnvToConfigDir,
} from './channels/discord-channels-store'; } from './channels/official-channels-store';
// Import centralized error handling // Import centralized error handling
import { handleError, runCleanup } from './errors'; import { handleError, runCleanup } from './errors';
@@ -144,39 +148,56 @@ function resolveNativeClaudeLaunchArgs(
profileType: 'default' | 'account', profileType: 'default' | 'account',
targetConfigDir?: string targetConfigDir?: string
): string[] { ): string[] {
const config = getDiscordChannelsConfig(); const config = getOfficialChannelsConfig();
const plan = resolveDiscordChannelsLaunchPlan({ const channelReadiness = {
telegram: getOfficialChannelReadiness('telegram'),
discord: getOfficialChannelReadiness('discord'),
imessage: !officialChannelRequiresMacOS('imessage') || process.platform === 'darwin',
};
const plan = resolveOfficialChannelsLaunchPlan({
args, args,
config, config,
target: 'claude', target: 'claude',
profileType, profileType,
bunAvailable: isBunAvailable(), bunAvailable: isBunAvailable(),
tokenConfigured: hasConfiguredDiscordBotToken(), channelReadiness,
}); });
if (plan.skipMessage) { for (const message of plan.skippedMessages) {
console.error(warn(plan.skipMessage)); console.error(warn(message));
} }
if (!plan.applied) { if (!plan.applied) {
return args; return args;
} }
const activeConfigDir = resolveDiscordChannelsSyncConfigDir(targetConfigDir); const activeConfigDir = resolveOfficialChannelsSyncConfigDir(targetConfigDir);
const syncedChannels = [...plan.appliedChannels];
if (activeConfigDir) { if (activeConfigDir) {
const syncResult = syncDiscordChannelsEnvToConfigDir(activeConfigDir); for (const channelId of [...syncedChannels]) {
if (!syncResult.synced && syncResult.reason !== 'already_current') { if (!getOfficialChannelTokenIds().includes(channelId)) {
const suffix = syncResult.error ? ` (${syncResult.error})` : ''; continue;
console.error( }
warn(
`Discord Channels auto-enable skipped: failed to sync token env to ${syncResult.targetPath}${suffix}` const syncResult = syncOfficialChannelEnvToConfigDir(channelId, activeConfigDir);
) if (!syncResult.synced && syncResult.reason !== 'already_current') {
); const suffix = syncResult.error ? ` (${syncResult.error})` : '';
return args; 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> { 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 * as path from 'path';
import { getCcsDir } from '../utils/config-manager'; import { getCcsDir } from '../utils/config-manager';
import { getDefaultClaudeConfigDir } from '../utils/claude-config-path'; import { getDefaultClaudeConfigDir } from '../utils/claude-config-path';
import type { OfficialChannelId } from '../config/unified-config-types';
export const DISCORD_BOT_TOKEN_ENV_KEY = 'DISCORD_BOT_TOKEN'; import {
getOfficialChannelEnvDir,
getOfficialChannelEnvKey,
getOfficialChannelTokenIds,
isOfficialChannelTokenRequired,
} from './official-channels-runtime';
export interface DiscordChannelsSyncResult { export interface DiscordChannelsSyncResult {
synced: boolean; synced: boolean;
@@ -12,8 +17,11 @@ export interface DiscordChannelsSyncResult {
error?: string; error?: string;
} }
export function getDiscordChannelsEnvPath(configDir = getDefaultClaudeConfigDir()): string { export function getOfficialChannelEnvPath(
return path.join(configDir, 'channels', 'discord', '.env'); channelId: OfficialChannelId,
configDir = getDefaultClaudeConfigDir()
): string {
return path.join(configDir, 'channels', getOfficialChannelEnvDir(channelId), '.env');
} }
function readFileIfExists(filePath: string): string | null { function readFileIfExists(filePath: string): string | null {
@@ -85,14 +93,19 @@ function writeSecureFile(filePath: string, content: string): void {
fs.chmodSync(filePath, 0o600); 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); const currentContent = readFileIfExists(filePath);
if (currentContent === null) { if (currentContent === null) {
return false; return false;
} }
const nextContent = removeEnvValue(currentContent, DISCORD_BOT_TOKEN_ENV_KEY); const nextContent = removeEnvValue(currentContent, envKey);
if (nextContent.length === 0) { if (nextContent.length === 0) {
fs.rmSync(filePath, { force: true }); fs.rmSync(filePath, { force: true });
return true; return true;
@@ -132,9 +145,17 @@ export function normalizeDiscordBotToken(value: string): string | null {
return normalized; 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/)) { 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) { if (!match) {
continue; continue;
} }
@@ -145,52 +166,75 @@ export function readDiscordBotTokenFromEnvContent(content: string): string | nul
return null; return null;
} }
export function readConfiguredDiscordBotToken(): string | null { export function readConfiguredOfficialChannelToken(channelId: OfficialChannelId): string | null {
const content = readFileIfExists(getDiscordChannelsEnvPath()); const content = readFileIfExists(getOfficialChannelEnvPath(channelId));
return content ? readDiscordBotTokenFromEnvContent(content) : null; return content ? readOfficialChannelTokenFromEnvContent(channelId, content) : null;
} }
export function hasConfiguredDiscordBotToken(): boolean { export function hasConfiguredOfficialChannelToken(channelId: OfficialChannelId): boolean {
return readConfiguredDiscordBotToken() !== null; return readConfiguredOfficialChannelToken(channelId) !== null;
} }
export function setConfiguredDiscordBotToken(token: string): string { export function setConfiguredOfficialChannelToken(
const normalized = normalizeDiscordBotToken(token); channelId: OfficialChannelId,
if (!normalized) { token: string
throw new Error('Discord bot token cannot be empty or multiline.'); ): 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) ?? ''; const currentContent = readFileIfExists(envPath) ?? '';
writeSecureFile(envPath, upsertEnvValue(currentContent, DISCORD_BOT_TOKEN_ENV_KEY, normalized)); writeSecureFile(envPath, upsertEnvValue(currentContent, envKey, normalized));
return envPath; return envPath;
} }
export function clearConfiguredDiscordBotToken(): string { export function clearConfiguredOfficialChannelToken(channelId: OfficialChannelId): string {
const envPath = getDiscordChannelsEnvPath(); const envPath = getOfficialChannelEnvPath(channelId);
clearDiscordBotTokenAtPath(envPath); clearOfficialChannelTokenAtPath(channelId, envPath);
return envPath; return envPath;
} }
export function clearConfiguredDiscordBotTokenEverywhere(): string[] { export function clearConfiguredOfficialChannelTokensEverywhere(
channelId?: OfficialChannelId
): string[] {
const clearedPaths: string[] = []; const clearedPaths: string[] = [];
const channels = channelId ? [channelId] : getOfficialChannelTokenIds();
for (const configDir of listManagedClaudeConfigDirs()) { for (const configDir of listManagedClaudeConfigDirs()) {
const envPath = getDiscordChannelsEnvPath(configDir); for (const tokenChannelId of channels) {
if (clearDiscordBotTokenAtPath(envPath)) { const envPath = getOfficialChannelEnvPath(tokenChannelId, configDir);
clearedPaths.push(envPath); if (clearOfficialChannelTokenAtPath(tokenChannelId, envPath)) {
clearedPaths.push(envPath);
}
} }
} }
return clearedPaths; return clearedPaths;
} }
export function syncDiscordChannelsEnvToConfigDir( export function syncOfficialChannelEnvToConfigDir(
channelId: OfficialChannelId,
targetConfigDir: string targetConfigDir: string
): DiscordChannelsSyncResult { ): DiscordChannelsSyncResult {
const sourcePath = getDiscordChannelsEnvPath(); const envKey = getOfficialChannelEnvKey(channelId);
const targetPath = getDiscordChannelsEnvPath(targetConfigDir); if (!envKey) {
const token = readConfiguredDiscordBotToken(); 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)) { if (!fs.existsSync(sourcePath)) {
return { synced: false, targetPath, reason: 'missing_env' }; return { synced: false, targetPath, reason: 'missing_env' };
@@ -206,7 +250,7 @@ export function syncDiscordChannelsEnvToConfigDir(
try { try {
const targetContent = readFileIfExists(targetPath) ?? ''; const targetContent = readFileIfExists(targetPath) ?? '';
writeSecureFile(targetPath, upsertEnvValue(targetContent, DISCORD_BOT_TOKEN_ENV_KEY, token)); writeSecureFile(targetPath, upsertEnvValue(targetContent, envKey, token));
return { synced: true, targetPath }; return { synced: true, targetPath };
} catch (error) { } catch (error) {
return { 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 { initUI, header, ok, info, warn, fail, subheader, color, dim } from '../utils/ui';
import { import {
getDiscordChannelsConfig, getOfficialChannelsConfig,
loadOrCreateUnifiedConfig, loadOrCreateUnifiedConfig,
updateUnifiedConfig, updateUnifiedConfig,
} from '../config/unified-config-loader'; } 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 { import {
clearConfiguredDiscordBotTokenEverywhere, clearConfiguredOfficialChannelTokensEverywhere,
getDiscordChannelsEnvPath, getOfficialChannelEnvPath,
hasConfiguredDiscordBotToken, hasConfiguredOfficialChannelToken,
setConfiguredDiscordBotToken, setConfiguredOfficialChannelToken,
} from '../channels/discord-channels-store'; } from '../channels/official-channels-store';
import { DISCORD_CHANNEL_PLUGIN_SPEC, isBunAvailable } from '../channels/discord-channels-runtime'; 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'; import { extractOption, hasAnyFlag } from './arg-extractor';
interface ChannelsCommandOptions { interface ChannelsCommandOptions {
enable: boolean; enable: boolean;
disable: boolean; disable: boolean;
clear: boolean;
unattended: boolean; unattended: boolean;
noUnattended: boolean; noUnattended: boolean;
clearToken: boolean; setSelection?: string;
setToken?: string; setSelectionMissing: boolean;
clearTokenAll: boolean;
clearTokenChannel?: OfficialChannelId;
setToken?: { channelId: OfficialChannelId; token: string };
setTokenMissing: boolean; setTokenMissing: boolean;
clearTokenInvalid?: string;
setTokenInvalid?: string;
help: boolean; 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 { export function parseChannelsCommandArgs(args: string[]): ChannelsCommandOptions {
const setSelection = extractOption(args, ['--set']);
const setToken = extractOption(args, ['--set-token']); 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 { return {
enable: hasAnyFlag(args, ['--enable']), enable: hasAnyFlag(args, ['--enable']),
disable: hasAnyFlag(args, ['--disable']), disable: hasAnyFlag(args, ['--disable']),
clear: hasAnyFlag(args, ['--clear']),
unattended: hasAnyFlag(args, ['--unattended']), unattended: hasAnyFlag(args, ['--unattended']),
noUnattended: hasAnyFlag(args, ['--no-unattended']), noUnattended: hasAnyFlag(args, ['--no-unattended']),
clearToken: hasAnyFlag(args, ['--clear-token']), setSelection: setSelection.found ? setSelection.value : undefined,
setToken: setToken.found ? setToken.value : undefined, setSelectionMissing: setSelection.found && setSelection.missingValue,
clearTokenAll,
clearTokenChannel,
clearTokenInvalid,
setToken: parsedSetToken,
setTokenMissing: setToken.found && setToken.missingValue, setTokenMissing: setToken.found && setToken.missingValue,
setTokenInvalid,
help: hasAnyFlag(args, ['--help', '-h']), help: hasAnyFlag(args, ['--help', '-h']),
}; };
} }
@@ -44,72 +128,106 @@ function showHelp(): void {
console.log(''); console.log('');
console.log(header('ccs config channels')); console.log(header('ccs config channels'));
console.log(''); console.log('');
console.log( console.log(` ${getOfficialChannelsSectionDescription()}`);
' Configure Anthropic official Discord Channels auto-enable for native Claude sessions.' console.log(` ${dim(getOfficialChannelsDocsSummary())}`);
);
console.log(''); console.log('');
console.log(subheader('Usage:')); console.log(subheader('Usage:'));
console.log(` ${color('ccs config channels', 'command')} [options]`); console.log(` ${color('ccs config channels', 'command')} [options]`);
console.log(''); console.log('');
console.log(subheader('Options:')); console.log(subheader('Options:'));
console.log(` ${color('--enable', 'command')} Enable auto-adding Discord Channels`); console.log(` ${color('--set <csv|all>', 'command')} ${getOfficialChannelsSetHelp()}`);
console.log( console.log(` ${color('--clear', 'command')} Clear all selected channels`);
` ${color('--disable', 'command')} Disable auto-adding Discord Channels` console.log(` ${color('--enable', 'command')} Legacy alias: add Discord`);
); console.log(` ${color('--disable', 'command')} Legacy alias: remove Discord`);
console.log( console.log(` ${color('--unattended', 'command')} Also add --dangerously-skip-permissions`);
` ${color('--unattended', 'command')} Also add --dangerously-skip-permissions`
);
console.log(` ${color('--no-unattended', 'command')} Disable unattended runtime flag`); console.log(` ${color('--no-unattended', 'command')} Disable unattended runtime flag`);
console.log(` ${color('--set-token <token>', 'command')} Save DISCORD_BOT_TOKEN`); console.log(` ${color('--set-token <spec>', 'command')} ${getOfficialChannelTokenHelp()}`);
console.log(` ${color('--clear-token', 'command')} Remove saved DISCORD_BOT_TOKEN`); console.log(` ${color('--clear-token [channel]', 'command')} ${getOfficialChannelClearTokenHelp()}`);
console.log(` ${color('--help, -h', 'command')} Show this help`); console.log(` ${color('--help, -h', 'command')} Show this help`);
console.log(''); console.log('');
console.log(subheader('Examples:')); console.log(subheader('Examples:'));
console.log(` $ ${color('ccs config channels', 'command')} ${dim('# Show status')}`);
console.log( console.log(
` $ ${color('ccs config channels', 'command')} ${dim('# Show status')}` ` $ ${color('ccs config channels --set telegram,discord', 'command')} ${dim('# Enable Telegram + Discord')}`
); );
console.log( 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( 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( 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(''); console.log('');
} }
function showStatus(): void { function showStatus(): void {
const config = getDiscordChannelsConfig(); const config = getOfficialChannelsConfig();
const selected = config.selected;
const bunReady = isBunAvailable(); const bunReady = isBunAvailable();
const tokenConfigured = hasConfiguredDiscordBotToken();
console.log(''); console.log('');
console.log(header('Discord Channels Configuration')); console.log(header('Official Channels Configuration'));
console.log(''); 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(` Unattended: ${config.unattended ? warn('Enabled') : info('Disabled')}`);
console.log(` Bun: ${bunReady ? ok('Installed') : warn('Missing')}`); 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('');
console.log(subheader('Applies To:')); console.log(subheader('Applies To:'));
console.log(` ${dim('Native Claude target only: default and account sessions.')}`); console.log(` ${dim(getOfficialChannelsCompatibilityMessage())}`);
console.log(` ${dim('Not applied to CLIProxy, API-key, Copilot, or Droid flows.')}`); console.log(` ${dim(`Supported profiles: ${getOfficialChannelsSupportedProfiles().join(', ')}`)}`);
console.log(''); console.log('');
console.log(subheader('Files:')); console.log(subheader('Channels:'));
console.log(` Config: ${color('~/.ccs/config.yaml', 'path')}`); for (const channelId of expandOfficialChannelSelection('all')) {
console.log(` Token: ${color(getDiscordChannelsEnvPath(), 'path')}`); 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('');
console.log(subheader('Manual Claude Setup:')); console.log(subheader('Manual Claude Setup:'));
console.log(` ${color('/plugin install discord@claude-plugins-official', 'command')}`); for (const channelId of expandOfficialChannelSelection('all')) {
console.log(` ${color('/discord:configure <DISCORD_BOT_TOKEN>', 'command')}`); console.log(` ${dim(`${getOfficialChannelDisplayName(channelId)}:`)}`);
console.log(` ${color('/discord:access pair <code>', 'command')}`); for (const command of getOfficialChannelManualSetupCommands(channelId)) {
console.log(` ${color('/discord:access policy allowlist', 'command')}`); console.log(` ${color(command, 'command')}`);
}
}
console.log(''); 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> { export async function handleConfigChannelsCommand(args: string[]): Promise<void> {
await initUI(); await initUI();
@@ -119,68 +237,96 @@ export async function handleConfigChannelsCommand(args: string[]): Promise<void>
return; return;
} }
if (options.enable && options.disable) { if (options.setSelectionMissing) {
console.error(fail('Cannot use --enable and --disable together')); console.error(fail(`--set requires a value (${getOfficialChannelChoices()} or all)`));
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
if (options.unattended && options.noUnattended) { if (options.setSelection !== undefined && !isOfficialChannelSelectionValid(options.setSelection)) {
console.error(fail('Cannot use --unattended and --no-unattended together')); console.error(fail(`Invalid --set value: ${options.setSelection} (${getOfficialChannelChoices()} or all)`));
process.exitCode = 1;
return;
}
if (options.setToken !== undefined && options.clearToken) {
console.error(fail('Cannot use --set-token and --clear-token together'));
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
if (options.setTokenMissing) { 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; process.exitCode = 1;
return; return;
} }
const config = loadOrCreateUnifiedConfig(); const config = loadOrCreateUnifiedConfig();
const nextConfig = { 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) { const explicitSelection = resolveNextSelection(options);
nextConfig.enabled = true; const hasConfigMutation =
updated = true; 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) { if (options.disable) {
nextConfig.enabled = false; nextConfig.selected = nextConfig.selected.filter((channelId) => channelId !== 'discord');
updated = true;
} }
if (options.unattended) { if (options.unattended) {
nextConfig.unattended = true; nextConfig.unattended = true;
updated = true;
} }
if (options.noUnattended) { if (options.noUnattended) {
nextConfig.unattended = false; nextConfig.unattended = false;
updated = true;
} }
try { try {
if (updated) { if (hasConfigMutation) {
updateUnifiedConfig({ discord_channels: nextConfig }); 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(ok('Configuration updated'));
console.log(''); 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) { } catch (error) {
console.error(fail((error as Error).message)); console.error(fail((error as Error).message));
process.exitCode = 1; 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('Includes a dedicated Claude IDE Extension page for VS Code-compatible hosts.');
console.log(''); console.log('');
console.log('Commands:'); console.log('Commands:');
console.log(' channels Manage Discord Channels auto-enable + bot token'); console.log(' channels Manage official Claude channels (Telegram, Discord, iMessage)');
console.log(' --enable Enable runtime auto-add for compatible Claude sessions'); console.log(' --set <csv|all> Select channels to auto-enable at runtime');
console.log(' --disable Disable runtime auto-add'); 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(' --unattended Also add --dangerously-skip-permissions at runtime');
console.log(' --set-token <t> Save DISCORD_BOT_TOKEN to Claude channels env'); console.log(' --set-token <s> Save channel token (telegram=<t> or discord=<t>)');
console.log(' --clear-token Remove saved DISCORD_BOT_TOKEN'); console.log(' --clear-token Remove all saved channel tokens');
console.log(' --clear-token <c> Remove one saved channel token');
console.log(''); console.log('');
console.log(' auth Manage dashboard authentication'); console.log(' auth Manage dashboard authentication');
console.log(' auth setup Configure username and password'); 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 --host 127.0.0.1 Restrict dashboard to this machine');
console.log(' ccs config --dev Development mode with hot reload'); console.log(' ccs config --dev Development mode with hot reload');
console.log(' ccs config auth setup Configure dashboard login'); console.log(' ccs config auth setup Configure dashboard login');
console.log(' ccs config channels Show Discord Channels status'); console.log(' ccs config channels Show Official Channels status');
console.log(' ccs config channels --enable Enable runtime auto-add'); console.log(' ccs config channels --set telegram,discord Enable Telegram + Discord');
console.log(' ccs config channels --set-token xxx Save DISCORD_BOT_TOKEN'); 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 Show image settings');
console.log(' ccs config image-analysis --enable Enable feature'); console.log(' ccs config image-analysis --enable Enable feature');
console.log(' ccs config thinking Show thinking settings'); 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', 'Open web dashboard (includes Claude IDE Extension setup page)'],
['ccs config auth setup', 'Configure dashboard login'], ['ccs config auth setup', 'Configure dashboard login'],
['ccs config auth show', 'Show dashboard auth status'], ['ccs config auth show', 'Show dashboard auth status'],
['ccs config channels', 'Show Discord Channels status'], ['ccs config channels', 'Show Official Channels status'],
['ccs config channels --enable', 'Auto-enable Discord Channels on native Claude sessions'], ['ccs config channels --set telegram,discord', 'Auto-enable Telegram + Discord'],
['ccs config channels --set-token <token>', 'Save DISCORD_BOT_TOKEN for Discord Channels'], ['ccs config channels --set-token telegram=<token>', 'Save TELEGRAM_BOT_TOKEN'],
['ccs config image-analysis', 'Show image analysis settings'], ['ccs config image-analysis', 'Show image analysis settings'],
['ccs config image-analysis --enable', 'Enable image analysis'], ['ccs config image-analysis --enable', 'Enable image analysis'],
['ccs config thinking', 'Show thinking/reasoning settings'], ['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).'], ['', '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', '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 --unattended', 'Also add --dangerously-skip-permissions'],
['ccs config channels --set-token <token>', 'Save DISCORD_BOT_TOKEN'], ['ccs config channels --set-token telegram=<token>', 'Save TELEGRAM_BOT_TOKEN'],
['ccs config channels --clear-token', 'Remove saved 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.'], ['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 // CCS Environment Variables
+50 -17
View File
@@ -21,7 +21,7 @@ import {
DEFAULT_CLIPROXY_SAFETY_CONFIG, DEFAULT_CLIPROXY_SAFETY_CONFIG,
DEFAULT_QUOTA_MANAGEMENT_CONFIG, DEFAULT_QUOTA_MANAGEMENT_CONFIG,
DEFAULT_THINKING_CONFIG, DEFAULT_THINKING_CONFIG,
DEFAULT_DISCORD_CHANNELS_CONFIG, DEFAULT_OFFICIAL_CHANNELS_CONFIG,
DEFAULT_DASHBOARD_AUTH_CONFIG, DEFAULT_DASHBOARD_AUTH_CONFIG,
DEFAULT_IMAGE_ANALYSIS_CONFIG, DEFAULT_IMAGE_ANALYSIS_CONFIG,
} from './unified-config-types'; } from './unified-config-types';
@@ -30,7 +30,8 @@ import type {
CLIProxySafetyConfig, CLIProxySafetyConfig,
GlobalEnvConfig, GlobalEnvConfig,
ThinkingConfig, ThinkingConfig,
DiscordChannelsConfig, OfficialChannelsConfig,
OfficialChannelId,
DashboardAuthConfig, DashboardAuthConfig,
ImageAnalysisConfig, ImageAnalysisConfig,
CursorConfig, CursorConfig,
@@ -38,6 +39,11 @@ import type {
} from './unified-config-types'; } from './unified-config-types';
import { validateCompositeTiers } from '../cliproxy/composite-validator'; import { validateCompositeTiers } from '../cliproxy/composite-validator';
import { isUnifiedConfigEnabled } from './feature-flags'; import { isUnifiedConfigEnabled } from './feature-flags';
import {
isOfficialChannelId,
normalizeOfficialChannelIds,
resolveLegacyDiscordSelection,
} from '../channels/official-channels-runtime';
const CONFIG_YAML = 'config.yaml'; const CONFIG_YAML = 'config.yaml';
const CONFIG_JSON = 'config.json'; 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. * Merge partial config with defaults.
* Preserves existing data while filling in missing sections. * Preserves existing data while filling in missing sections.
@@ -501,11 +531,9 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
provider_overrides: partial.thinking?.provider_overrides, provider_overrides: partial.thinking?.provider_overrides,
show_warnings: partial.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings, show_warnings: partial.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings,
}, },
discord_channels: { channels: normalizeOfficialChannelsConfig(
enabled: partial.discord_channels?.enabled ?? DEFAULT_DISCORD_CHANNELS_CONFIG.enabled, partial as Partial<UnifiedConfig> & { discord_channels?: LegacyDiscordChannelsConfig }
unattended: ),
partial.discord_channels?.unattended ?? DEFAULT_DISCORD_CHANNELS_CONFIG.unattended,
},
// Dashboard auth config - disabled by default // Dashboard auth config - disabled by default
dashboard_auth: { dashboard_auth: {
enabled: partial.dashboard_auth?.enabled ?? DEFAULT_DASHBOARD_AUTH_CONFIG.enabled, enabled: partial.dashboard_auth?.enabled ?? DEFAULT_DASHBOARD_AUTH_CONFIG.enabled,
@@ -770,20 +798,22 @@ function generateYamlWithComments(config: UnifiedConfig): string {
lines.push(''); lines.push('');
} }
// Discord Channels section // Official Channels section
if (config.discord_channels) { if (config.channels) {
lines.push('# ----------------------------------------------------------------------------'); 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('# 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('# Bot tokens live in Claude channel env files, not in config.yaml.');
lines.push('# unattended adds --dangerously-skip-permissions only when auto-enable is active.'); 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('# Compatible sessions: native Claude default/account profiles only.');
lines.push('# Configure via: ccs config channels or the Settings > Channels dashboard tab.'); lines.push('# Configure via: ccs config channels or the Settings > Channels dashboard tab.');
lines.push('# ----------------------------------------------------------------------------'); lines.push('# ----------------------------------------------------------------------------');
lines.push( lines.push(
yaml yaml
.dump( .dump(
{ discord_channels: config.discord_channels }, { channels: config.channels },
{ indent: 2, lineWidth: -1, quotingType: '"' } { indent: 2, lineWidth: -1, quotingType: '"' }
) )
.trim() .trim()
@@ -1167,15 +1197,18 @@ export function getThinkingConfig(): ThinkingConfig {
} }
/** /**
* Get Discord Channels configuration. * Get Official Channels configuration.
* Returns defaults if not configured. * Returns defaults if not configured.
*/ */
export function getDiscordChannelsConfig(): DiscordChannelsConfig { export function getOfficialChannelsConfig(): OfficialChannelsConfig {
const config = loadOrCreateUnifiedConfig(); const config = loadOrCreateUnifiedConfig();
return { return {
enabled: config.discord_channels?.enabled ?? DEFAULT_DISCORD_CHANNELS_CONFIG.enabled, selected:
unattended: config.discord_channels?.unattended ?? DEFAULT_DISCORD_CHANNELS_CONFIG.unattended, 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 9 = Real WebSearch backends (DuckDuckGo/Brave) with legacy CLI fallback
* Version 10 = Exa + Tavily WebSearch backends * Version 10 = Exa + Tavily WebSearch backends
* Version 11 = Discord Channels runtime auto-enable preferences * 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. * Supported CLIProxy providers.
@@ -696,22 +697,27 @@ export const DEFAULT_THINKING_CONFIG: ThinkingConfig = {
}; };
/** /**
* Discord Channels configuration. * Supported Anthropic official channel IDs.
* Controls runtime-only injection of Anthropic's official Discord channel plugin.
*/ */
export interface DiscordChannelsConfig { export type OfficialChannelId = 'telegram' | 'discord' | 'imessage';
/** Enable auto-adding the official Discord channel for compatible sessions */
enabled: boolean; /**
* 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 */ /** Also add --dangerously-skip-permissions when auto-enable is active */
unattended: boolean; unattended: boolean;
} }
/** /**
* Default Discord Channels configuration. * Default Official Channels configuration.
* Disabled by default because the feature requires explicit user setup. * Disabled by default because the feature requires explicit user setup.
*/ */
export const DEFAULT_DISCORD_CHANNELS_CONFIG: DiscordChannelsConfig = { export const DEFAULT_OFFICIAL_CHANNELS_CONFIG: OfficialChannelsConfig = {
enabled: false, selected: [],
unattended: false, unattended: false,
}; };
@@ -812,7 +818,7 @@ export interface UnifiedConfig {
/** Thinking/reasoning budget configuration (v8+) */ /** Thinking/reasoning budget configuration (v8+) */
thinking?: ThinkingConfig; thinking?: ThinkingConfig;
/** Discord Channels runtime auto-enable preferences (v11+) */ /** Discord Channels runtime auto-enable preferences (v11+) */
discord_channels?: DiscordChannelsConfig; channels?: OfficialChannelsConfig;
/** Dashboard authentication configuration (optional) */ /** Dashboard authentication configuration (optional) */
dashboard_auth?: DashboardAuthConfig; dashboard_auth?: DashboardAuthConfig;
/** Image analysis configuration (vision via CLIProxy) */ /** Image analysis configuration (vision via CLIProxy) */
@@ -939,7 +945,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG }, cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG },
quota_management: { ...DEFAULT_QUOTA_MANAGEMENT_CONFIG }, quota_management: { ...DEFAULT_QUOTA_MANAGEMENT_CONFIG },
thinking: { ...DEFAULT_THINKING_CONFIG }, thinking: { ...DEFAULT_THINKING_CONFIG },
discord_channels: { ...DEFAULT_DISCORD_CHANNELS_CONFIG }, channels: { ...DEFAULT_OFFICIAL_CHANNELS_CONFIG },
dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG }, dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG },
image_analysis: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG }, image_analysis: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG },
}; };
+71 -35
View File
@@ -1,25 +1,58 @@
import { Router, type Request, type Response } from 'express'; 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 { import {
clearConfiguredDiscordBotTokenEverywhere, clearConfiguredOfficialChannelTokensEverywhere,
getDiscordChannelsEnvPath, getOfficialChannelEnvPath,
hasConfiguredDiscordBotToken, getOfficialChannelReadiness,
setConfiguredDiscordBotToken, hasConfiguredOfficialChannelToken,
} from '../../channels/discord-channels-store'; setConfiguredOfficialChannelToken,
} from '../../channels/official-channels-store';
import { import {
DISCORD_CHANNEL_PLUGIN_SPEC, expandOfficialChannelSelection,
getOfficialChannelDisplayName,
getOfficialChannelEnvKey,
getOfficialChannelPluginSpec,
getOfficialChannelSummary,
getOfficialChannelUnavailableReason,
getOfficialChannelsSupportedProfiles,
getOfficialChannelManualSetupCommands,
getOfficialChannelTokenIds,
isBunAvailable, isBunAvailable,
} from '../../channels/discord-channels-runtime'; isOfficialChannelId,
} from '../../channels/official-channels-runtime';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
const router = Router(); 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) => { router.use((req: Request, res: Response, next) => {
if ( if (
requireLocalAccessWhenAuthDisabled( requireLocalAccessWhenAuthDisabled(
req, req,
res, res,
'Discord Channels settings require localhost access when dashboard auth is disabled.' 'Official Channels settings require localhost access when dashboard auth is disabled.'
) )
) { ) {
next(); next();
@@ -28,28 +61,19 @@ router.use((req: Request, res: Response, next) => {
router.get('/', (_req: Request, res: Response): void => { router.get('/', (_req: Request, res: Response): void => {
res.json({ res.json({
config: getDiscordChannelsConfig(), config: getOfficialChannelsConfig(),
status: { status: buildChannelsStatus(),
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',
],
},
}); });
}); });
router.put('/', (req: Request, res: Response): void => { 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') { if (
res.status(400).json({ error: 'enabled must be a boolean' }); 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; return;
} }
if (unattended !== undefined && typeof unattended !== 'boolean') { if (unattended !== undefined && typeof unattended !== 'boolean') {
@@ -59,28 +83,33 @@ router.put('/', (req: Request, res: Response): void => {
try { try {
const updated = mutateUnifiedConfig((config) => { const updated = mutateUnifiedConfig((config) => {
config.discord_channels = { config.channels = {
enabled: enabled ?? config.discord_channels?.enabled ?? false, selected: selected ? [...new Set(selected)] : config.channels?.selected ?? [],
unattended: unattended ?? config.discord_channels?.unattended ?? false, unattended: unattended ?? config.channels?.unattended ?? false,
}; };
}); });
res.json({ success: true, config: updated.discord_channels }); res.json({ success: true, config: updated.channels });
} catch (error) { } catch (error) {
res.status(500).json({ error: (error as Error).message }); 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 }; 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') { if (typeof token !== 'string') {
res.status(400).json({ error: 'token must be a string' }); res.status(400).json({ error: 'token must be a string' });
return; return;
} }
try { try {
const tokenPath = setConfiguredDiscordBotToken(token); const tokenPath = setConfiguredOfficialChannelToken(channelId, token);
res.json({ success: true, tokenConfigured: true, tokenPath }); res.json({ success: true, tokenConfigured: true, tokenPath });
} catch (error) { } catch (error) {
const message = (error as Error).message; 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 { try {
const clearedPaths = clearConfiguredDiscordBotTokenEverywhere(); const clearedPaths = clearConfiguredOfficialChannelTokensEverywhere(channelId);
res.json({ res.json({
success: true, success: true,
tokenConfigured: false, tokenConfigured: false,
tokenPath: getDiscordChannelsEnvPath(), tokenPath: getOfficialChannelEnvPath(channelId),
clearedPaths, clearedPaths,
}); });
} catch (error) { } 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'; import { parseChannelsCommandArgs } from '../../../src/commands/config-channels-command';
describe('config channels command parser', () => { describe('config channels command parser', () => {
it('parses toggles and token input', () => { it('parses selection, unattended mode, and token input', () => {
const result = parseChannelsCommandArgs([ const result = parseChannelsCommandArgs([
'--enable', '--set',
'telegram,discord',
'--unattended', '--unattended',
'--set-token', '--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.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', () => { it('supports inline token assignment, legacy flags, and clear-token variants', () => {
const result = parseChannelsCommandArgs(['--disable', '--no-unattended', '--set-token=abc']); const result = parseChannelsCommandArgs([
const clearResult = parseChannelsCommandArgs(['--clear-token']); '--disable',
'--no-unattended',
'--set-token=abc',
]);
const clearAll = parseChannelsCommandArgs(['--clear-token']);
const clearOne = parseChannelsCommandArgs(['--clear-token', 'discord']);
expect(result.disable).toBe(true); expect(result.disable).toBe(true);
expect(result.noUnattended).toBe(true); expect(result.noUnattended).toBe(true);
expect(result.setToken).toBe('abc'); expect(result.setToken).toEqual({ channelId: 'discord', token: 'abc' });
expect(clearResult.clearToken).toBe(true); 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); 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(); const config = createEmptyUnifiedConfig();
expect(config.discord_channels?.enabled).toBe(false); expect(config.channels?.selected).toEqual([]);
expect(config.discord_channels?.unattended).toBe(false); expect(config.channels?.unattended).toBe(false);
}); });
it('should have CLIProxy providers list', () => { it('should have CLIProxy providers list', () => {
@@ -1,14 +1,14 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import type { DiscordChannelsConfig, DiscordChannelsStatus } from '../types'; import type { OfficialChannelId, OfficialChannelsConfig, OfficialChannelsStatus } from '../types';
const DEFAULT_CONFIG: DiscordChannelsConfig = { const DEFAULT_CONFIG: OfficialChannelsConfig = {
enabled: false, selected: [],
unattended: false, unattended: false,
}; };
export function useDiscordChannelsConfig() { export function useOfficialChannelsConfig() {
const [config, setConfig] = useState<DiscordChannelsConfig>(DEFAULT_CONFIG); const [config, setConfig] = useState<OfficialChannelsConfig>(DEFAULT_CONFIG);
const [status, setStatus] = useState<DiscordChannelsStatus | null>(null); const [status, setStatus] = useState<OfficialChannelsStatus | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -25,12 +25,12 @@ export function useDiscordChannelsConfig() {
setError(null); setError(null);
const res = await fetch('/api/channels'); const res = await fetch('/api/channels');
if (!res.ok) { 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 { const data = (await res.json()) as {
config?: DiscordChannelsConfig; config?: OfficialChannelsConfig;
status?: DiscordChannelsStatus; status?: OfficialChannelsStatus;
}; };
setConfig(data.config ?? DEFAULT_CONFIG); setConfig(data.config ?? DEFAULT_CONFIG);
@@ -43,7 +43,7 @@ export function useDiscordChannelsConfig() {
}, []); }, []);
const updateConfig = useCallback( const updateConfig = useCallback(
async (updates: Partial<DiscordChannelsConfig>, successMessage = 'Settings saved') => { async (updates: Partial<OfficialChannelsConfig>, successMessage = 'Settings saved') => {
try { try {
setSaving(true); setSaving(true);
setError(null); setError(null);
@@ -56,10 +56,10 @@ export function useDiscordChannelsConfig() {
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { error?: string }; 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 }); setConfig(data.config ?? { ...config, ...updates });
flashSuccess(successMessage); flashSuccess(successMessage);
} catch (err) { } catch (err) {
@@ -72,12 +72,12 @@ export function useDiscordChannelsConfig() {
); );
const saveToken = useCallback( const saveToken = useCallback(
async (token: string) => { async (channelId: OfficialChannelId, token: string) => {
try { try {
setSaving(true); setSaving(true);
setError(null); setError(null);
const res = await fetch('/api/channels/discord/token', { const res = await fetch(`/api/channels/${channelId}/token`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }), body: JSON.stringify({ token }),
@@ -85,11 +85,11 @@ export function useDiscordChannelsConfig() {
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { error?: string }; 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(); await fetchConfig();
flashSuccess('Discord bot token saved'); flashSuccess(`${channelId} token saved`);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : 'Unknown error');
} finally { } finally {
@@ -99,28 +99,31 @@ export function useDiscordChannelsConfig() {
[fetchConfig, flashSuccess] [fetchConfig, flashSuccess]
); );
const clearToken = useCallback(async () => { const clearToken = useCallback(
try { async (channelId: OfficialChannelId) => {
setSaving(true); try {
setError(null); setSaving(true);
setError(null);
const res = await fetch('/api/channels/discord/token', { const res = await fetch(`/api/channels/${channelId}/token`, {
method: 'DELETE', method: 'DELETE',
}); });
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { error?: string }; const data = (await res.json()) as { error?: string };
throw new Error(data.error || 'Failed to clear Discord bot token'); 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(); [fetchConfig, flashSuccess]
flashSuccess('Discord bot token cleared'); );
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setSaving(false);
}
}, [fetchConfig, flashSuccess]);
return { return {
config, config,
+141 -122
View File
@@ -7,7 +7,6 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { import {
AlertCircle, AlertCircle,
Bot,
CheckCircle2, CheckCircle2,
MessageSquare, MessageSquare,
RefreshCw, RefreshCw,
@@ -15,8 +14,17 @@ import {
ShieldAlert, ShieldAlert,
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { useDiscordChannelsConfig } from '../hooks/use-discord-channels-config'; import { useOfficialChannelsConfig } from '../hooks/use-official-channels-config';
import { useRawConfig } from '../hooks'; 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() { export default function ChannelsSection() {
const { const {
@@ -30,9 +38,9 @@ export default function ChannelsSection() {
updateConfig, updateConfig,
saveToken, saveToken,
clearToken, clearToken,
} = useDiscordChannelsConfig(); } = useOfficialChannelsConfig();
const { fetchRawConfig } = useRawConfig(); const { fetchRawConfig } = useRawConfig();
const [tokenDraft, setTokenDraft] = useState(''); const [tokenDrafts, setTokenDrafts] = useState<TokenDrafts>(EMPTY_DRAFTS);
useEffect(() => { useEffect(() => {
void fetchConfig(); void fetchConfig();
@@ -43,31 +51,39 @@ export default function ChannelsSection() {
await Promise.all([fetchConfig(), fetchRawConfig()]); await Promise.all([fetchConfig(), fetchRawConfig()]);
}; };
const handleToggle = async ( const toggleChannel = async (channelId: OfficialChannelId, checked: boolean): Promise<void> => {
updates: Partial<typeof config>, const nextSelected = checked
successMessage: string ? [...new Set([...config.selected, channelId])]
): Promise<void> => { : config.selected.filter((value) => value !== channelId);
await updateConfig(updates, successMessage);
await updateConfig(
{ selected: nextSelected },
checked ? `${channelId} enabled` : `${channelId} disabled`
);
await fetchRawConfig(); await fetchRawConfig();
}; };
const handleSaveToken = async (): Promise<void> => { const updateTokenDraft = (channelId: OfficialChannelId, value: string) => {
await saveToken(tokenDraft); setTokenDrafts((current) => ({ ...current, [channelId]: value }));
setTokenDraft(''); };
const handleSaveToken = async (channelId: OfficialChannelId): Promise<void> => {
await saveToken(channelId, tokenDrafts[channelId]);
setTokenDrafts((current) => ({ ...current, [channelId]: '' }));
await fetchRawConfig(); await fetchRawConfig();
}; };
const handleClearToken = async (): Promise<void> => { const handleClearToken = async (channelId: OfficialChannelId): Promise<void> => {
await clearToken(); await clearToken(channelId);
setTokenDraft(''); setTokenDrafts((current) => ({ ...current, [channelId]: '' }));
await fetchRawConfig(); await fetchRawConfig();
}; };
if (loading) { if (loading) {
return ( 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"> <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> <span>Loading</span>
</div> </div>
</div> </div>
@@ -79,8 +95,8 @@ export default function ChannelsSection() {
<div <div
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${ className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
error || success error || success
? 'opacity-100 translate-y-0' ? 'translate-y-0 opacity-100'
: 'opacity-0 -translate-y-2 pointer-events-none' : 'pointer-events-none -translate-y-2 opacity-0'
}`} }`}
> >
{error && ( {error && (
@@ -90,7 +106,7 @@ export default function ChannelsSection() {
</Alert> </Alert>
)} )}
{success && ( {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" /> <CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">{success}</span> <span className="text-sm font-medium">{success}</span>
</div> </div>
@@ -98,20 +114,22 @@ export default function ChannelsSection() {
</div> </div>
<ScrollArea className="flex-1"> <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"> <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"> <p className="text-sm text-muted-foreground">
Auto-enable Anthropic&apos;s official Discord Channels plugin for compatible Claude Auto-enable Anthropic&apos;s official Claude channels for compatible native Claude
sessions. CCS stores only the booleans in <code>config.yaml</code>; the bot token sessions. CCS stores only channel selection in <code>config.yaml</code>; bot tokens
stays in Claude&apos;s official channels env file. stay in Claude&apos;s per-channel env files.
</p> </p>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border bg-muted/30 p-4"> <div className="rounded-lg border bg-muted/30 p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Runtime</p> <p className="text-xs uppercase tracking-wide text-muted-foreground">Selected</p>
<p className="mt-1 font-medium">{status?.pluginSpec ?? 'Unknown plugin'}</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"> <p className="mt-2 text-sm text-muted-foreground">
Applies only to native Claude <code>default</code> and <code>account</code>{' '} Applies only to native Claude <code>default</code> and <code>account</code>{' '}
sessions. sessions.
@@ -125,48 +143,23 @@ export default function ChannelsSection() {
</span> </span>
</div> </div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span>Bot token</span> <span>Supported profiles</span>
<span className={status?.tokenConfigured ? 'text-green-600' : 'text-amber-600'}> <span>{status?.supportedProfiles.join(', ')}</span>
{status?.tokenConfigured ? 'Configured' : 'Not configured'}
</span>
</div> </div>
<div className="text-xs text-muted-foreground break-all">{status?.tokenPath}</div>
</div> </div>
</div> </div>
<div className="rounded-lg border p-4 space-y-4"> <div className="rounded-lg border p-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="flex items-start justify-between gap-4 rounded-lg bg-muted/30 p-4"> <div className="flex items-start justify-between gap-4 rounded-lg bg-muted/30 p-4">
<div className="flex gap-3"> <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> <div>
<Label className="text-sm font-medium"> <Label className="text-sm font-medium">
Also add <code>--dangerously-skip-permissions</code> Also add <code>--dangerously-skip-permissions</code>
</Label> </Label>
<p className="text-sm text-muted-foreground mt-1"> <p className="mt-1 text-sm text-muted-foreground">
Opt-in only. CCS adds the bypass flag only when it is auto-enabling Discord Opt-in only. CCS adds the bypass flag once when at least one selected channel
Channels and you did not already set a permission flag yourself. is being auto-enabled and you did not already pass a permission flag yourself.
</p> </p>
</div> </div>
</div> </div>
@@ -174,83 +167,109 @@ export default function ChannelsSection() {
checked={config.unattended} checked={config.unattended}
disabled={saving} disabled={saving}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
void handleToggle( void updateConfig(
{ unattended: checked }, { unattended: checked },
checked checked ? 'Unattended mode enabled' : 'Unattended mode disabled'
? 'Unattended Discord Channels enabled'
: 'Unattended Discord Channels disabled'
) )
} }
/> />
</div> </div>
</div> </div>
<div className="rounded-lg border p-4 space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> {status?.channels.map((channel) => {
<Bot className="w-4 h-4 text-primary" /> const enabled = config.selected.includes(channel.id);
<Label className="text-base font-medium">Discord bot token</Label> const tokenDraft = tokenDrafts[channel.id];
</div>
<p className="text-sm text-muted-foreground"> return (
Save <code>DISCORD_BOT_TOKEN</code> into Claude&apos;s official Discord channel env <div key={channel.id} className="rounded-lg border p-4 space-y-4">
file. The dashboard never reads the token value back after save. <div className="flex items-start justify-between gap-4">
</p> <div>
<Input <Label className="text-base font-medium">{channel.displayName}</Label>
type="password" <p className="mt-1 text-sm text-muted-foreground">{channel.summary}</p>
value={tokenDraft} <p className="mt-2 font-mono text-xs text-muted-foreground">
onChange={(event) => setTokenDraft(event.target.value)} {channel.pluginSpec}
placeholder={ </p>
status?.tokenConfigured {channel.unavailableReason && (
? 'Configured. Enter a new token to replace it.' <p className="mt-2 text-sm text-amber-600">{channel.unavailableReason}</p>
: 'Paste DISCORD_BOT_TOKEN' )}
} </div>
disabled={saving} <Switch
/> checked={enabled}
<div className="flex flex-wrap gap-2"> disabled={saving || Boolean(channel.unavailableReason)}
<Button onCheckedChange={(checked) => void toggleChannel(channel.id, checked)}
onClick={() => void handleSaveToken()} />
disabled={saving || !tokenDraft.trim()} </div>
>
<Save className="w-4 h-4 mr-2" /> {channel.requiresToken && (
Save Token <div className="space-y-3 rounded-lg bg-muted/30 p-4">
</Button> <p className="text-sm text-muted-foreground">
<Button Save <code>{channel.envKey}</code> in Claude&apos;s official channel env
variant="outline" file. The dashboard never reads the token value back after save.
onClick={() => void handleClearToken()} </p>
disabled={saving || !status?.tokenConfigured} <Input
> type="password"
<Trash2 className="w-4 h-4 mr-2" /> value={tokenDraft}
Clear Token onChange={(event) => updateTokenDraft(channel.id, event.target.value)}
</Button> placeholder={
<Button variant="outline" onClick={() => void refreshAll()} disabled={saving}> channel.tokenConfigured
<RefreshCw className={`w-4 h-4 mr-2 ${saving ? 'animate-spin' : ''}`} /> ? `Configured. Enter a new ${channel.envKey} to replace it.`
Refresh : `Paste ${channel.envKey}`
</Button> }
</div> 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> </div>
<Alert> <Alert>
<AlertDescription> <AlertDescription>
CCS does not persist a global Claude setting for channels. It only prepares the token CCS does not persist a global Claude setting for channels. It only prepares channel
file and injects runtime flags when the session is compatible and the prerequisites env files and injects runtime flags when the selected channels are compatible and
are present. ready.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<div className="rounded-lg border p-4 space-y-3"> <div className="flex justify-end">
<Label className="text-base font-medium">Claude-side setup</Label> <Button variant="outline" onClick={() => void refreshAll()} disabled={saving}>
<p className="text-sm text-muted-foreground"> <RefreshCw className={`mr-2 h-4 w-4 ${saving ? 'animate-spin' : ''}`} />
If the plugin is not ready yet, complete the official setup once inside Claude. Refresh
</p> </Button>
<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> </div>
</div> </div>
</ScrollArea> </ScrollArea>
+20 -8
View File
@@ -60,22 +60,34 @@ export interface GlobalEnvConfig {
env: Record<string, string>; env: Record<string, string>;
} }
// === Discord Channels Types === // === Official Channels Types ===
export interface DiscordChannelsConfig { export type OfficialChannelId = 'telegram' | 'discord' | 'imessage';
enabled: boolean;
export interface OfficialChannelsConfig {
selected: OfficialChannelId[];
unattended: boolean; unattended: boolean;
} }
export interface DiscordChannelsStatus { export interface OfficialChannelStatus {
bunInstalled: boolean; id: OfficialChannelId;
tokenConfigured: boolean; displayName: string;
tokenPath: string;
pluginSpec: string; pluginSpec: string;
supportedProfiles: string[]; summary: string;
requiresToken: boolean;
envKey?: string;
tokenConfigured: boolean;
tokenPath?: string;
unavailableReason?: string;
manualSetupCommands: string[]; manualSetupCommands: string[];
} }
export interface OfficialChannelsStatus {
bunInstalled: boolean;
supportedProfiles: string[];
channels: OfficialChannelStatus[];
}
// === Tab Types === // === Tab Types ===
export type SettingsTab = export type SettingsTab =