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