feat(channels): auto-enable official discord plugin

- add runtime injection for official Discord Channels on eligible native Claude sessions

- add CLI and dashboard configuration plus secure token handling

- add tests for launch planning, token sync, and config routing

Refs #783
This commit is contained in:
Tam Nhu Tran
2026-03-25 16:31:55 -04:00
parent 7596e463c5
commit 4bce1559dd
23 changed files with 1513 additions and 27 deletions
+58 -3
View File
@@ -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<boolean> {
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<void> {
// Register target adapters
registerTarget(new ClaudeAdapter());
@@ -838,7 +887,8 @@ async function main(): Promise<void> {
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<void> {
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;
+107
View File
@@ -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,
};
}
+219
View File
@@ -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<string>([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,
};
}
}
+191
View File
@@ -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 <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 <DISCORD_BOT_TOKEN>', 'command')}`);
console.log(` ${color('/discord:access pair <code>', 'command')}`);
console.log(` ${color('/discord:access policy allowlist', 'command')}`);
console.log('');
}
export async function handleConfigChannelsCommand(args: string[]): Promise<void> {
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();
}
+10
View File
@@ -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 <t> 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');
+7
View File
@@ -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) => {
+14
View File
@@ -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 <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 <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)'],
+41
View File
@@ -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>): 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
+25 -1
View File
@@ -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 },
};
+10 -1
View File
@@ -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. */
+106
View File
@@ -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_BOT_TOKEN>',
'/discord:access pair <code>',
'/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;
+2
View File
@@ -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 ====================
@@ -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;
}
}
});
});
@@ -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);
});
});
@@ -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);
});
});
+19 -1
View File
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
const startServerCalls: Array<Record<string, unknown>> = [];
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);
});
+7 -7
View File
@@ -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;
@@ -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 (
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
<TabsList className="grid w-full grid-cols-6">
<TabsList className="grid w-full grid-cols-7">
{tabs.map(({ value, label, icon: Icon }) => (
<TabsTrigger key={value} value={value} className="gap-1.5 px-1 text-xs">
<Icon className="h-3.5 w-3.5 shrink-0" />
@@ -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<DiscordChannelsConfig>(DEFAULT_CONFIG);
const [status, setStatus] = useState<DiscordChannelsStatus | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(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<DiscordChannelsConfig>, 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,
};
}
+13 -11
View File
@@ -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) => {
+3
View File
@@ -48,6 +48,7 @@ function lazyWithRetry<T extends ComponentType<unknown>>(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() {
<SectionErrorBoundary>
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'channels' && <ChannelsSection />}
{activeTab === 'globalenv' && <GlobalEnvSection />}
{activeTab === 'thinking' && <ThinkingSection />}
{activeTab === 'proxy' && <ProxySection />}
@@ -153,6 +155,7 @@ function SettingsPageInner() {
<SectionErrorBoundary>
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'channels' && <ChannelsSection />}
{activeTab === 'globalenv' && <GlobalEnvSection />}
{activeTab === 'thinking' && <ThinkingSection />}
{activeTab === 'proxy' && <ProxySection />}
+259
View File
@@ -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<typeof config>,
successMessage: string
): Promise<void> => {
await updateConfig(updates, successMessage);
await fetchRawConfig();
};
const handleSaveToken = async (): Promise<void> => {
await saveToken(tokenDraft);
setTokenDraft('');
await fetchRawConfig();
};
const handleClearToken = async (): Promise<void> => {
await clearToken();
setTokenDraft('');
await fetchRawConfig();
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading</span>
</div>
</div>
);
}
return (
<>
<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'
}`}
>
{error && (
<Alert variant="destructive" className="py-2 shadow-lg">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</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">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">{success}</span>
</div>
)}
</div>
<ScrollArea className="flex-1">
<div className="p-5 space-y-6">
<div className="flex items-center gap-3">
<MessageSquare className="w-5 h-5 text-primary" />
<p className="text-sm text-muted-foreground">
Auto-enable Anthropic&apos;s official Discord Channels plugin for compatible Claude
sessions. CCS stores only the booleans in <code>config.yaml</code>; the bot token
stays in Claude&apos;s official channels env file.
</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="mt-2 text-sm text-muted-foreground">
Applies only to native Claude <code>default</code> and <code>account</code>{' '}
sessions.
</p>
</div>
<div className="rounded-lg border bg-muted/30 p-4 space-y-2">
<div className="flex items-center justify-between text-sm">
<span>Bun</span>
<span className={status?.bunInstalled ? 'text-green-600' : 'text-amber-600'}>
{status?.bunInstalled ? 'Installed' : 'Missing'}
</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>
</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="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" />
<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>
</div>
</div>
<Switch
checked={config.unattended}
disabled={saving}
onCheckedChange={(checked) =>
void handleToggle(
{ unattended: checked },
checked
? 'Unattended Discord Channels enabled'
: 'Unattended Discord Channels disabled'
)
}
/>
</div>
</div>
<div className="rounded-lg border p-4 space-y-4">
<div className="flex items-center gap-2">
<Bot className="w-4 h-4 text-primary" />
<Label className="text-base font-medium">Discord bot token</Label>
</div>
<p className="text-sm text-muted-foreground">
Save <code>DISCORD_BOT_TOKEN</code> into Claude&apos;s official Discord channel env
file. The dashboard never reads the token value back after save.
</p>
<Input
type="password"
value={tokenDraft}
onChange={(event) => setTokenDraft(event.target.value)}
placeholder={
status?.tokenConfigured
? 'Configured. Enter a new token to replace it.'
: 'Paste DISCORD_BOT_TOKEN'
}
disabled={saving}
/>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => void handleSaveToken()}
disabled={saving || !tokenDraft.trim()}
>
<Save className="w-4 h-4 mr-2" />
Save Token
</Button>
<Button
variant="outline"
onClick={() => void handleClearToken()}
disabled={saving || !status?.tokenConfigured}
>
<Trash2 className="w-4 h-4 mr-2" />
Clear Token
</Button>
<Button variant="outline" onClick={() => void refreshAll()} disabled={saving}>
<RefreshCw className={`w-4 h-4 mr-2 ${saving ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
<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.
</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>
</div>
</ScrollArea>
</>
);
}
+24 -1
View File
@@ -60,9 +60,32 @@ export interface GlobalEnvConfig {
env: Record<string, string>;
}
// === 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 ===