diff --git a/src/ccs.ts b/src/ccs.ts index 5e412bef..e9361348 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -29,11 +29,20 @@ import { getWebSearchHookEnv, ensureProfileHooks, } from './utils/websearch-manager'; -import { getGlobalEnvConfig } from './config/unified-config-loader'; +import { getGlobalEnvConfig, getDiscordChannelsConfig } 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 { + isBunAvailable, + resolveDiscordChannelsSyncConfigDir, + resolveDiscordChannelsLaunchPlan, +} from './channels/discord-channels-runtime'; +import { + hasConfiguredDiscordBotToken, + syncDiscordChannelsEnvToConfigDir, +} from './channels/discord-channels-store'; // Import centralized error handling import { handleError, runCleanup } from './errors'; @@ -130,6 +139,46 @@ async function showCachedUpdateNotification(): Promise { return false; } +function resolveNativeClaudeLaunchArgs( + args: string[], + profileType: 'default' | 'account', + targetConfigDir?: string +): string[] { + const config = getDiscordChannelsConfig(); + const plan = resolveDiscordChannelsLaunchPlan({ + args, + config, + target: 'claude', + profileType, + bunAvailable: isBunAvailable(), + tokenConfigured: hasConfiguredDiscordBotToken(), + }); + + if (plan.skipMessage) { + console.error(warn(plan.skipMessage)); + } + + if (!plan.applied) { + return args; + } + + const activeConfigDir = resolveDiscordChannelsSyncConfigDir(targetConfigDir); + 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; + } + } + + return plan.args; +} + async function main(): Promise { // Register target adapters registerTarget(new ClaudeAdapter()); @@ -838,7 +887,8 @@ async function main(): Promise { CCS_WEBSEARCH_SKIP: '1', CCS_IMAGE_ANALYSIS_SKIP: '1', }; - execClaude(claudeCli, remainingArgs, envVars); + const launchArgs = resolveNativeClaudeLaunchArgs(remainingArgs, 'account', instancePath); + execClaude(claudeCli, launchArgs, envVars); } else { // DEFAULT: No profile configured, use Claude's own defaults // Skip WebSearch hook - native Claude has server-side WebSearch @@ -906,7 +956,12 @@ async function main(): Promise { return; } - execClaude(claudeCli, remainingArgs, envVars); + const launchArgs = resolveNativeClaudeLaunchArgs( + remainingArgs, + 'default', + envVars.CLAUDE_CONFIG_DIR + ); + execClaude(claudeCli, launchArgs, envVars); } } catch (error) { const err = error as ProfileError; diff --git a/src/channels/discord-channels-runtime.ts b/src/channels/discord-channels-runtime.ts new file mode 100644 index 00000000..e2bccc0b --- /dev/null +++ b/src/channels/discord-channels-runtime.ts @@ -0,0 +1,107 @@ +import { spawnSync } from 'child_process'; +import type { DiscordChannelsConfig } from '../config/unified-config-types'; +import type { TargetType } from '../targets/target-adapter'; +import type { ProfileType } from '../types/profile'; + +export const DISCORD_CHANNEL_PLUGIN_SPEC = 'plugin:discord@claude-plugins-official'; + +export interface DiscordChannelsLaunchPlan { + args: string[]; + applied: boolean; + appliedPermissionBypass: boolean; + skipMessage?: string; +} + +interface DiscordChannelsLaunchInput { + args: string[]; + config: DiscordChannelsConfig; + target: TargetType; + profileType: ProfileType; + bunAvailable: boolean; + tokenConfigured: boolean; +} + +export function isBunAvailable(): boolean { + const result = spawnSync('bun', ['--version'], { stdio: 'ignore' }); + return result.status === 0; +} + +export function isDiscordChannelsSessionSupported( + target: TargetType, + profileType: ProfileType +): boolean { + return target === 'claude' && (profileType === 'default' || profileType === 'account'); +} + +export function hasExplicitChannelsFlag(args: string[]): boolean { + return args.some((arg) => arg === '--channels' || arg.startsWith('--channels=')); +} + +export function hasExplicitPermissionOverride(args: string[]): boolean { + return args.some( + (arg) => + arg === '--dangerously-skip-permissions' || + arg === '--permission-mode' || + arg.startsWith('--permission-mode=') + ); +} + +export function resolveDiscordChannelsSyncConfigDir(targetConfigDir?: string): string | undefined { + return targetConfigDir ?? process.env.CLAUDE_CONFIG_DIR; +} + +export function resolveDiscordChannelsLaunchPlan( + input: DiscordChannelsLaunchInput +): DiscordChannelsLaunchPlan { + const { args, config, target, profileType, bunAvailable, tokenConfigured } = input; + + if (!config.enabled) { + return { args, applied: false, appliedPermissionBypass: false }; + } + + if (!isDiscordChannelsSessionSupported(target, profileType)) { + return { + args, + applied: false, + appliedPermissionBypass: false, + skipMessage: + 'Discord Channels auto-enable only applies to native Claude default/account sessions.', + }; + } + + if (hasExplicitChannelsFlag(args)) { + return { args, applied: false, appliedPermissionBypass: false }; + } + + if (!bunAvailable) { + return { + args, + applied: false, + appliedPermissionBypass: false, + skipMessage: 'Discord Channels auto-enable skipped because Bun is not installed.', + }; + } + + if (!tokenConfigured) { + return { + args, + applied: false, + appliedPermissionBypass: false, + skipMessage: + 'Discord Channels auto-enable skipped because DISCORD_BOT_TOKEN is not configured.', + }; + } + + const nextArgs = [...args, '--channels', DISCORD_CHANNEL_PLUGIN_SPEC]; + const canApplyPermissionBypass = config.unattended && !hasExplicitPermissionOverride(args); + + if (canApplyPermissionBypass) { + nextArgs.push('--dangerously-skip-permissions'); + } + + return { + args: nextArgs, + applied: true, + appliedPermissionBypass: canApplyPermissionBypass, + }; +} diff --git a/src/channels/discord-channels-store.ts b/src/channels/discord-channels-store.ts new file mode 100644 index 00000000..4472da94 --- /dev/null +++ b/src/channels/discord-channels-store.ts @@ -0,0 +1,219 @@ +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'; + +export interface DiscordChannelsSyncResult { + synced: boolean; + targetPath: string; + reason?: 'missing_env' | 'missing_token' | 'already_current' | 'write_failed'; + error?: string; +} + +export function getDiscordChannelsEnvPath(configDir = getDefaultClaudeConfigDir()): string { + return path.join(configDir, 'channels', 'discord', '.env'); +} + +function readFileIfExists(filePath: string): string | null { + return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : null; +} + +function parseEnvValue(rawValue: string): string { + const value = rawValue.trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1).trim(); + } + return value; +} + +function formatEnvValue(value: string): string { + return /^[A-Za-z0-9._:-]+$/.test(value) ? value : JSON.stringify(value); +} + +function upsertEnvValue(content: string, key: string, value: string): string { + const lines = content.length > 0 ? content.split(/\r?\n/) : []; + const nextLines: string[] = []; + let replaced = false; + + for (const line of lines) { + if (/^\s*$/.test(line) && nextLines.length === 0) { + continue; + } + if (new RegExp(`^\\s*${key}\\s*=`).test(line)) { + nextLines.push(`${key}=${formatEnvValue(value)}`); + replaced = true; + continue; + } + nextLines.push(line); + } + + if (!replaced) { + if (nextLines.length > 0 && nextLines[nextLines.length - 1] !== '') { + nextLines.push(''); + } + nextLines.push(`${key}=${formatEnvValue(value)}`); + } + + return `${nextLines.join('\n').replace(/\n+$/u, '')}\n`; +} + +function removeEnvValue(content: string, key: string): string { + const nextLines = content + .split(/\r?\n/) + .filter((line) => !new RegExp(`^\\s*${key}\\s*=`).test(line)); + + while (nextLines.length > 0 && /^\s*$/.test(nextLines[0] ?? '')) { + nextLines.shift(); + } + while (nextLines.length > 0 && /^\s*$/.test(nextLines[nextLines.length - 1] ?? '')) { + nextLines.pop(); + } + + return nextLines.length > 0 ? `${nextLines.join('\n')}\n` : ''; +} + +function writeSecureFile(filePath: string, content: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); + const tempPath = `${filePath}.tmp`; + fs.writeFileSync(tempPath, content, { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(tempPath, filePath); + fs.chmodSync(filePath, 0o600); +} + +function clearDiscordBotTokenAtPath(filePath: string): boolean { + const currentContent = readFileIfExists(filePath); + + if (currentContent === null) { + return false; + } + + const nextContent = removeEnvValue(currentContent, DISCORD_BOT_TOKEN_ENV_KEY); + if (nextContent.length === 0) { + fs.rmSync(filePath, { force: true }); + return true; + } + + writeSecureFile(filePath, nextContent); + return true; +} + +function listManagedClaudeConfigDirs(): string[] { + const dirs = new Set([getDefaultClaudeConfigDir()]); + const processConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim(); + + if (processConfigDir) { + dirs.add(path.resolve(processConfigDir)); + } + + const instancesDir = path.join(getCcsDir(), 'instances'); + if (!fs.existsSync(instancesDir)) { + return [...dirs]; + } + + for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + dirs.add(path.join(instancesDir, entry.name)); + } + } + + return [...dirs]; +} + +export function normalizeDiscordBotToken(value: string): string | null { + const normalized = value.trim(); + if (!normalized || /[\r\n]/.test(normalized)) { + return null; + } + return normalized; +} + +export function readDiscordBotTokenFromEnvContent(content: string): string | null { + for (const line of content.split(/\r?\n/)) { + const match = line.match(/^\s*DISCORD_BOT_TOKEN\s*=\s*(.*)\s*$/); + if (!match) { + continue; + } + const parsed = parseEnvValue(match[1] ?? ''); + return parsed.length > 0 ? parsed : null; + } + + return null; +} + +export function readConfiguredDiscordBotToken(): string | null { + const content = readFileIfExists(getDiscordChannelsEnvPath()); + return content ? readDiscordBotTokenFromEnvContent(content) : null; +} + +export function hasConfiguredDiscordBotToken(): boolean { + return readConfiguredDiscordBotToken() !== null; +} + +export function setConfiguredDiscordBotToken(token: string): string { + const normalized = normalizeDiscordBotToken(token); + if (!normalized) { + throw new Error('Discord bot token cannot be empty or multiline.'); + } + + const envPath = getDiscordChannelsEnvPath(); + const currentContent = readFileIfExists(envPath) ?? ''; + writeSecureFile(envPath, upsertEnvValue(currentContent, DISCORD_BOT_TOKEN_ENV_KEY, normalized)); + return envPath; +} + +export function clearConfiguredDiscordBotToken(): string { + const envPath = getDiscordChannelsEnvPath(); + clearDiscordBotTokenAtPath(envPath); + return envPath; +} + +export function clearConfiguredDiscordBotTokenEverywhere(): string[] { + const clearedPaths: string[] = []; + + for (const configDir of listManagedClaudeConfigDirs()) { + const envPath = getDiscordChannelsEnvPath(configDir); + if (clearDiscordBotTokenAtPath(envPath)) { + clearedPaths.push(envPath); + } + } + + return clearedPaths; +} + +export function syncDiscordChannelsEnvToConfigDir( + targetConfigDir: string +): DiscordChannelsSyncResult { + const sourcePath = getDiscordChannelsEnvPath(); + const targetPath = getDiscordChannelsEnvPath(targetConfigDir); + const token = readConfiguredDiscordBotToken(); + + if (!fs.existsSync(sourcePath)) { + return { synced: false, targetPath, reason: 'missing_env' }; + } + + if (!token) { + return { synced: false, targetPath, reason: 'missing_token' }; + } + + if (path.resolve(sourcePath) === path.resolve(targetPath)) { + return { synced: false, targetPath, reason: 'already_current' }; + } + + try { + const targetContent = readFileIfExists(targetPath) ?? ''; + writeSecureFile(targetPath, upsertEnvValue(targetContent, DISCORD_BOT_TOKEN_ENV_KEY, token)); + return { synced: true, targetPath }; + } catch (error) { + return { + synced: false, + targetPath, + reason: 'write_failed', + error: (error as Error).message, + }; + } +} diff --git a/src/commands/config-channels-command.ts b/src/commands/config-channels-command.ts new file mode 100644 index 00000000..12bad174 --- /dev/null +++ b/src/commands/config-channels-command.ts @@ -0,0 +1,191 @@ +import { initUI, header, ok, info, warn, fail, subheader, color, dim } from '../utils/ui'; +import { + getDiscordChannelsConfig, + loadOrCreateUnifiedConfig, + updateUnifiedConfig, +} from '../config/unified-config-loader'; +import { DEFAULT_DISCORD_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'; +import { extractOption, hasAnyFlag } from './arg-extractor'; + +interface ChannelsCommandOptions { + enable: boolean; + disable: boolean; + unattended: boolean; + noUnattended: boolean; + clearToken: boolean; + setToken?: string; + setTokenMissing: boolean; + help: boolean; +} + +export function parseChannelsCommandArgs(args: string[]): ChannelsCommandOptions { + const setToken = extractOption(args, ['--set-token']); + + return { + enable: hasAnyFlag(args, ['--enable']), + disable: hasAnyFlag(args, ['--disable']), + unattended: hasAnyFlag(args, ['--unattended']), + noUnattended: hasAnyFlag(args, ['--no-unattended']), + clearToken: hasAnyFlag(args, ['--clear-token']), + setToken: setToken.found ? setToken.value : undefined, + setTokenMissing: setToken.found && setToken.missingValue, + help: hasAnyFlag(args, ['--help', '-h']), + }; +} + +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(''); + 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('--no-unattended', 'command')} Disable unattended runtime flag`); + console.log(` ${color('--set-token ', 'command')} Save DISCORD_BOT_TOKEN`); + console.log(` ${color('--clear-token', 'command')} Remove saved DISCORD_BOT_TOKEN`); + console.log(` ${color('--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 --enable', 'command')} ${dim('# Auto-enable Discord Channels')}` + ); + console.log( + ` $ ${color('ccs config channels --unattended', 'command')} ${dim('# Also skip permissions prompts')}` + ); + console.log( + ` $ ${color('ccs config channels --set-token xxxxxx', 'command')} ${dim('# Save bot token')}` + ); + console.log(''); +} + +function showStatus(): void { + const config = getDiscordChannelsConfig(); + const bunReady = isBunAvailable(); + const tokenConfigured = hasConfiguredDiscordBotToken(); + + console.log(''); + console.log(header('Discord Channels Configuration')); + console.log(''); + console.log(` Status: ${config.enabled ? ok('Enabled') : 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(''); + console.log(subheader('Files:')); + console.log(` Config: ${color('~/.ccs/config.yaml', 'path')}`); + console.log(` Token: ${color(getDiscordChannelsEnvPath(), 'path')}`); + console.log(''); + console.log(subheader('Manual Claude Setup:')); + console.log(` ${color('/plugin install discord@claude-plugins-official', 'command')}`); + console.log(` ${color('/discord:configure ', 'command')}`); + console.log(` ${color('/discord:access pair ', 'command')}`); + console.log(` ${color('/discord:access policy allowlist', 'command')}`); + console.log(''); +} + +export async function handleConfigChannelsCommand(args: string[]): Promise { + await initUI(); + + const options = parseChannelsCommandArgs(args); + if (options.help) { + showHelp(); + return; + } + + if (options.enable && options.disable) { + console.error(fail('Cannot use --enable and --disable together')); + 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')); + process.exitCode = 1; + return; + } + if (options.setTokenMissing) { + console.error(fail('--set-token requires a token value')); + process.exitCode = 1; + return; + } + + const config = loadOrCreateUnifiedConfig(); + const nextConfig = { + ...(config.discord_channels ?? DEFAULT_DISCORD_CHANNELS_CONFIG), + }; + let updated = false; + + if (options.enable) { + nextConfig.enabled = true; + updated = true; + } + if (options.disable) { + nextConfig.enabled = false; + updated = true; + } + if (options.unattended) { + nextConfig.unattended = true; + updated = true; + } + if (options.noUnattended) { + nextConfig.unattended = false; + updated = true; + } + + try { + if (updated) { + updateUnifiedConfig({ discord_channels: nextConfig }); + 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; + return; + } + + showStatus(); +} diff --git a/src/commands/config-command-options.ts b/src/commands/config-command-options.ts index 1e19e5db..488bcdb3 100644 --- a/src/commands/config-command-options.ts +++ b/src/commands/config-command-options.ts @@ -83,6 +83,13 @@ 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(' --unattended Also add --dangerously-skip-permissions at runtime'); + console.log(' --set-token Save DISCORD_BOT_TOKEN to Claude channels env'); + console.log(' --clear-token Remove saved DISCORD_BOT_TOKEN'); + console.log(''); console.log(' auth Manage dashboard authentication'); console.log(' auth setup Configure username and password'); console.log(' auth show Display current auth status'); @@ -120,6 +127,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 image-analysis Show image settings'); console.log(' ccs config image-analysis --enable Enable feature'); console.log(' ccs config thinking Show thinking settings'); diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index 1965ce86..77088ebf 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -24,6 +24,13 @@ import { import { parseConfigCommandArgs, showConfigCommandHelp } from './config-command-options'; const CONFIG_SUBCOMMAND_ROUTES: readonly NamedCommandRoute[] = [ + { + name: 'channels', + handle: async (args) => { + const { handleConfigChannelsCommand } = await import('./config-channels-command'); + await handleConfigChannelsCommand(args); + }, + }, { name: 'auth', handle: async (args) => { diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 0d8c0e26..73bc63e8 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -311,6 +311,9 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs config', 'Open web dashboard (includes Claude IDE Extension setup page)'], ['ccs config auth setup', 'Configure dashboard login'], ['ccs config auth show', 'Show dashboard auth status'], + ['ccs config channels', 'Show Discord Channels status'], + ['ccs config channels --enable', 'Auto-enable Discord Channels on native Claude sessions'], + ['ccs config channels --set-token ', 'Save DISCORD_BOT_TOKEN for Discord Channels'], ['ccs config image-analysis', 'Show image analysis settings'], ['ccs config image-analysis --enable', 'Enable image analysis'], ['ccs config thinking', 'Show thinking/reasoning settings'], @@ -466,6 +469,17 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['', 'providers (agy, gemini, codex, kiro, ghcp).'], ]); + printSubSection('Discord Channels (official Claude plugin)', [ + ['ccs config channels', 'Show current status'], + ['ccs config channels --enable', 'Auto-add Discord Channels on native Claude sessions'], + ['ccs config channels --unattended', 'Also add --dangerously-skip-permissions'], + ['ccs config channels --set-token ', 'Save DISCORD_BOT_TOKEN'], + ['ccs config channels --clear-token', 'Remove saved token'], + ['', ''], + ['Note:', 'Runtime-only. Applies to native Claude default/account sessions.'], + ['', 'CCS stores the token in ~/.claude/channels/discord/.env.'], + ]); + // CCS Environment Variables printSubSection('Environment Variables', [ ['CCS_DIR', 'Override CCS config directory (default: ~/.ccs)'], diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index a95cb2c5..0b922386 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -21,6 +21,7 @@ import { DEFAULT_CLIPROXY_SAFETY_CONFIG, DEFAULT_QUOTA_MANAGEMENT_CONFIG, DEFAULT_THINKING_CONFIG, + DEFAULT_DISCORD_CHANNELS_CONFIG, DEFAULT_DASHBOARD_AUTH_CONFIG, DEFAULT_IMAGE_ANALYSIS_CONFIG, } from './unified-config-types'; @@ -29,6 +30,7 @@ import type { CLIProxySafetyConfig, GlobalEnvConfig, ThinkingConfig, + DiscordChannelsConfig, DashboardAuthConfig, ImageAnalysisConfig, CursorConfig, @@ -499,6 +501,11 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { provider_overrides: partial.thinking?.provider_overrides, show_warnings: partial.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings, }, + discord_channels: { + enabled: partial.discord_channels?.enabled ?? DEFAULT_DISCORD_CHANNELS_CONFIG.enabled, + unattended: + partial.discord_channels?.unattended ?? DEFAULT_DISCORD_CHANNELS_CONFIG.unattended, + }, // Dashboard auth config - disabled by default dashboard_auth: { enabled: partial.dashboard_auth?.enabled ?? DEFAULT_DASHBOARD_AUTH_CONFIG.enabled, @@ -763,6 +770,27 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Discord Channels section + if (config.discord_channels) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Discord Channels: Runtime auto-enable for Anthropic official Discord plugin'); + 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('# 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 }, + { indent: 2, lineWidth: -1, quotingType: '"' } + ) + .trim() + ); + lines.push(''); + } + // Dashboard auth section (only if configured) if (config.dashboard_auth?.enabled) { lines.push('# ----------------------------------------------------------------------------'); @@ -1138,6 +1166,19 @@ export function getThinkingConfig(): ThinkingConfig { }; } +/** + * Get Discord Channels configuration. + * Returns defaults if not configured. + */ +export function getDiscordChannelsConfig(): DiscordChannelsConfig { + const config = loadOrCreateUnifiedConfig(); + + return { + enabled: config.discord_channels?.enabled ?? DEFAULT_DISCORD_CHANNELS_CONFIG.enabled, + unattended: config.discord_channels?.unattended ?? DEFAULT_DISCORD_CHANNELS_CONFIG.unattended, + }; +} + /** * Get dashboard_auth configuration with ENV var override. * Priority: ENV vars > config.yaml > defaults diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index bf96e4a3..acaeaeae 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -24,8 +24,9 @@ import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; * Version 8 = Thinking/reasoning budget configuration * 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 */ -export const UNIFIED_CONFIG_VERSION = 10; +export const UNIFIED_CONFIG_VERSION = 11; /** * Supported CLIProxy providers. @@ -694,6 +695,26 @@ export const DEFAULT_THINKING_CONFIG: ThinkingConfig = { show_warnings: true, }; +/** + * Discord Channels configuration. + * Controls runtime-only injection of Anthropic's official Discord channel plugin. + */ +export interface DiscordChannelsConfig { + /** Enable auto-adding the official Discord channel for compatible sessions */ + enabled: boolean; + /** Also add --dangerously-skip-permissions when auto-enable is active */ + unattended: boolean; +} + +/** + * Default Discord Channels configuration. + * Disabled by default because the feature requires explicit user setup. + */ +export const DEFAULT_DISCORD_CHANNELS_CONFIG: DiscordChannelsConfig = { + enabled: false, + unattended: false, +}; + /** * Dashboard authentication configuration. * Optional login protection for CCS dashboard. @@ -790,6 +811,8 @@ export interface UnifiedConfig { quota_management?: QuotaManagementConfig; /** Thinking/reasoning budget configuration (v8+) */ thinking?: ThinkingConfig; + /** Discord Channels runtime auto-enable preferences (v11+) */ + discord_channels?: DiscordChannelsConfig; /** Dashboard authentication configuration (optional) */ dashboard_auth?: DashboardAuthConfig; /** Image analysis configuration (vision via CLIProxy) */ @@ -916,6 +939,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 }, dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG }, image_analysis: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG }, }; diff --git a/src/utils/claude-config-path.ts b/src/utils/claude-config-path.ts index 0cd853ae..351c5be4 100644 --- a/src/utils/claude-config-path.ts +++ b/src/utils/claude-config-path.ts @@ -1,6 +1,15 @@ import * as path from 'path'; import { getCcsHome } from './config-manager'; +/** + * Resolve the canonical default Claude config directory. + * Ignores CLAUDE_CONFIG_DIR so CCS can keep a stable source of truth + * for shared plugin/channel state while still honoring test/dev home overrides. + */ +export function getDefaultClaudeConfigDir(): string { + return path.join(getCcsHome(), '.claude'); +} + /** * Resolve Claude config directory with test/dev overrides. * Precedence: @@ -13,7 +22,7 @@ export function getClaudeConfigDir(): string { return path.resolve(process.env.CLAUDE_CONFIG_DIR); } - return path.join(getCcsHome(), '.claude'); + return getDefaultClaudeConfigDir(); } /** Resolve Claude settings.json path. */ diff --git a/src/web-server/routes/channels-routes.ts b/src/web-server/routes/channels-routes.ts new file mode 100644 index 00000000..87aae579 --- /dev/null +++ b/src/web-server/routes/channels-routes.ts @@ -0,0 +1,106 @@ +import { Router, type Request, type Response } from 'express'; +import { getDiscordChannelsConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; +import { + clearConfiguredDiscordBotTokenEverywhere, + getDiscordChannelsEnvPath, + hasConfiguredDiscordBotToken, + setConfiguredDiscordBotToken, +} from '../../channels/discord-channels-store'; +import { + DISCORD_CHANNEL_PLUGIN_SPEC, + isBunAvailable, +} from '../../channels/discord-channels-runtime'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; + +const router = Router(); + +router.use((req: Request, res: Response, next) => { + if ( + requireLocalAccessWhenAuthDisabled( + req, + res, + 'Discord Channels settings require localhost access when dashboard auth is disabled.' + ) + ) { + next(); + } +}); + +router.get('/', (_req: Request, res: Response): void => { + res.json({ + config: getDiscordChannelsConfig(), + status: { + bunInstalled: isBunAvailable(), + tokenConfigured: hasConfiguredDiscordBotToken(), + tokenPath: getDiscordChannelsEnvPath(), + pluginSpec: DISCORD_CHANNEL_PLUGIN_SPEC, + supportedProfiles: ['default', 'account'], + manualSetupCommands: [ + '/plugin install discord@claude-plugins-official', + '/discord:configure ', + '/discord:access pair ', + '/discord:access policy allowlist', + ], + }, + }); +}); + +router.put('/', (req: Request, res: Response): void => { + const { enabled, unattended } = req.body as { enabled?: unknown; unattended?: unknown }; + + if (enabled !== undefined && typeof enabled !== 'boolean') { + res.status(400).json({ error: 'enabled must be a boolean' }); + return; + } + if (unattended !== undefined && typeof unattended !== 'boolean') { + res.status(400).json({ error: 'unattended must be a boolean' }); + return; + } + + try { + const updated = mutateUnifiedConfig((config) => { + config.discord_channels = { + enabled: enabled ?? config.discord_channels?.enabled ?? false, + unattended: unattended ?? config.discord_channels?.unattended ?? false, + }; + }); + + res.json({ success: true, config: updated.discord_channels }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.put('/discord/token', (req: Request, res: Response): void => { + const { token } = req.body as { token?: unknown }; + + if (typeof token !== 'string') { + res.status(400).json({ error: 'token must be a string' }); + return; + } + + try { + const tokenPath = setConfiguredDiscordBotToken(token); + res.json({ success: true, tokenConfigured: true, tokenPath }); + } catch (error) { + const message = (error as Error).message; + const statusCode = message.includes('cannot be empty') ? 400 : 500; + res.status(statusCode).json({ error: message }); + } +}); + +router.delete('/discord/token', (_req: Request, res: Response): void => { + try { + const clearedPaths = clearConfiguredDiscordBotTokenEverywhere(); + res.json({ + success: true, + tokenConfigured: false, + tokenPath: getDiscordChannelsEnvPath(), + clearedPaths, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 49066a51..e4cd9f74 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -15,6 +15,7 @@ import healthRoutes from './health-routes'; import providerRoutes from './provider-routes'; import variantRoutes from './variant-routes'; import settingsRoutes from './settings-routes'; +import channelsRoutes from './channels-routes'; import websearchRoutes from './websearch-routes'; import cliproxyAuthRoutes from './cliproxy-auth-routes'; import cliproxyStatsRoutes from './cliproxy-stats-routes'; @@ -37,6 +38,7 @@ export const apiRoutes = Router(); // Profile CRUD, settings management, presets, accounts apiRoutes.use('/profiles', profileRoutes); apiRoutes.use('/settings', settingsRoutes); +apiRoutes.use('/channels', channelsRoutes); apiRoutes.use('/accounts', accountRoutes); // ==================== Unified Config ==================== diff --git a/tests/unit/channels/discord-channels-runtime.test.ts b/tests/unit/channels/discord-channels-runtime.test.ts new file mode 100644 index 00000000..7d19024a --- /dev/null +++ b/tests/unit/channels/discord-channels-runtime.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'bun:test'; +import { + DISCORD_CHANNEL_PLUGIN_SPEC, + hasExplicitChannelsFlag, + hasExplicitPermissionOverride, + isDiscordChannelsSessionSupported, + resolveDiscordChannelsSyncConfigDir, + resolveDiscordChannelsLaunchPlan, +} from '../../../src/channels/discord-channels-runtime'; + +describe('discord channels runtime planning', () => { + it('supports only native Claude default/account sessions', () => { + expect(isDiscordChannelsSessionSupported('claude', 'default')).toBe(true); + expect(isDiscordChannelsSessionSupported('claude', 'account')).toBe(true); + expect(isDiscordChannelsSessionSupported('claude', 'settings')).toBe(false); + expect(isDiscordChannelsSessionSupported('droid', 'default')).toBe(false); + }); + + it('detects explicit channel and permission overrides', () => { + expect(hasExplicitChannelsFlag(['--channels', 'plugin:other'])).toBe(true); + expect(hasExplicitChannelsFlag([`--channels=${DISCORD_CHANNEL_PLUGIN_SPEC}`])).toBe(true); + expect(hasExplicitChannelsFlag(['--permission-mode', 'acceptEdits'])).toBe(false); + + expect(hasExplicitPermissionOverride(['--dangerously-skip-permissions'])).toBe(true); + expect(hasExplicitPermissionOverride(['--permission-mode', 'acceptEdits'])).toBe(true); + expect(hasExplicitPermissionOverride(['--permission-mode=acceptEdits'])).toBe(true); + }); + + it('adds the official plugin flag and optional permission bypass when eligible', () => { + const plan = resolveDiscordChannelsLaunchPlan({ + args: ['--verbose'], + config: { enabled: true, unattended: true }, + target: 'claude', + profileType: 'default', + bunAvailable: true, + tokenConfigured: true, + }); + + expect(plan.applied).toBe(true); + expect(plan.args).toEqual([ + '--verbose', + '--channels', + DISCORD_CHANNEL_PLUGIN_SPEC, + '--dangerously-skip-permissions', + ]); + expect(plan.appliedPermissionBypass).toBe(true); + }); + + it('keeps explicit permission choice and still adds the official channel when possible', () => { + const plan = resolveDiscordChannelsLaunchPlan({ + args: ['--permission-mode', 'acceptEdits'], + config: { enabled: true, unattended: true }, + target: 'claude', + profileType: 'account', + bunAvailable: true, + tokenConfigured: true, + }); + + expect(plan.applied).toBe(true); + expect(plan.args).toEqual([ + '--permission-mode', + 'acceptEdits', + '--channels', + DISCORD_CHANNEL_PLUGIN_SPEC, + ]); + expect(plan.appliedPermissionBypass).toBe(false); + }); + + it('skips when the session is incompatible or prerequisites are missing', () => { + const incompatible = resolveDiscordChannelsLaunchPlan({ + args: [], + config: { enabled: true, unattended: false }, + target: 'claude', + profileType: 'settings', + bunAvailable: true, + tokenConfigured: true, + }); + const missingBun = resolveDiscordChannelsLaunchPlan({ + args: [], + config: { enabled: true, unattended: false }, + target: 'claude', + profileType: 'default', + bunAvailable: false, + tokenConfigured: true, + }); + const missingToken = resolveDiscordChannelsLaunchPlan({ + args: [], + config: { enabled: true, unattended: false }, + target: 'claude', + profileType: 'default', + bunAvailable: true, + tokenConfigured: false, + }); + + expect(incompatible.applied).toBe(false); + expect(incompatible.skipMessage).toContain('native Claude default/account sessions'); + expect(missingBun.applied).toBe(false); + expect(missingBun.skipMessage).toContain('Bun is not installed'); + expect(missingToken.applied).toBe(false); + expect(missingToken.skipMessage).toContain('DISCORD_BOT_TOKEN is not configured'); + }); + + it('leaves explicit channel arguments untouched', () => { + const plan = resolveDiscordChannelsLaunchPlan({ + args: ['--channels', 'plugin:custom'], + config: { enabled: true, unattended: true }, + target: 'claude', + profileType: 'default', + bunAvailable: true, + tokenConfigured: true, + }); + + expect(plan.applied).toBe(false); + expect(plan.args).toEqual(['--channels', 'plugin:custom']); + expect(plan.skipMessage).toBeUndefined(); + }); + + it('falls back to process.env.CLAUDE_CONFIG_DIR for sync when no explicit dir is passed', () => { + const originalConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = '/tmp/external-claude-config'; + + try { + expect(resolveDiscordChannelsSyncConfigDir()).toBe('/tmp/external-claude-config'); + expect(resolveDiscordChannelsSyncConfigDir('/tmp/explicit')).toBe('/tmp/explicit'); + } finally { + if (originalConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.CLAUDE_CONFIG_DIR; + } + } + }); +}); diff --git a/tests/unit/channels/discord-channels-store.test.ts b/tests/unit/channels/discord-channels-store.test.ts new file mode 100644 index 00000000..fdb10717 --- /dev/null +++ b/tests/unit/channels/discord-channels-store.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + clearConfiguredDiscordBotTokenEverywhere, + clearConfiguredDiscordBotToken, + getDiscordChannelsEnvPath, + hasConfiguredDiscordBotToken, + readConfiguredDiscordBotToken, + readDiscordBotTokenFromEnvContent, + setConfiguredDiscordBotToken, + syncDiscordChannelsEnvToConfigDir, +} from '../../../src/channels/discord-channels-store'; + +describe('discord channels token store', () => { + let tempHome = ''; + let originalHome: string | undefined; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-discord-channels-')); + originalHome = process.env.HOME; + originalCcsHome = process.env.CCS_HOME; + process.env.HOME = tempHome; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('writes and reads DISCORD_BOT_TOKEN from the canonical Claude channels env file', () => { + const envPath = setConfiguredDiscordBotToken('discord-secret'); + + expect(envPath).toBe(path.join(tempHome, '.claude', 'channels', 'discord', '.env')); + expect(hasConfiguredDiscordBotToken()).toBe(true); + expect(readConfiguredDiscordBotToken()).toBe('discord-secret'); + expect(readDiscordBotTokenFromEnvContent(fs.readFileSync(envPath, 'utf8'))).toBe( + 'discord-secret' + ); + }); + + it('removes only the token entry and deletes the file when nothing remains', () => { + const envPath = getDiscordChannelsEnvPath(); + fs.mkdirSync(path.dirname(envPath), { recursive: true }); + fs.writeFileSync(envPath, '# comment\nDISCORD_BOT_TOKEN=secret\nOTHER_KEY=value\n', 'utf8'); + + clearConfiguredDiscordBotToken(); + expect(fs.readFileSync(envPath, 'utf8')).toBe('# comment\nOTHER_KEY=value\n'); + + clearConfiguredDiscordBotToken(); + fs.writeFileSync(envPath, 'DISCORD_BOT_TOKEN=secret\n', 'utf8'); + clearConfiguredDiscordBotToken(); + expect(fs.existsSync(envPath)).toBe(false); + }); + + it('syncs the canonical env file into an alternate CLAUDE_CONFIG_DIR for account sessions', () => { + setConfiguredDiscordBotToken('discord-secret'); + + const targetConfigDir = path.join(tempHome, '.ccs', 'instances', 'work'); + const targetPath = path.join(targetConfigDir, 'channels', 'discord', '.env'); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, '# keep\nOTHER_KEY=value\n', 'utf8'); + + const result = syncDiscordChannelsEnvToConfigDir(targetConfigDir); + + expect(result.synced).toBe(true); + expect(result.targetPath).toBe(targetPath); + expect(fs.readFileSync(targetPath, 'utf8')).toBe( + '# keep\nOTHER_KEY=value\n\nDISCORD_BOT_TOKEN=discord-secret\n' + ); + expect(fs.statSync(targetPath).mode & 0o777).toBe(0o600); + }); + + it('clears previously synced copies across managed Claude config dirs', () => { + setConfiguredDiscordBotToken('discord-secret'); + + const instanceConfigDir = path.join(tempHome, '.ccs', 'instances', 'work'); + const instanceEnvPath = path.join(instanceConfigDir, 'channels', 'discord', '.env'); + + syncDiscordChannelsEnvToConfigDir(instanceConfigDir); + expect(fs.existsSync(instanceEnvPath)).toBe(true); + + const clearedPaths = clearConfiguredDiscordBotTokenEverywhere(); + + expect(clearedPaths).toContain(getDiscordChannelsEnvPath()); + expect(clearedPaths).toContain(instanceEnvPath); + expect(fs.existsSync(getDiscordChannelsEnvPath())).toBe(false); + expect(fs.existsSync(instanceEnvPath)).toBe(false); + }); +}); diff --git a/tests/unit/commands/config-channels-command.test.ts b/tests/unit/commands/config-channels-command.test.ts new file mode 100644 index 00000000..712ee5bf --- /dev/null +++ b/tests/unit/commands/config-channels-command.test.ts @@ -0,0 +1,27 @@ +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', () => { + const result = parseChannelsCommandArgs([ + '--enable', + '--unattended', + '--set-token', + 'discord-secret', + ]); + + expect(result.enable).toBe(true); + expect(result.unattended).toBe(true); + expect(result.setToken).toBe('discord-secret'); + }); + + it('supports inline token assignment and clear flags', () => { + const result = parseChannelsCommandArgs(['--disable', '--no-unattended', '--set-token=abc']); + const clearResult = parseChannelsCommandArgs(['--clear-token']); + + expect(result.disable).toBe(true); + expect(result.noUnattended).toBe(true); + expect(result.setToken).toBe('abc'); + expect(clearResult.clearToken).toBe(true); + }); +}); diff --git a/tests/unit/commands/config-command.test.ts b/tests/unit/commands/config-command.test.ts index d5c539f9..368087e3 100644 --- a/tests/unit/commands/config-command.test.ts +++ b/tests/unit/commands/config-command.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; const startServerCalls: Array> = []; const configAuthCalls: string[][] = []; +const configChannelsCalls: string[][] = []; let logLines: string[] = []; let errorLines: string[] = []; let dashboardAuthEnabled = false; @@ -14,6 +15,7 @@ let originalProcessExit: typeof process.exit; beforeEach(() => { startServerCalls.length = 0; configAuthCalls.length = 0; + configChannelsCalls.length = 0; logLines = []; errorLines = []; dashboardAuthEnabled = false; @@ -94,6 +96,11 @@ beforeEach(() => { configAuthCalls.push([...args]); }, })); + mock.module('../../../src/commands/config-channels-command', () => ({ + handleConfigChannelsCommand: async (args: string[]) => { + configChannelsCalls.push([...args]); + }, + })); }); afterEach(() => { @@ -132,6 +139,15 @@ describe('config command dashboard startup', () => { expect(startServerCalls).toHaveLength(0); }); + it('routes channels subcommands before dashboard startup', async () => { + const handleConfigCommand = await loadHandleConfigCommand(); + + await handleConfigCommand(['channels', '--enable']); + + expect(configChannelsCalls).toEqual([['--enable']]); + expect(startServerCalls).toHaveLength(0); + }); + it('rejects unknown config subcommands before dashboard startup', async () => { const handleConfigCommand = await loadHandleConfigCommand(); process.exit = ((code?: number) => { @@ -155,7 +171,9 @@ describe('config command dashboard startup', () => { const rendered = logLines.join('\n'); expect(rendered).toContain('Dashboard: http://localhost:3000'); expect(rendered).toContain('Bind host: ::'); - expect(rendered).toContain('Dashboard may be reachable from other devices that can connect to this machine.'); + expect(rendered).toContain( + 'Dashboard may be reachable from other devices that can connect to this machine.' + ); expect(rendered).toContain('Protect it before sharing: ccs config auth setup'); expect(errorLines).toHaveLength(0); }); diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index 6572a0df..2c85bdd1 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -106,6 +106,12 @@ describe('unified-config-types', () => { expect(config.preferences.auto_update).toBe(true); }); + it('should default Discord Channels to disabled and attended mode', () => { + const config = createEmptyUnifiedConfig(); + expect(config.discord_channels?.enabled).toBe(false); + expect(config.discord_channels?.unattended).toBe(false); + }); + it('should have CLIProxy providers list', () => { const config = createEmptyUnifiedConfig(); expect(config.cliproxy.providers).toContain('gemini'); @@ -202,13 +208,7 @@ describe('continuity-inheritance-config', () => { fs.writeFileSync( path.join(ccsDir, 'config.yaml'), - [ - 'version: 8', - 'continuity_inherit_from_account:', - ' glm: pro', - ' empty: ""', - '', - ].join('\n') + ['version: 8', 'continuity_inherit_from_account:', ' glm: pro', ' empty: ""', ''].join('\n') ); process.env.CCS_HOME = tempHome; diff --git a/ui/src/pages/settings/components/tab-navigation.tsx b/ui/src/pages/settings/components/tab-navigation.tsx index d0d919c6..0c3fb57d 100644 --- a/ui/src/pages/settings/components/tab-navigation.tsx +++ b/ui/src/pages/settings/components/tab-navigation.tsx @@ -4,7 +4,7 @@ */ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Globe, Settings2, Server, KeyRound, Brain, Archive } from 'lucide-react'; +import { Globe, Settings2, Server, KeyRound, Brain, Archive, MessageSquare } from 'lucide-react'; import type { SettingsTab } from '../types'; import { useTranslation } from 'react-i18next'; @@ -17,6 +17,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) { const { t } = useTranslation(); const tabs = [ { value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe }, + { value: 'channels' as const, label: 'Channels', icon: MessageSquare }, { value: 'globalenv' as const, label: t('settingsTabs.env'), icon: Settings2 }, { value: 'thinking' as const, label: t('settingsTabs.think'), icon: Brain }, { value: 'proxy' as const, label: t('settingsTabs.proxy'), icon: Server }, @@ -26,7 +27,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) { return ( onTabChange(v as SettingsTab)}> - + {tabs.map(({ value, label, icon: Icon }) => ( diff --git a/ui/src/pages/settings/hooks/use-discord-channels-config.ts b/ui/src/pages/settings/hooks/use-discord-channels-config.ts new file mode 100644 index 00000000..44e1b9ee --- /dev/null +++ b/ui/src/pages/settings/hooks/use-discord-channels-config.ts @@ -0,0 +1,137 @@ +import { useCallback, useState } from 'react'; +import type { DiscordChannelsConfig, DiscordChannelsStatus } from '../types'; + +const DEFAULT_CONFIG: DiscordChannelsConfig = { + enabled: false, + unattended: false, +}; + +export function useDiscordChannelsConfig() { + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const flashSuccess = useCallback((message: string) => { + setSuccess(message); + window.setTimeout(() => setSuccess(null), 1500); + }, []); + + const fetchConfig = useCallback(async () => { + try { + setLoading(true); + setError(null); + const res = await fetch('/api/channels'); + if (!res.ok) { + throw new Error('Failed to load Discord Channels settings'); + } + + const data = (await res.json()) as { + config?: DiscordChannelsConfig; + status?: DiscordChannelsStatus; + }; + + setConfig(data.config ?? DEFAULT_CONFIG); + setStatus(data.status ?? null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, []); + + const updateConfig = useCallback( + async (updates: Partial, successMessage = 'Settings saved') => { + try { + setSaving(true); + setError(null); + + const res = await fetch('/api/channels', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }); + + if (!res.ok) { + const data = (await res.json()) as { error?: string }; + throw new Error(data.error || 'Failed to save Discord Channels settings'); + } + + const data = (await res.json()) as { config?: DiscordChannelsConfig }; + setConfig(data.config ?? { ...config, ...updates }); + flashSuccess(successMessage); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setSaving(false); + } + }, + [config, flashSuccess] + ); + + const saveToken = useCallback( + async (token: string) => { + try { + setSaving(true); + setError(null); + + const res = await fetch('/api/channels/discord/token', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + + if (!res.ok) { + const data = (await res.json()) as { error?: string }; + throw new Error(data.error || 'Failed to save Discord bot token'); + } + + await fetchConfig(); + flashSuccess('Discord bot token saved'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setSaving(false); + } + }, + [fetchConfig, flashSuccess] + ); + + const clearToken = useCallback(async () => { + try { + setSaving(true); + setError(null); + + const res = await fetch('/api/channels/discord/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'); + } + + await fetchConfig(); + flashSuccess('Discord bot token cleared'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setSaving(false); + } + }, [fetchConfig, flashSuccess]); + + return { + config, + status, + loading, + saving, + error, + success, + fetchConfig, + updateConfig, + saveToken, + clearToken, + }; +} diff --git a/ui/src/pages/settings/hooks/use-settings-tab.ts b/ui/src/pages/settings/hooks/use-settings-tab.ts index 8efd3123..15a05a5f 100644 --- a/ui/src/pages/settings/hooks/use-settings-tab.ts +++ b/ui/src/pages/settings/hooks/use-settings-tab.ts @@ -11,17 +11,19 @@ export function useSettingsTab() { // Normalize to lowercase for case-insensitive matching (fixes ?tab=Backups vs ?tab=backups) const tabParam = searchParams.get('tab')?.toLowerCase(); const activeTab: SettingsTab = - tabParam === 'globalenv' - ? 'globalenv' - : tabParam === 'proxy' - ? 'proxy' - : tabParam === 'auth' - ? 'auth' - : tabParam === 'thinking' - ? 'thinking' - : tabParam === 'backups' - ? 'backups' - : 'websearch'; + tabParam === 'channels' + ? 'channels' + : tabParam === 'globalenv' + ? 'globalenv' + : tabParam === 'proxy' + ? 'proxy' + : tabParam === 'auth' + ? 'auth' + : tabParam === 'thinking' + ? 'thinking' + : tabParam === 'backups' + ? 'backups' + : 'websearch'; const setActiveTab = useCallback( (tab: SettingsTab) => { diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx index bb317ff9..b01539f2 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -48,6 +48,7 @@ function lazyWithRetry>(importFn: () => Promise // Lazy-loaded sections with retry capability const WebSearchSection = lazyWithRetry(() => import('./sections/websearch')); +const ChannelsSection = lazyWithRetry(() => import('./sections/channels')); const GlobalEnvSection = lazyWithRetry(() => import('./sections/globalenv-section')); const ThinkingSection = lazyWithRetry(() => import('./sections/thinking')); const ProxySection = lazyWithRetry(() => import('./sections/proxy')); @@ -130,6 +131,7 @@ function SettingsPageInner() { }> {activeTab === 'websearch' && } + {activeTab === 'channels' && } {activeTab === 'globalenv' && } {activeTab === 'thinking' && } {activeTab === 'proxy' && } @@ -153,6 +155,7 @@ function SettingsPageInner() { }> {activeTab === 'websearch' && } + {activeTab === 'channels' && } {activeTab === 'globalenv' && } {activeTab === 'thinking' && } {activeTab === 'proxy' && } diff --git a/ui/src/pages/settings/sections/channels.tsx b/ui/src/pages/settings/sections/channels.tsx new file mode 100644 index 00000000..b3a744e5 --- /dev/null +++ b/ui/src/pages/settings/sections/channels.tsx @@ -0,0 +1,259 @@ +import { useEffect, useState } from 'react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Switch } from '@/components/ui/switch'; +import { + AlertCircle, + Bot, + CheckCircle2, + MessageSquare, + RefreshCw, + Save, + ShieldAlert, + Trash2, +} from 'lucide-react'; +import { useDiscordChannelsConfig } from '../hooks/use-discord-channels-config'; +import { useRawConfig } from '../hooks'; + +export default function ChannelsSection() { + const { + config, + status, + loading, + saving, + error, + success, + fetchConfig, + updateConfig, + saveToken, + clearToken, + } = useDiscordChannelsConfig(); + const { fetchRawConfig } = useRawConfig(); + const [tokenDraft, setTokenDraft] = useState(''); + + useEffect(() => { + void fetchConfig(); + void fetchRawConfig(); + }, [fetchConfig, fetchRawConfig]); + + const refreshAll = async () => { + await Promise.all([fetchConfig(), fetchRawConfig()]); + }; + + const handleToggle = async ( + updates: Partial, + successMessage: string + ): Promise => { + await updateConfig(updates, successMessage); + await fetchRawConfig(); + }; + + const handleSaveToken = async (): Promise => { + await saveToken(tokenDraft); + setTokenDraft(''); + await fetchRawConfig(); + }; + + const handleClearToken = async (): Promise => { + await clearToken(); + setTokenDraft(''); + await fetchRawConfig(); + }; + + if (loading) { + return ( +
+
+ + Loading +
+
+ ); + } + + return ( + <> +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + {success} +
+ )} +
+ + +
+
+ +

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

+
+ +
+
+

Runtime

+

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

+

+ Applies only to native Claude default and account{' '} + sessions. +

+
+
+
+ Bun + + {status?.bunInstalled ? 'Installed' : 'Missing'} + +
+
+ Bot token + + {status?.tokenConfigured ? 'Configured' : 'Not configured'} + +
+
{status?.tokenPath}
+
+
+ +
+
+
+ +

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

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

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

+
+
+ + void handleToggle( + { unattended: checked }, + checked + ? 'Unattended Discord Channels enabled' + : 'Unattended Discord Channels disabled' + ) + } + /> +
+
+ +
+
+ + +
+

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

+ setTokenDraft(event.target.value)} + placeholder={ + status?.tokenConfigured + ? 'Configured. Enter a new token to replace it.' + : 'Paste DISCORD_BOT_TOKEN' + } + disabled={saving} + /> +
+ + + +
+
+ + + + 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. + + + +
+ +

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

+
+ {(status?.manualSetupCommands ?? []).map((command) => ( +
+ {command} +
+ ))} +
+
+
+
+ + ); +} diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index 606d3c00..b3fbd88c 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -60,9 +60,32 @@ export interface GlobalEnvConfig { env: Record; } +// === Discord Channels Types === + +export interface DiscordChannelsConfig { + enabled: boolean; + unattended: boolean; +} + +export interface DiscordChannelsStatus { + bunInstalled: boolean; + tokenConfigured: boolean; + tokenPath: string; + pluginSpec: string; + supportedProfiles: string[]; + manualSetupCommands: string[]; +} + // === Tab Types === -export type SettingsTab = 'websearch' | 'globalenv' | 'proxy' | 'auth' | 'thinking' | 'backups'; +export type SettingsTab = + | 'websearch' + | 'channels' + | 'globalenv' + | 'proxy' + | 'auth' + | 'thinking' + | 'backups'; // === Thinking Types ===