mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
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:
+58
-3
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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's official Discord Channels plugin for compatible Claude
|
||||
sessions. CCS stores only the booleans in <code>config.yaml</code>; the bot token
|
||||
stays in Claude's official channels env file.
|
||||
</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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 ===
|
||||
|
||||
|
||||
Reference in New Issue
Block a user