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

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

+
+ +
+
+

Runtime

+

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

+

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

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

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

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

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

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

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

+ setTokenDraft(event.target.value)} + placeholder={ + status?.tokenConfigured + ? 'Configured. Enter a new token to replace it.' + : 'Paste DISCORD_BOT_TOKEN' + } + disabled={saving} + /> +
+ + + +
+
+ + + + CCS does not persist a global Claude setting for channels. It only prepares the token + file and injects runtime flags when the session is compatible and the prerequisites + are present. + + + +
+ +

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

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

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

-

Runtime

-

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

+

Selected

+

+ {config.selected.length > 0 ? config.selected.join(', ') : 'None'} +

Applies only to native Claude default and account{' '} sessions. @@ -125,48 +143,23 @@ export default function ChannelsSection() {

- Bot token - - {status?.tokenConfigured ? 'Configured' : 'Not configured'} - + Supported profiles + {status?.supportedProfiles.join(', ')}
-
{status?.tokenPath}
-
-
-
- -

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

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

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

+ Opt-in only. CCS adds the bypass flag once when at least one selected channel + is being auto-enabled and you did not already pass a permission flag yourself.

@@ -174,83 +167,109 @@ export default function ChannelsSection() { checked={config.unattended} disabled={saving} onCheckedChange={(checked) => - void handleToggle( + void updateConfig( { unattended: checked }, - checked - ? 'Unattended Discord Channels enabled' - : 'Unattended Discord Channels disabled' + checked ? 'Unattended mode enabled' : 'Unattended mode disabled' ) } />
-
-
- - -
-

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

- setTokenDraft(event.target.value)} - placeholder={ - status?.tokenConfigured - ? 'Configured. Enter a new token to replace it.' - : 'Paste DISCORD_BOT_TOKEN' - } - disabled={saving} - /> -
- - - -
+
+ {status?.channels.map((channel) => { + const enabled = config.selected.includes(channel.id); + const tokenDraft = tokenDrafts[channel.id]; + + return ( +
+
+
+ +

{channel.summary}

+

+ {channel.pluginSpec} +

+ {channel.unavailableReason && ( +

{channel.unavailableReason}

+ )} +
+ void toggleChannel(channel.id, checked)} + /> +
+ + {channel.requiresToken && ( +
+

+ Save {channel.envKey} in Claude's official channel env + file. The dashboard never reads the token value back after save. +

+ updateTokenDraft(channel.id, event.target.value)} + placeholder={ + channel.tokenConfigured + ? `Configured. Enter a new ${channel.envKey} to replace it.` + : `Paste ${channel.envKey}` + } + disabled={saving} + /> +
+ {channel.tokenPath} +
+
+ + +
+
+ )} + +
+

Claude-side setup

+ {(channel.manualSetupCommands ?? []).map((command) => ( +
+ {command} +
+ ))} +
+
+ ); + })}
- CCS does not persist a global Claude setting for channels. It only prepares the token - file and injects runtime flags when the session is compatible and the prerequisites - are present. + CCS does not persist a global Claude setting for channels. It only prepares channel + env files and injects runtime flags when the selected channels are compatible and + ready. -
- -

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

-
- {(status?.manualSetupCommands ?? []).map((command) => ( -
- {command} -
- ))} -
+
+
diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index b3fbd88c..d37d018d 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -60,22 +60,34 @@ export interface GlobalEnvConfig { env: Record; } -// === Discord Channels Types === +// === Official Channels Types === -export interface DiscordChannelsConfig { - enabled: boolean; +export type OfficialChannelId = 'telegram' | 'discord' | 'imessage'; + +export interface OfficialChannelsConfig { + selected: OfficialChannelId[]; unattended: boolean; } -export interface DiscordChannelsStatus { - bunInstalled: boolean; - tokenConfigured: boolean; - tokenPath: string; +export interface OfficialChannelStatus { + id: OfficialChannelId; + displayName: string; pluginSpec: string; - supportedProfiles: string[]; + summary: string; + requiresToken: boolean; + envKey?: string; + tokenConfigured: boolean; + tokenPath?: string; + unavailableReason?: string; manualSetupCommands: string[]; } +export interface OfficialChannelsStatus { + bunInstalled: boolean; + supportedProfiles: string[]; + channels: OfficialChannelStatus[]; +} + // === Tab Types === export type SettingsTab = From 0e2f47802bf112e925f1cf8ce9156b1ce0602de7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 24 Mar 2026 16:03:17 -0400 Subject: [PATCH 3/8] style: format official channel files --- src/channels/official-channels-runtime.ts | 28 ++++++++++------ src/commands/config-channels-command.ts | 41 ++++++++++++++++------- src/commands/help-command.ts | 5 ++- src/config/unified-config-loader.ts | 13 +++---- src/web-server/routes/channels-routes.ts | 5 +-- 5 files changed, 60 insertions(+), 32 deletions(-) diff --git a/src/channels/official-channels-runtime.ts b/src/channels/official-channels-runtime.ts index bf3472aa..4cda65e7 100644 --- a/src/channels/official-channels-runtime.ts +++ b/src/channels/official-channels-runtime.ts @@ -1,10 +1,7 @@ import { spawnSync } from 'child_process'; import type { TargetType } from '../targets/target-adapter'; import type { ProfileType } from '../types/profile'; -import type { - OfficialChannelId, - OfficialChannelsConfig, -} from '../config/unified-config-types'; +import type { OfficialChannelId, OfficialChannelsConfig } from '../config/unified-config-types'; export interface OfficialChannelDefinition { id: OfficialChannelId; @@ -132,7 +129,11 @@ export function buildOfficialChannelsArgs( channels: OfficialChannelId[], includePermissionBypass: boolean ): string[] { - const nextArgs = [...args, '--channels', ...channels.map((channel) => OFFICIAL_CHANNELS[channel].pluginSpec)]; + const nextArgs = [ + ...args, + '--channels', + ...channels.map((channel) => OFFICIAL_CHANNELS[channel].pluginSpec), + ]; if (includePermissionBypass) { nextArgs.push('--dangerously-skip-permissions'); @@ -191,9 +192,7 @@ export function resolveOfficialChannelsLaunchPlan( const channel = OFFICIAL_CHANNELS[channelId]; if (channel.requiresMacOS && !isMacOS()) { - skippedMessages.push( - `${channel.displayName} auto-enable skipped because it requires macOS.` - ); + skippedMessages.push(`${channel.displayName} auto-enable skipped because it requires macOS.`); continue; } @@ -256,7 +255,9 @@ export function getOfficialChannelSummary(channelId: OfficialChannelId): string return 'macOS-only. No bot token required, but Messages permissions are required.'; } -export function getOfficialChannelUnavailableReason(channelId: OfficialChannelId): string | undefined { +export function getOfficialChannelUnavailableReason( + channelId: OfficialChannelId +): string | undefined { if (channelId === 'imessage' && !isMacOS()) { return 'Requires macOS.'; } @@ -300,7 +301,9 @@ export function isOfficialChannelSelectionValid(selection: string): boolean { .map((value) => value.trim().toLowerCase()) .filter(Boolean); - return parsed.length > 0 && parsed.every((value) => value === 'all' || isOfficialChannelId(value)); + return ( + parsed.length > 0 && parsed.every((value) => value === 'all' || isOfficialChannelId(value)) + ); } export function resolveLegacyDiscordSelection(enabled: boolean | undefined): OfficialChannelId[] { @@ -369,7 +372,10 @@ export function getOfficialChannelsDocsSummary(): string { return 'Supported official channels are Telegram, Discord, and iMessage.'; } -export function getOfficialChannelSyncFailureMessage(channelId: OfficialChannelId, targetPath: string): string { +export function getOfficialChannelSyncFailureMessage( + channelId: OfficialChannelId, + targetPath: string +): string { return `${getOfficialChannelDisplayName(channelId)} auto-enable skipped: failed to sync channel env to ${targetPath}`; } diff --git a/src/commands/config-channels-command.ts b/src/commands/config-channels-command.ts index 352c53d2..26a2d269 100644 --- a/src/commands/config-channels-command.ts +++ b/src/commands/config-channels-command.ts @@ -62,9 +62,7 @@ function parseTokenAssignment(value: string): { } | null { const separatorIndex = value.indexOf('='); if (separatorIndex === -1) { - return value.trim() - ? { channelId: 'discord', token: value.trim() } - : null; + return value.trim() ? { channelId: 'discord', token: value.trim() } : null; } const channelId = value.slice(0, separatorIndex).trim().toLowerCase(); @@ -139,14 +137,20 @@ function showHelp(): void { console.log(` ${color('--clear', 'command')} Clear all selected channels`); console.log(` ${color('--enable', 'command')} Legacy alias: add Discord`); console.log(` ${color('--disable', 'command')} Legacy alias: remove Discord`); - console.log(` ${color('--unattended', 'command')} Also add --dangerously-skip-permissions`); + console.log( + ` ${color('--unattended', 'command')} Also add --dangerously-skip-permissions` + ); console.log(` ${color('--no-unattended', 'command')} Disable unattended runtime flag`); console.log(` ${color('--set-token ', 'command')} ${getOfficialChannelTokenHelp()}`); - console.log(` ${color('--clear-token [channel]', 'command')} ${getOfficialChannelClearTokenHelp()}`); + console.log( + ` ${color('--clear-token [channel]', 'command')} ${getOfficialChannelClearTokenHelp()}` + ); console.log(` ${color('--help, -h', 'command')} Show this help`); console.log(''); console.log(subheader('Examples:')); - console.log(` $ ${color('ccs config channels', 'command')} ${dim('# Show status')}`); + console.log( + ` $ ${color('ccs config channels', 'command')} ${dim('# Show status')}` + ); console.log( ` $ ${color('ccs config channels --set telegram,discord', 'command')} ${dim('# Enable Telegram + Discord')}` ); @@ -170,13 +174,17 @@ function showStatus(): void { console.log(''); console.log(header('Official Channels Configuration')); console.log(''); - console.log(` Channels: ${selected.length > 0 ? ok(getChannelConfigSelectionLabel(selected)) : warn('Disabled')}`); + console.log( + ` Channels: ${selected.length > 0 ? ok(getChannelConfigSelectionLabel(selected)) : warn('Disabled')}` + ); console.log(` Unattended: ${config.unattended ? warn('Enabled') : info('Disabled')}`); console.log(` Bun: ${bunReady ? ok('Installed') : warn('Missing')}`); console.log(''); console.log(subheader('Applies To:')); console.log(` ${dim(getOfficialChannelsCompatibilityMessage())}`); - console.log(` ${dim(`Supported profiles: ${getOfficialChannelsSupportedProfiles().join(', ')}`)}`); + console.log( + ` ${dim(`Supported profiles: ${getOfficialChannelsSupportedProfiles().join(', ')}`)}` + ); console.log(''); console.log(subheader('Channels:')); for (const channelId of expandOfficialChannelSelection('all')) { @@ -195,7 +203,9 @@ function showStatus(): void { console.log(` ${enabled ? '[x]' : '[ ]'} ${displayName}: ${status}`); console.log(` ${dim(getOfficialChannelSummary(channelId))}`); if (envKey) { - console.log(` ${dim(`${envKey}: ${tokenConfigured ? 'configured' : 'not configured'}`)}`); + console.log( + ` ${dim(`${envKey}: ${tokenConfigured ? 'configured' : 'not configured'}`)}` + ); console.log(` ${dim(getOfficialChannelEnvPath(channelId))}`); } console.log(` ${dim(getOfficialChannelReadyMessage(channelId))}`); @@ -242,8 +252,13 @@ export async function handleConfigChannelsCommand(args: string[]): Promise process.exitCode = 1; return; } - if (options.setSelection !== undefined && !isOfficialChannelSelectionValid(options.setSelection)) { - console.error(fail(`Invalid --set value: ${options.setSelection} (${getOfficialChannelChoices()} or all)`)); + if ( + options.setSelection !== undefined && + !isOfficialChannelSelectionValid(options.setSelection) + ) { + console.error( + fail(`Invalid --set value: ${options.setSelection} (${getOfficialChannelChoices()} or all)`) + ); process.exitCode = 1; return; } @@ -261,7 +276,9 @@ export async function handleConfigChannelsCommand(args: string[]): Promise } if (options.clearTokenInvalid) { console.error( - fail(`Invalid --clear-token value: ${options.clearTokenInvalid} (use ${getOfficialChannelChoices()})`) + fail( + `Invalid --clear-token value: ${options.clearTokenInvalid} (use ${getOfficialChannelChoices()})` + ) ); process.exitCode = 1; return; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 70a7532e..f6aefb3d 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -471,7 +471,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); printSubSection('Official Channels (official Claude plugins)', [ ['ccs config channels', 'Show current status'], - ['ccs config channels --set telegram,discord', 'Auto-add selected channels on native Claude sessions'], + [ + 'ccs config channels --set telegram,discord', + 'Auto-add selected channels on native Claude sessions', + ], ['ccs config channels --set all', 'Enable Telegram, Discord, and iMessage'], ['ccs config channels --unattended', 'Also add --dangerously-skip-permissions'], ['ccs config channels --set-token telegram=', 'Save TELEGRAM_BOT_TOKEN'], diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index eb5bf431..e571219d 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -305,7 +305,9 @@ function normalizeOfficialChannelsConfig( partial: Partial & { discord_channels?: LegacyDiscordChannelsConfig } ): OfficialChannelsConfig { const rawSelected = Array.isArray(partial.channels?.selected) - ? partial.channels.selected.filter((value): value is OfficialChannelId => isOfficialChannelId(value)) + ? partial.channels.selected.filter((value): value is OfficialChannelId => + isOfficialChannelId(value) + ) : []; return { @@ -806,16 +808,15 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push('# Runtime-only: CCS injects --channels at launch for compatible Claude sessions.'); lines.push('# Bot tokens live in Claude channel env files, not in config.yaml.'); lines.push('# Use selected: [telegram, discord, imessage] to choose channels.'); - lines.push('# unattended adds --dangerously-skip-permissions only when channel auto-enable is active.'); + lines.push( + '# unattended adds --dangerously-skip-permissions only when channel auto-enable is active.' + ); lines.push('# Compatible sessions: native Claude default/account profiles only.'); lines.push('# Configure via: ccs config channels or the Settings > Channels dashboard tab.'); lines.push('# ----------------------------------------------------------------------------'); lines.push( yaml - .dump( - { channels: config.channels }, - { indent: 2, lineWidth: -1, quotingType: '"' } - ) + .dump({ channels: config.channels }, { indent: 2, lineWidth: -1, quotingType: '"' }) .trim() ); lines.push(''); diff --git a/src/web-server/routes/channels-routes.ts b/src/web-server/routes/channels-routes.ts index 92507cf6..9d61f852 100644 --- a/src/web-server/routes/channels-routes.ts +++ b/src/web-server/routes/channels-routes.ts @@ -71,7 +71,8 @@ router.put('/', (req: Request, res: Response): void => { if ( selected !== undefined && - (!Array.isArray(selected) || selected.some((value) => typeof value !== 'string' || !isOfficialChannelId(value))) + (!Array.isArray(selected) || + selected.some((value) => typeof value !== 'string' || !isOfficialChannelId(value))) ) { res.status(400).json({ error: 'selected must be an array of official channel IDs' }); return; @@ -84,7 +85,7 @@ router.put('/', (req: Request, res: Response): void => { try { const updated = mutateUnifiedConfig((config) => { config.channels = { - selected: selected ? [...new Set(selected)] : config.channels?.selected ?? [], + selected: selected ? [...new Set(selected)] : (config.channels?.selected ?? []), unattended: unattended ?? config.channels?.unattended ?? false, }; }); From a97fc42b10d3ce783b2bf84bce499b7bb9dca141 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Mar 2026 10:02:11 -0400 Subject: [PATCH 4/8] feat(channels): auto-enable official Claude channels --- src/ccs.ts | 51 +- src/channels/official-channels-runtime.ts | 485 +++++++++++++++- src/channels/official-channels-store.ts | 173 ++++-- src/commands/config-channels-command.ts | 187 ++++++- src/commands/config-command-options.ts | 2 + src/commands/help-command.ts | 15 +- src/config/unified-config-loader.ts | 27 +- src/utils/claude-detector.ts | 129 ++++- src/web-server/routes/channels-routes.ts | 103 +++- .../official-channels-runtime.test.ts | 426 +++++++++++++- .../channels/official-channels-store.test.ts | 139 +++-- .../unit/commands/help-command-parity.test.ts | 19 + tests/unit/unified-config.test.ts | 71 +++ tests/unit/web-server/channels-routes.test.ts | 163 ++++++ .../hooks/use-official-channels-config.ts | 55 +- ui/src/pages/settings/sections/channels.tsx | 376 ++++++++++--- ui/src/pages/settings/types.ts | 51 ++ .../sections/channels-section.test.tsx | 518 ++++++++++++++++++ 18 files changed, 2653 insertions(+), 337 deletions(-) create mode 100644 tests/unit/web-server/channels-routes.test.ts create mode 100644 ui/tests/unit/ui/pages/settings/sections/channels-section.test.tsx diff --git a/src/ccs.ts b/src/ccs.ts index 637235ea..4d273c2b 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -36,17 +36,11 @@ import { fail, info, warn } from './utils/ui'; import { isCopilotSubcommandToken } from './copilot/constants'; import { buildOfficialChannelsArgs, - getOfficialChannelDisplayName, - getOfficialChannelTokenIds, - isBunAvailable, + getOfficialChannelsEnvironmentStatus, officialChannelRequiresMacOS, resolveOfficialChannelsLaunchPlan, - resolveOfficialChannelsSyncConfigDir, } from './channels/official-channels-runtime'; -import { - getOfficialChannelReadiness, - syncOfficialChannelEnvToConfigDir, -} from './channels/official-channels-store'; +import { getOfficialChannelReadiness } from './channels/official-channels-store'; // Import centralized error handling import { handleError, runCleanup } from './errors'; @@ -149,6 +143,9 @@ function resolveNativeClaudeLaunchArgs( targetConfigDir?: string ): string[] { const config = getOfficialChannelsConfig(); + const environment = getOfficialChannelsEnvironmentStatus( + targetConfigDir ? { CLAUDE_CONFIG_DIR: targetConfigDir } : undefined + ); const channelReadiness = { telegram: getOfficialChannelReadiness('telegram'), discord: getOfficialChannelReadiness('discord'), @@ -159,7 +156,7 @@ function resolveNativeClaudeLaunchArgs( config, target: 'claude', profileType, - bunAvailable: isBunAvailable(), + environment, channelReadiness, }); @@ -167,37 +164,19 @@ function resolveNativeClaudeLaunchArgs( console.error(warn(message)); } + if ( + config.selected.length > 0 && + environment.auth.state === 'eligible' && + environment.auth.orgRequirementMessage + ) { + console.error(warn(environment.auth.orgRequirementMessage)); + } + if (!plan.applied) { return args; } - const activeConfigDir = resolveOfficialChannelsSyncConfigDir(targetConfigDir); - const syncedChannels = [...plan.appliedChannels]; - - if (activeConfigDir) { - for (const channelId of [...syncedChannels]) { - if (!getOfficialChannelTokenIds().includes(channelId)) { - continue; - } - - const syncResult = syncOfficialChannelEnvToConfigDir(channelId, activeConfigDir); - if (!syncResult.synced && syncResult.reason !== 'already_current') { - const suffix = syncResult.error ? ` (${syncResult.error})` : ''; - console.error( - warn( - `${getOfficialChannelDisplayName(channelId)} auto-enable skipped: failed to sync channel env to ${syncResult.targetPath}${suffix}` - ) - ); - syncedChannels.splice(syncedChannels.indexOf(channelId), 1); - } - } - } - - if (syncedChannels.length === 0) { - return args; - } - - return buildOfficialChannelsArgs(args, syncedChannels, plan.wantsPermissionBypass); + return buildOfficialChannelsArgs(args, plan.appliedChannels, plan.wantsPermissionBypass); } async function main(): Promise { diff --git a/src/channels/official-channels-runtime.ts b/src/channels/official-channels-runtime.ts index 4cda65e7..f708f66e 100644 --- a/src/channels/official-channels-runtime.ts +++ b/src/channels/official-channels-runtime.ts @@ -2,6 +2,13 @@ import { spawnSync } from 'child_process'; import type { TargetType } from '../targets/target-adapter'; import type { ProfileType } from '../types/profile'; import type { OfficialChannelId, OfficialChannelsConfig } from '../config/unified-config-types'; +import type { OfficialChannelTokenSource } from './official-channels-store'; +import { + getClaudeAuthStatus, + getClaudeCliVersion, + isClaudeCliVersionAtLeast, + type ClaudeAuthStatus, +} from '../utils/claude-detector'; export interface OfficialChannelDefinition { id: OfficialChannelId; @@ -9,6 +16,7 @@ export interface OfficialChannelDefinition { pluginSpec: string; envKey?: string; envDir: string; + stateDirEnvKey: string; requiresMacOS?: boolean; manualSetupCommands: string[]; } @@ -20,6 +28,7 @@ export const OFFICIAL_CHANNELS: Record', @@ -33,6 +42,7 @@ export const OFFICIAL_CHANNELS: Record', @@ -45,6 +55,7 @@ export const OFFICIAL_CHANNELS: Record; } @@ -114,14 +190,125 @@ export function hasExplicitChannelsFlag(args: string[]): boolean { export function hasExplicitPermissionOverride(args: string[]): boolean { return args.some( (arg) => + arg === '--allow-dangerously-skip-permissions' || arg === '--dangerously-skip-permissions' || arg === '--permission-mode' || arg.startsWith('--permission-mode=') ); } -export function resolveOfficialChannelsSyncConfigDir(targetConfigDir?: string): string | undefined { - return targetConfigDir ?? process.env.CLAUDE_CONFIG_DIR; +function isTeamOrEnterpriseSubscription(subscriptionType: string | null): boolean { + const normalized = subscriptionType?.trim().toLowerCase() ?? ''; + return normalized.includes('team') || normalized.includes('enterprise'); +} + +export function resolveOfficialChannelsVersionSummary( + version: string | null +): OfficialChannelsVersionSummary { + if (!version) { + return { + current: null, + minimum: MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION, + state: 'unknown', + message: `Unable to detect Claude Code version. Official Channels require v${MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION}+.`, + }; + } + + if (isClaudeCliVersionAtLeast(version, MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION)) { + return { + current: version, + minimum: MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION, + state: 'supported', + message: `Claude Code v${version}`, + }; + } + + return { + current: version, + minimum: MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION, + state: 'unsupported', + message: `Official Channels require Claude Code v${MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION}+ (found v${version}).`, + }; +} + +export function resolveOfficialChannelsAuthSummary( + authStatus: ClaudeAuthStatus | null +): OfficialChannelsAuthSummary { + if (!authStatus) { + return { + checked: false, + loggedIn: false, + authMethod: null, + subscriptionType: null, + state: 'unknown', + eligible: false, + message: 'Unable to verify Claude auth status. Official Channels require claude.ai login.', + }; + } + + if (!authStatus.loggedIn) { + return { + checked: true, + loggedIn: false, + authMethod: authStatus.authMethod ?? null, + subscriptionType: authStatus.subscriptionType ?? null, + state: 'ineligible', + eligible: false, + message: 'Official Channels require claude.ai login. Run `claude auth login` first.', + }; + } + + if (authStatus.authMethod !== 'claude.ai') { + return { + checked: true, + loggedIn: true, + authMethod: authStatus.authMethod ?? null, + subscriptionType: authStatus.subscriptionType ?? null, + state: 'ineligible', + eligible: false, + message: `Official Channels require claude.ai login. Current auth method: ${authStatus.authMethod ?? 'unknown'}.`, + }; + } + + return { + checked: true, + loggedIn: true, + authMethod: authStatus.authMethod, + subscriptionType: authStatus.subscriptionType ?? null, + state: 'eligible', + eligible: true, + message: 'Authenticated with claude.ai.', + ...(isTeamOrEnterpriseSubscription(authStatus.subscriptionType ?? null) + ? { + orgRequirementMessage: + 'Team and Enterprise orgs also need channels enabled by an admin before messages will arrive.', + } + : {}), + }; +} + +export function getOfficialChannelsStateScopeMessage(): string { + return "Telegram and Discord tokens live in Claude's machine-level channel state under ~/.claude/channels/. Native Claude sessions share that state unless you manually override the official *_STATE_DIR variables."; +} + +export function getOfficialChannelsSupportMessage(): string { + return 'Works only for native Claude default/account sessions. It does not apply to API, OAuth, or Droid targets such as `ccs glm`, `ccs gemini`, `ccs codex`, or `ccs --target droid`.'; +} + +export function getOfficialChannelsAccountStatusCaveat(): string { + return 'Dashboard status reflects the base Claude install visible to the current CCS process. Isolated native account sessions can still differ until that account signs in with claude.ai.'; +} + +export function getOfficialChannelsEnvironmentStatus( + authEnvOverrides?: NodeJS.ProcessEnv +): OfficialChannelsEnvironmentStatus { + return { + bunInstalled: isBunAvailable(), + supportedProfiles: getOfficialChannelsSupportedProfiles(), + stateScopeMessage: getOfficialChannelsStateScopeMessage(), + claudeVersion: resolveOfficialChannelsVersionSummary(getClaudeCliVersion()), + auth: resolveOfficialChannelsAuthSummary(getClaudeAuthStatus(authEnvOverrides)), + }; } export function buildOfficialChannelsArgs( @@ -145,7 +332,7 @@ export function buildOfficialChannelsArgs( export function resolveOfficialChannelsLaunchPlan( input: DiscordChannelsLaunchInput ): DiscordChannelsLaunchPlan { - const { args, config, target, profileType, bunAvailable, channelReadiness } = input; + const { args, config, target, profileType, environment, channelReadiness } = input; const skippedMessages: string[] = []; if (config.selected.length === 0) { @@ -162,9 +349,7 @@ export function resolveOfficialChannelsLaunchPlan( applied: false, wantsPermissionBypass: false, appliedChannels: [], - skippedMessages: [ - 'Official Channels auto-enable only applies to native Claude default/account sessions.', - ], + skippedMessages: [getOfficialChannelsCompatibilityMessage()], }; } @@ -177,7 +362,7 @@ export function resolveOfficialChannelsLaunchPlan( }; } - if (!bunAvailable) { + if (!environment.bunInstalled) { return { applied: false, wantsPermissionBypass: false, @@ -186,6 +371,24 @@ export function resolveOfficialChannelsLaunchPlan( }; } + if (environment.claudeVersion.state !== 'supported') { + return { + applied: false, + wantsPermissionBypass: false, + appliedChannels: [], + skippedMessages: [environment.claudeVersion.message], + }; + } + + if (environment.auth.state !== 'eligible') { + return { + applied: false, + wantsPermissionBypass: false, + appliedChannels: [], + skippedMessages: [environment.auth.message], + }; + } + const appliedChannels: OfficialChannelId[] = []; for (const channelId of normalizeOfficialChannelIds(config.selected)) { @@ -244,15 +447,19 @@ export function getOfficialChannelEnvDir(channelId: OfficialChannelId): string { return OFFICIAL_CHANNELS[channelId].envDir; } +export function getOfficialChannelStateDirEnvKey(channelId: OfficialChannelId): string { + return OFFICIAL_CHANNELS[channelId].stateDirEnvKey; +} + export function getOfficialChannelSummary(channelId: OfficialChannelId): string { if (channelId === 'telegram') { - return 'Bot token required. Polls your Telegram bot while Claude is running.'; + return 'Bot token required. Runtime-only while Claude is running; Telegram pairing and access policy still happen in Claude.'; } if (channelId === 'discord') { - return 'Bot token required. Receives DMs and allowed server messages while Claude is running.'; + return 'Bot token required. Runtime-only while Claude is running; Discord pairing and access policy still happen in Claude.'; } - return 'macOS-only. No bot token required, but Messages permissions are required.'; + return 'macOS-only. Runtime-only while Claude is running; plugin install, Full Disk Access, and the first-reply Automation approval are still required.'; } export function getOfficialChannelUnavailableReason( @@ -268,14 +475,258 @@ export function getOfficialChannelUnavailableReason( export function getOfficialChannelReadyMessage(channelId: OfficialChannelId): string { if (channelId === 'imessage') { return isMacOS() - ? 'Ready after Claude-side install and macOS permissions.' + ? 'Needs Claude-side install plus Full Disk Access and the first-reply Automation prompt.' : 'Unavailable on this platform.'; } const envKey = getOfficialChannelEnvKey(channelId); return envKey - ? `${envKey} must be configured before CCS can auto-enable this channel.` - : 'Ready.'; + ? `${envKey} must be configured before CCS can auto-enable this channel. Claude-side pairing and access policy are still required.` + : 'Claude-side setup required.'; +} + +export function buildOfficialChannelSetupSummary( + channel: OfficialChannelsStatusChannelInput +): OfficialChannelSetupSummary { + if (!channel.selected) { + return { + state: 'not_selected', + label: 'Not selected', + detail: 'CCS will not auto-add this channel until you turn it on here.', + nextStep: 'Turn this channel on if you want CCS to add it on supported native Claude runs.', + }; + } + + if (channel.unavailableReason) { + return { + state: 'unavailable', + label: channel.unavailableReason, + detail: `${channel.displayName} is selected, but this machine cannot use it right now.`, + nextStep: 'Turn it off here, or switch to a supported machine before relying on it.', + }; + } + + if (channel.id === 'imessage') { + return { + state: 'needs_claude_setup', + label: 'Claude-side setup remaining', + detail: + 'CCS can add iMessage on the next native Claude run, but plugin install, sender allowlist, Full Disk Access, and the first-reply Automation prompt are still local steps.', + nextStep: 'Complete the one-time Claude and macOS setup below before relying on iMessage.', + }; + } + + const envKey = getOfficialChannelEnvKey(channel.id); + if (channel.requiresToken && !channel.tokenAvailable) { + return { + state: 'needs_token', + label: 'Needs token', + detail: `${envKey} is missing. CCS cannot auto-add ${channel.displayName} until you save it here or provide it in the current CCS process env.`, + nextStep: `Save ${envKey} below, or export it before launching CCS.`, + }; + } + + const sourceDetail = channel.savedInClaudeState + ? `${envKey} is saved in Claude channel state.` + : `${envKey} is available from the current CCS process env.`; + + return { + state: 'ready', + label: channel.savedInClaudeState + ? 'Ready for next native run' + : 'Ready from current CCS process env', + detail: channel.savedInClaudeState + ? `${sourceDetail}${channel.processEnvAvailable ? ` The current CCS process env also provides ${envKey}.` : ''} CCS can auto-add ${channel.displayName} on the next supported native Claude run. Claude-side pairing and access policy still happen in Claude.` + : `${sourceDetail} CCS can auto-add ${channel.displayName} on the next supported native Claude run. Claude-side pairing and access policy still happen in Claude.`, + nextStep: channel.savedInClaudeState + ? 'Run `ccs` or a native Claude account profile. Claude-side pairing and access policy may still be required.' + : 'Run CCS from this same env, or save the token here if you want persistent Claude state.', + }; +} + +export function buildOfficialChannelsReadinessSummary(input: { + config: OfficialChannelsConfig; + environment: OfficialChannelsEnvironmentStatus; + channels: OfficialChannelsStatusChannelInput[]; +}): OfficialChannelsReadinessSummary { + const { config, environment, channels } = input; + + if (config.selected.length === 0) { + return { + state: 'needs_setup', + title: 'No channels selected yet', + message: + 'Choose at least one official channel before CCS can auto-add it on supported native Claude runs.', + nextStep: 'Turn on Telegram, Discord, and/or iMessage below.', + blockers: ['Select at least one channel for auto-enable.'], + }; + } + + const blockers: string[] = []; + if (!environment.bunInstalled) { + blockers.push('Install Bun to use Anthropic official channel plugins.'); + } + if (environment.claudeVersion.state !== 'supported') { + blockers.push(environment.claudeVersion.message); + } + if (environment.auth.state !== 'eligible') { + blockers.push(environment.auth.message); + } + + const selectedChannels = channels.filter((channel) => channel.selected); + const missingTokenChannels = selectedChannels.filter( + (channel) => channel.requiresToken && !channel.tokenAvailable + ); + if (missingTokenChannels.length > 0) { + blockers.push( + `Missing bot token for ${missingTokenChannels.map((channel) => channel.displayName).join(', ')}.` + ); + } + + if (blockers.length > 0) { + return { + state: 'needs_setup', + title: 'Needs setup before CCS can auto-add these channels', + message: blockers[0] ?? 'Official Channels still need setup.', + nextStep: 'Resolve the blockers below, then launch a supported native Claude session again.', + blockers, + }; + } + + const limitedNotes: string[] = []; + const unavailableSelectedChannels = selectedChannels.filter((channel) => + Boolean(channel.unavailableReason) + ); + if (unavailableSelectedChannels.length > 0) { + limitedNotes.push( + `${unavailableSelectedChannels.map((channel) => channel.displayName).join(', ')} cannot run on this machine.` + ); + } + if (selectedChannels.some((channel) => channel.id === 'imessage')) { + limitedNotes.push( + 'iMessage still needs Claude-side install plus local macOS permissions before it is dependable.' + ); + } + + if (limitedNotes.length > 0) { + return { + state: 'limited', + title: 'Selected, but some channels still need manual setup', + message: limitedNotes[0] ?? 'Some selected channels still need additional setup.', + nextStep: 'Review the channel cards below before relying on this from a native Claude run.', + blockers: limitedNotes, + }; + } + + const selectedLabels = selectedChannels.map((channel) => channel.displayName).join(', '); + const envOnlyChannels = selectedChannels.filter( + (channel) => channel.processEnvAvailable && !channel.savedInClaudeState + ); + return { + state: 'ready', + title: 'Ready for the next native Claude run', + message: + envOnlyChannels.length === 0 + ? `CCS can auto-add ${selectedLabels} the next time you run \`ccs\` or a native Claude account profile.` + : envOnlyChannels.length === selectedChannels.length + ? `CCS can auto-add ${selectedLabels} on the next supported native Claude run from this same CCS process env.` + : `CCS can auto-add ${selectedLabels} on the next supported native Claude run. ${envOnlyChannels.map((channel) => channel.displayName).join(', ')} currently depends on this same CCS process env.`, + nextStep: + envOnlyChannels.length === 0 + ? 'Claude-side pairing and access policy may still be required inside Claude, but CCS-side prerequisites are ready.' + : envOnlyChannels.length === selectedChannels.length + ? 'Run CCS from this same env, or save the token here first if you want persistent Claude channel state.' + : 'Save env-only tokens here if you want persistent Claude channel state across shells.', + blockers: [], + }; +} + +export function buildOfficialChannelsLaunchPreview(input: { + config: OfficialChannelsConfig; + environment: OfficialChannelsEnvironmentStatus; + channels: OfficialChannelsStatusChannelInput[]; +}): OfficialChannelsLaunchPreview { + const { config, environment, channels } = input; + + if (config.selected.length === 0) { + return { + state: 'disabled', + title: 'Nothing will be auto-added yet', + detail: 'Turn on at least one channel below before `ccs` can add official channel flags.', + command: 'ccs', + appendedArgs: [], + appliedChannels: [], + permissionBypassIncluded: false, + skippedMessages: [], + }; + } + + const channelReadiness = Object.fromEntries( + channels.map((channel) => [ + channel.id, + !channel.unavailableReason && + (channel.id === 'imessage' || !channel.requiresToken || channel.tokenAvailable), + ]) + ) as Record; + + const plan = resolveOfficialChannelsLaunchPlan({ + args: [], + config, + target: 'claude', + profileType: 'default', + environment, + channelReadiness, + }); + + const appendedArgs = plan.applied + ? buildOfficialChannelsArgs([], plan.appliedChannels, plan.wantsPermissionBypass) + : []; + + if (!plan.applied) { + return { + state: 'blocked', + title: 'Running `ccs` now will not auto-add channels', + detail: + plan.skippedMessages[0] ?? + 'Official Channels are selected, but this machine is not ready to auto-add them yet.', + command: 'ccs', + appendedArgs: [], + appliedChannels: [], + permissionBypassIncluded: false, + skippedMessages: plan.skippedMessages, + }; + } + + const appliedLabels = plan.appliedChannels.map((channelId) => + getOfficialChannelDisplayName(channelId) + ); + + if (plan.skippedMessages.length > 0) { + return { + state: 'partial', + title: `CCS will auto-add ${appliedLabels.join(', ')}`, + detail: + 'Some selected channels are still skipped. Review the notes below before relying on the rest.', + command: 'ccs', + appendedArgs, + appliedChannels: plan.appliedChannels, + permissionBypassIncluded: plan.wantsPermissionBypass, + skippedMessages: plan.skippedMessages, + }; + } + + return { + state: 'ready', + title: `CCS will auto-add ${appliedLabels.join(', ')}`, + detail: plan.wantsPermissionBypass + ? 'Running `ccs` will add the selected official channels and skip permission prompts for that launch.' + : 'Running `ccs` will add the selected official channels automatically on this machine.', + command: 'ccs', + appendedArgs, + appliedChannels: plan.appliedChannels, + permissionBypassIncluded: plan.wantsPermissionBypass, + skippedMessages: [], + }; } export function expandOfficialChannelSelection(selection: string): OfficialChannelId[] { @@ -341,11 +792,11 @@ export function getOfficialChannelConfiguredPlaceholder(channelId: OfficialChann } export function getOfficialChannelsSectionDescription(): string { - return 'Auto-enable Anthropic official channels for compatible Claude sessions. Tokens stay in Claude channel env files rather than config.yaml.'; + return 'Auto-enable Anthropic official channels for compatible Claude sessions. CCS only stores selection in config.yaml; Claude keeps machine-level channel state under ~/.claude/channels/.'; } export function getOfficialChannelsRuntimeNote(): string { - return 'CCS does not persist a global Claude channels default. It only injects runtime flags when the selected channels are supported and ready.'; + return 'CCS does not persist a global Claude channels default. It only injects runtime flags for the current Claude session when prerequisites are met.'; } export function getOfficialChannelsSetHelp(): string { @@ -399,7 +850,7 @@ export function getOfficialChannelsBunMissingMessage(): string { } export function getOfficialChannelsCompatibilityMessage(): string { - return 'Official Channels auto-enable only applies to native Claude default/account sessions.'; + return 'Official Channels auto-enable only works for native Claude default/account sessions. It does not apply to `ccs glm`, other API/OAuth profiles, or Droid targets.'; } export function getOfficialChannelsNoSelectionMessage(): string { diff --git a/src/channels/official-channels-store.ts b/src/channels/official-channels-store.ts index 432fca15..00624d28 100644 --- a/src/channels/official-channels-store.ts +++ b/src/channels/official-channels-store.ts @@ -6,22 +6,42 @@ import type { OfficialChannelId } from '../config/unified-config-types'; import { getOfficialChannelEnvDir, getOfficialChannelEnvKey, + getOfficialChannelStateDirEnvKey, getOfficialChannelTokenIds, isOfficialChannelTokenRequired, } from './official-channels-runtime'; -export interface DiscordChannelsSyncResult { - synced: boolean; - targetPath: string; - reason?: 'missing_env' | 'missing_token' | 'already_current' | 'write_failed'; - error?: string; +export type OfficialChannelTokenSource = 'saved_env' | 'process_env' | 'missing'; + +export interface OfficialChannelTokenStatus { + available: boolean; + source: OfficialChannelTokenSource; + envKey?: string; + tokenPath?: string; + savedInClaudeState: boolean; + processEnvAvailable: boolean; +} + +function getResolvedStateDirOverride( + channelId: OfficialChannelId, + envOverrides?: NodeJS.ProcessEnv | null +): string | null { + const env = envOverrides === undefined ? process.env : envOverrides; + const rawStateDir = env?.[getOfficialChannelStateDirEnvKey(channelId)]?.trim(); + + return rawStateDir ? path.resolve(rawStateDir) : null; } export function getOfficialChannelEnvPath( channelId: OfficialChannelId, - configDir = getDefaultClaudeConfigDir() + configDir = getDefaultClaudeConfigDir(), + envOverrides?: NodeJS.ProcessEnv | null ): string { - return path.join(configDir, 'channels', getOfficialChannelEnvDir(channelId), '.env'); + const overrideStateDir = getResolvedStateDirOverride(channelId, envOverrides); + const stateDir = + overrideStateDir ?? path.join(configDir, 'channels', getOfficialChannelEnvDir(channelId)); + + return path.join(stateDir, '.env'); } function readFileIfExists(filePath: string): string | null { @@ -137,6 +157,19 @@ function listManagedClaudeConfigDirs(): string[] { return [...dirs]; } +function listManagedOfficialChannelEnvPaths(channelId: OfficialChannelId): string[] { + const envPaths = new Set([ + getOfficialChannelEnvPath(channelId, getDefaultClaudeConfigDir(), null), + getOfficialChannelEnvPath(channelId), + ]); + + for (const configDir of listManagedClaudeConfigDirs()) { + envPaths.add(getOfficialChannelEnvPath(channelId, configDir, null)); + } + + return [...envPaths]; +} + export function normalizeDiscordBotToken(value: string): string | null { const normalized = value.trim(); if (!normalized || /[\r\n]/.test(normalized)) { @@ -171,10 +204,82 @@ export function readConfiguredOfficialChannelToken(channelId: OfficialChannelId) return content ? readOfficialChannelTokenFromEnvContent(channelId, content) : null; } +export function readOfficialChannelTokenFromProcessEnv( + channelId: OfficialChannelId, + envOverrides?: NodeJS.ProcessEnv | null +): string | null { + const envKey = getOfficialChannelEnvKey(channelId); + if (!envKey) { + return null; + } + + const rawValue = (envOverrides === undefined ? process.env : envOverrides)?.[envKey]; + if (typeof rawValue !== 'string') { + return null; + } + + return normalizeDiscordBotToken(rawValue); +} + export function hasConfiguredOfficialChannelToken(channelId: OfficialChannelId): boolean { return readConfiguredOfficialChannelToken(channelId) !== null; } +export function getOfficialChannelTokenStatus( + channelId: OfficialChannelId, + envOverrides?: NodeJS.ProcessEnv | null +): OfficialChannelTokenStatus { + const envKey = getOfficialChannelEnvKey(channelId); + if (!envKey) { + return { + available: true, + source: 'saved_env', + savedInClaudeState: true, + processEnvAvailable: false, + }; + } + + const processEnvToken = readOfficialChannelTokenFromProcessEnv(channelId, envOverrides); + const tokenPath = getOfficialChannelEnvPath(channelId); + const savedToken = readConfiguredOfficialChannelToken(channelId); + + if (savedToken !== null) { + return { + available: true, + source: 'saved_env', + envKey, + tokenPath, + savedInClaudeState: true, + processEnvAvailable: processEnvToken !== null, + }; + } + + if (processEnvToken !== null) { + return { + available: true, + source: 'process_env', + envKey, + savedInClaudeState: false, + processEnvAvailable: true, + }; + } + + return { + available: false, + source: 'missing', + envKey, + tokenPath, + savedInClaudeState: false, + processEnvAvailable: false, + }; +} + +export function getOfficialChannelReadiness(channelId: OfficialChannelId): boolean { + return isOfficialChannelTokenRequired(channelId) + ? getOfficialChannelTokenStatus(channelId).available + : true; +} + export function setConfiguredOfficialChannelToken( channelId: OfficialChannelId, token: string @@ -207,9 +312,8 @@ export function clearConfiguredOfficialChannelTokensEverywhere( const clearedPaths: string[] = []; const channels = channelId ? [channelId] : getOfficialChannelTokenIds(); - for (const configDir of listManagedClaudeConfigDirs()) { - for (const tokenChannelId of channels) { - const envPath = getOfficialChannelEnvPath(tokenChannelId, configDir); + for (const tokenChannelId of channels) { + for (const envPath of listManagedOfficialChannelEnvPaths(tokenChannelId)) { if (clearOfficialChannelTokenAtPath(tokenChannelId, envPath)) { clearedPaths.push(envPath); } @@ -218,52 +322,3 @@ export function clearConfiguredOfficialChannelTokensEverywhere( return clearedPaths; } - -export function syncOfficialChannelEnvToConfigDir( - channelId: OfficialChannelId, - targetConfigDir: string -): DiscordChannelsSyncResult { - const envKey = getOfficialChannelEnvKey(channelId); - if (!envKey) { - return { - synced: false, - targetPath: getOfficialChannelEnvPath(channelId, targetConfigDir), - reason: 'missing_token', - }; - } - - const sourcePath = getOfficialChannelEnvPath(channelId); - const targetPath = getOfficialChannelEnvPath(channelId, targetConfigDir); - const token = readConfiguredOfficialChannelToken(channelId); - - if (!fs.existsSync(sourcePath)) { - return { synced: false, targetPath, reason: 'missing_env' }; - } - - 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, envKey, token)); - return { synced: true, targetPath }; - } catch (error) { - return { - synced: false, - targetPath, - reason: 'write_failed', - error: (error as Error).message, - }; - } -} - -export function getOfficialChannelReadiness(channelId: OfficialChannelId): boolean { - return isOfficialChannelTokenRequired(channelId) - ? hasConfiguredOfficialChannelToken(channelId) - : true; -} diff --git a/src/commands/config-channels-command.ts b/src/commands/config-channels-command.ts index 26a2d269..6ccf49d9 100644 --- a/src/commands/config-channels-command.ts +++ b/src/commands/config-channels-command.ts @@ -8,18 +8,22 @@ import type { OfficialChannelId } from '../config/unified-config-types'; import { DEFAULT_OFFICIAL_CHANNELS_CONFIG } from '../config/unified-config-types'; import { clearConfiguredOfficialChannelTokensEverywhere, - getOfficialChannelEnvPath, + getOfficialChannelTokenStatus, hasConfiguredOfficialChannelToken, setConfiguredOfficialChannelToken, } from '../channels/official-channels-store'; import { + buildOfficialChannelsLaunchPreview, + buildOfficialChannelsReadinessSummary, + buildOfficialChannelSetupSummary, expandOfficialChannelSelection, getChannelConfigSelectionLabel, getOfficialChannelChoices, + getOfficialChannelsAccountStatusCaveat, + getOfficialChannelsSupportMessage, getOfficialChannelDisplayName, getOfficialChannelEnvKey, getOfficialChannelManualSetupCommands, - getOfficialChannelReadyMessage, getOfficialChannelsCompatibilityMessage, getOfficialChannelsDocsSummary, getOfficialChannelsLegacyEnableHelp, @@ -28,12 +32,12 @@ import { getOfficialChannelClearTokenHelp, getOfficialChannelMacOSHelp, getOfficialChannelSummary, + getOfficialChannelsEnvironmentStatus, getOfficialChannelsRuntimeNote, getOfficialChannelsSectionDescription, getOfficialChannelsSupportedProfiles, getOfficialChannelUnavailableReason, getOfficialChannelTokenIds, - isBunAvailable, isOfficialChannelId, isOfficialChannelSelectionValid, } from '../channels/official-channels-runtime'; @@ -128,6 +132,9 @@ function showHelp(): void { console.log(''); console.log(` ${getOfficialChannelsSectionDescription()}`); console.log(` ${dim(getOfficialChannelsDocsSummary())}`); + console.log( + ` ${dim('Fastest path: run `ccs config`, open Settings -> Channels, turn on the channel, save the token if needed, then run `ccs`.')}` + ); console.log(''); console.log(subheader('Usage:')); console.log(` ${color('ccs config channels', 'command')} [options]`); @@ -135,8 +142,12 @@ function showHelp(): void { console.log(subheader('Options:')); console.log(` ${color('--set ', 'command')} ${getOfficialChannelsSetHelp()}`); console.log(` ${color('--clear', 'command')} Clear all selected channels`); - console.log(` ${color('--enable', 'command')} Legacy alias: add Discord`); - console.log(` ${color('--disable', 'command')} Legacy alias: remove Discord`); + console.log( + ` ${color('--enable', 'command')} Legacy compatibility alias: add Discord` + ); + console.log( + ` ${color('--disable', 'command')} Legacy compatibility alias: remove Discord` + ); console.log( ` ${color('--unattended', 'command')} Also add --dangerously-skip-permissions` ); @@ -148,6 +159,9 @@ function showHelp(): void { console.log(` ${color('--help, -h', 'command')} Show this help`); console.log(''); console.log(subheader('Examples:')); + console.log( + ` $ ${color('ccs config', 'command')} ${dim('# Dashboard -> Settings -> Channels (fastest path)')}` + ); console.log( ` $ ${color('ccs config channels', 'command')} ${dim('# Show status')}` ); @@ -163,60 +177,173 @@ function showHelp(): void { console.log( ` $ ${color('ccs config channels --clear-token discord', 'command')} ${dim('# Clear one token')}` ); + console.log( + ` ${dim('Official Channels only work on native Claude default/account sessions, not on ccs glm or other API/OAuth/Droid targets.')}` + ); console.log(''); } function showStatus(): void { const config = getOfficialChannelsConfig(); const selected = config.selected; - const bunReady = isBunAvailable(); + const environment = getOfficialChannelsEnvironmentStatus(); + const channelRows = expandOfficialChannelSelection('all').map((channelId) => { + const selectedForLaunch = selected.includes(channelId); + const tokenStatus = getOfficialChannelTokenStatus(channelId); + + return { + id: channelId, + displayName: getOfficialChannelDisplayName(channelId), + selected: selectedForLaunch, + requiresToken: getOfficialChannelTokenIds().includes(channelId), + tokenConfigured: hasConfiguredOfficialChannelToken(channelId), + tokenStatus, + unavailableReason: getOfficialChannelUnavailableReason(channelId), + setup: buildOfficialChannelSetupSummary({ + id: channelId, + displayName: getOfficialChannelDisplayName(channelId), + selected: selectedForLaunch, + requiresToken: getOfficialChannelTokenIds().includes(channelId), + tokenAvailable: tokenStatus.available, + tokenSource: tokenStatus.source, + savedInClaudeState: tokenStatus.savedInClaudeState, + processEnvAvailable: tokenStatus.processEnvAvailable, + unavailableReason: getOfficialChannelUnavailableReason(channelId), + }), + }; + }); + const summary = buildOfficialChannelsReadinessSummary({ + config, + environment, + channels: channelRows.map((channel) => ({ + id: channel.id, + displayName: channel.displayName, + selected: channel.selected, + requiresToken: channel.requiresToken, + tokenAvailable: channel.tokenStatus.available, + tokenSource: channel.tokenStatus.source, + savedInClaudeState: channel.tokenStatus.savedInClaudeState, + processEnvAvailable: channel.tokenStatus.processEnvAvailable, + unavailableReason: channel.unavailableReason, + })), + }); + const launchPreview = buildOfficialChannelsLaunchPreview({ + config, + environment, + channels: channelRows.map((channel) => ({ + id: channel.id, + displayName: channel.displayName, + selected: channel.selected, + requiresToken: channel.requiresToken, + tokenAvailable: channel.tokenStatus.available, + tokenSource: channel.tokenStatus.source, + savedInClaudeState: channel.tokenStatus.savedInClaudeState, + processEnvAvailable: channel.tokenStatus.processEnvAvailable, + unavailableReason: channel.unavailableReason, + })), + }); console.log(''); console.log(header('Official Channels Configuration')); console.log(''); + console.log( + ` Status: ${ + summary.state === 'ready' + ? ok(summary.title) + : summary.state === 'limited' + ? warn(summary.title) + : warn(summary.title) + }` + ); + console.log(` ${dim(summary.message)}`); + console.log(` ${dim(summary.nextStep)}`); + console.log(''); + console.log(` Launch: ${info(launchPreview.title)}`); + console.log(` ${dim(launchPreview.detail)}`); + if (launchPreview.appendedArgs.length > 0) { + console.log(` ${dim(`ccs adds: ${launchPreview.appendedArgs.join(' ')}`)}`); + } + if (launchPreview.skippedMessages.length > 0) { + console.log(` ${dim(`Skipped: ${launchPreview.skippedMessages.join(' | ')}`)}`); + } + console.log(''); console.log( ` Channels: ${selected.length > 0 ? ok(getChannelConfigSelectionLabel(selected)) : warn('Disabled')}` ); console.log(` Unattended: ${config.unattended ? warn('Enabled') : info('Disabled')}`); - console.log(` Bun: ${bunReady ? ok('Installed') : warn('Missing')}`); + console.log(` Bun: ${environment.bunInstalled ? ok('Installed') : warn('Missing')}`); + console.log( + ` Claude Code: ${ + environment.claudeVersion.state === 'supported' + ? ok(environment.claudeVersion.message) + : environment.claudeVersion.state === 'unsupported' + ? warn(environment.claudeVersion.message) + : info(environment.claudeVersion.message) + }` + ); + console.log( + ` Claude Auth: ${ + environment.auth.state === 'eligible' + ? ok(environment.auth.message) + : environment.auth.state === 'ineligible' + ? warn(environment.auth.message) + : info(environment.auth.message) + }` + ); console.log(''); console.log(subheader('Applies To:')); - console.log(` ${dim(getOfficialChannelsCompatibilityMessage())}`); + console.log(` ${dim(getOfficialChannelsSupportMessage())}`); console.log( ` ${dim(`Supported profiles: ${getOfficialChannelsSupportedProfiles().join(', ')}`)}` ); + console.log(` ${dim(environment.stateScopeMessage)}`); + console.log(` ${dim(getOfficialChannelsAccountStatusCaveat())}`); + if (environment.auth.orgRequirementMessage) { + console.log(` ${dim(environment.auth.orgRequirementMessage)}`); + } console.log(''); console.log(subheader('Channels:')); - for (const channelId of expandOfficialChannelSelection('all')) { - const displayName = getOfficialChannelDisplayName(channelId); - const enabled = selected.includes(channelId); - const envKey = getOfficialChannelEnvKey(channelId); - const tokenConfigured = envKey ? hasConfiguredOfficialChannelToken(channelId) : true; - const unavailableReason = getOfficialChannelUnavailableReason(channelId); - const status = unavailableReason - ? warn(unavailableReason) - : envKey - ? tokenConfigured - ? ok('Ready') - : warn(`${envKey} missing`) - : ok('Ready'); - console.log(` ${enabled ? '[x]' : '[ ]'} ${displayName}: ${status}`); - console.log(` ${dim(getOfficialChannelSummary(channelId))}`); - if (envKey) { - console.log( - ` ${dim(`${envKey}: ${tokenConfigured ? 'configured' : 'not configured'}`)}` - ); - console.log(` ${dim(getOfficialChannelEnvPath(channelId))}`); + for (const channel of channelRows) { + const status = + channel.setup.state === 'ready' + ? ok(channel.setup.label) + : channel.setup.state === 'not_selected' + ? info(channel.setup.label) + : warn(channel.setup.label); + console.log(` ${channel.selected ? '[x]' : '[ ]'} ${channel.displayName}: ${status}`); + console.log(` ${dim(getOfficialChannelSummary(channel.id))}`); + console.log(` ${dim(channel.setup.detail)}`); + console.log(` ${dim(channel.setup.nextStep)}`); + if (channel.requiresToken) { + const envKey = getOfficialChannelEnvKey(channel.id) ?? ''; + if (channel.tokenStatus.source === 'saved_env') { + console.log(` ${dim(`${envKey}: saved in Claude channel state`)}`); + if (channel.tokenStatus.processEnvAvailable) { + console.log(` ${dim(`${envKey}: also available from current CCS process env`)}`); + } + if (channel.tokenStatus.tokenPath) { + console.log(` ${dim(channel.tokenStatus.tokenPath)}`); + } + } else if (channel.tokenStatus.source === 'process_env') { + console.log(` ${dim(`${envKey}: available from current CCS process env`)}`); + } else { + console.log(` ${dim(`${envKey}: missing`)}`); + if (channel.tokenStatus.tokenPath) { + console.log(` ${dim(channel.tokenStatus.tokenPath)}`); + } + } } - console.log(` ${dim(getOfficialChannelReadyMessage(channelId))}`); } console.log(''); console.log(subheader('Notes:')); console.log(` ${dim(getOfficialChannelsLegacyEnableHelp())}`); + console.log(` ${dim(environment.stateScopeMessage)}`); console.log(` ${dim(getOfficialChannelMacOSHelp())}`); console.log(` ${dim(getOfficialChannelsRuntimeNote())}`); + console.log(` ${dim(getOfficialChannelsCompatibilityMessage())}`); + console.log(` ${dim(getOfficialChannelsAccountStatusCaveat())}`); console.log(''); - console.log(subheader('Manual Claude Setup:')); + console.log(subheader('Claude-side Setup:')); for (const channelId of expandOfficialChannelSelection('all')) { console.log(` ${dim(`${getOfficialChannelDisplayName(channelId)}:`)}`); for (const command of getOfficialChannelManualSetupCommands(channelId)) { diff --git a/src/commands/config-command-options.ts b/src/commands/config-command-options.ts index 7c0e25e9..24d31a38 100644 --- a/src/commands/config-command-options.ts +++ b/src/commands/config-command-options.ts @@ -92,6 +92,8 @@ export function showConfigCommandHelp(): void { console.log(' --set-token Save channel token (telegram= or discord=)'); console.log(' --clear-token Remove all saved channel tokens'); console.log(' --clear-token Remove one saved channel token'); + console.log(' Works only for native Claude default/account sessions'); + console.log(' Not for ccs glm, other API/OAuth profiles, or Droid targets'); console.log(''); console.log(' auth Manage dashboard authentication'); console.log(' auth setup Configure username and password'); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index f6aefb3d..756d8022 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -312,7 +312,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs config auth setup', 'Configure dashboard login'], ['ccs config auth show', 'Show dashboard auth status'], ['ccs config channels', 'Show Official Channels status'], - ['ccs config channels --set telegram,discord', 'Auto-enable Telegram + Discord'], + [ + 'ccs config channels --set telegram,discord', + 'Auto-add Telegram + Discord on supported native Claude runs', + ], ['ccs config channels --set-token telegram=', 'Save TELEGRAM_BOT_TOKEN'], ['ccs config image-analysis', 'Show image analysis settings'], ['ccs config image-analysis --enable', 'Enable image analysis'], @@ -470,10 +473,11 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ]); printSubSection('Official Channels (official Claude plugins)', [ + ['ccs config', 'Dashboard -> Settings -> Channels (fastest path)'], ['ccs config channels', 'Show current status'], [ 'ccs config channels --set telegram,discord', - 'Auto-add selected channels on native Claude sessions', + 'Auto-add selected channels on native Claude default/account sessions', ], ['ccs config channels --set all', 'Enable Telegram, Discord, and iMessage'], ['ccs config channels --unattended', 'Also add --dangerously-skip-permissions'], @@ -481,8 +485,11 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs config channels --set-token discord=', 'Save DISCORD_BOT_TOKEN'], ['ccs config channels --clear-token [channel]', 'Remove one or all saved channel tokens'], ['', ''], - ['Note:', 'Runtime-only. Applies to native Claude default/account sessions.'], + ['', 'Fastest path: turn on the channel, save the token if needed, then run ccs.'], + ['Note:', 'Runtime-only. Applies to native Claude default/account sessions only.'], + ['', 'Not supported for ccs glm, other API/OAuth profiles, or Droid targets.'], ['', 'Telegram/Discord tokens live in ~/.claude/channels//.env.'], + ['', 'Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work for that launch.'], ['', 'iMessage is macOS-only and requires local OS permissions instead of a bot token.'], ]); @@ -554,6 +561,4 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // License console.log(dim('License: MIT')); console.log(''); - - process.exit(0); } diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index e571219d..7a91ab83 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -79,10 +79,15 @@ function getLockFilePath(): string { function acquireLock(): string | null { const lockPath = getLockFilePath(); + const lockDir = path.dirname(lockPath); const lockToken = crypto.randomUUID(); const lockData = `${process.pid}\n${Date.now()}\n${lockToken}`; try { + if (!fs.existsSync(lockDir)) { + fs.mkdirSync(lockDir, { recursive: true, mode: 0o700 }); + } + // Check if lock exists if (fs.existsSync(lockPath)) { const content = fs.readFileSync(lockPath, 'utf8'); @@ -304,17 +309,21 @@ interface LegacyDiscordChannelsConfig { function normalizeOfficialChannelsConfig( partial: Partial & { discord_channels?: LegacyDiscordChannelsConfig } ): OfficialChannelsConfig { - const rawSelected = Array.isArray(partial.channels?.selected) - ? partial.channels.selected.filter((value): value is OfficialChannelId => - isOfficialChannelId(value) - ) - : []; + const hasCanonicalChannelsSection = partial.channels !== undefined; + const hasExplicitSelectedField = + hasCanonicalChannelsSection && + Object.prototype.hasOwnProperty.call(partial.channels, 'selected'); + const rawSelected = + hasExplicitSelectedField && Array.isArray(partial.channels?.selected) + ? partial.channels.selected.filter((value): value is OfficialChannelId => + isOfficialChannelId(value) + ) + : []; return { - selected: - rawSelected.length > 0 - ? normalizeOfficialChannelIds(rawSelected) - : resolveLegacyDiscordSelection(partial.discord_channels?.enabled), + selected: hasCanonicalChannelsSection + ? normalizeOfficialChannelIds(rawSelected) + : resolveLegacyDiscordSelection(partial.discord_channels?.enabled), unattended: partial.channels?.unattended ?? partial.discord_channels?.unattended ?? diff --git a/src/utils/claude-detector.ts b/src/utils/claude-detector.ts index 1edd0a04..a0cea8ed 100644 --- a/src/utils/claude-detector.ts +++ b/src/utils/claude-detector.ts @@ -1,7 +1,18 @@ import * as fs from 'fs'; -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; import { expandPath } from './helpers'; -import { ClaudeCliInfo } from '../types'; +import type { ClaudeCliInfo } from '../types'; +import { escapeShellArg, stripAnthropicEnv, stripClaudeCodeEnv } from './shell-executor'; + +export interface ClaudeAuthStatus { + loggedIn: boolean; + authMethod?: string | null; + apiProvider?: string | null; + email?: string | null; + orgId?: string | null; + orgName?: string | null; + subscriptionType?: string | null; +} /** * Windows installation paths for Claude CLI @@ -127,6 +138,120 @@ export function getClaudeCliInfo(): ClaudeCliInfo | null { }; } +function runClaudeCliCommand(args: string[], envOverrides?: NodeJS.ProcessEnv): string | null { + const cliInfo = getClaudeCliInfo(); + if (!cliInfo) { + return null; + } + + const env = stripClaudeCodeEnv(stripAnthropicEnv({ ...process.env, ...envOverrides })); + const { path: claudePath, needsShell } = cliInfo; + const isWindows = process.platform === 'win32'; + const isPowerShellScript = isWindows && /\.ps1$/i.test(claudePath); + + try { + if (isPowerShellScript) { + return execFileSync( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudePath, ...args], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + env, + } + ).trim(); + } + + if (needsShell) { + return execSync([claudePath, ...args].map(escapeShellArg).join(' '), { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + shell: process.env.ComSpec || 'cmd.exe', + env, + }).trim(); + } + + return execFileSync(claudePath, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + env, + }).trim(); + } catch (error) { + if (typeof error === 'object' && error !== null && 'stdout' in error) { + const stdout = (error as { stdout?: string | Buffer | null }).stdout; + if (typeof stdout === 'string') { + const trimmed = stdout.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (Buffer.isBuffer(stdout)) { + const trimmed = stdout.toString('utf8').trim(); + return trimmed.length > 0 ? trimmed : null; + } + } + + return null; + } +} + +export function getClaudeCliVersion(): string | null { + const output = runClaudeCliCommand(['--version']); + const versionMatch = output?.match(/(\d+\.\d+\.\d+)/); + return versionMatch ? versionMatch[1] : null; +} + +export function compareClaudeCliVersions(left: string, right: string): number { + const leftParts = left.split('.').map((value) => Number.parseInt(value, 10) || 0); + const rightParts = right.split('.').map((value) => Number.parseInt(value, 10) || 0); + const maxLength = Math.max(leftParts.length, rightParts.length); + + for (let index = 0; index < maxLength; index += 1) { + const leftValue = leftParts[index] ?? 0; + const rightValue = rightParts[index] ?? 0; + + if (leftValue !== rightValue) { + return leftValue > rightValue ? 1 : -1; + } + } + + return 0; +} + +export function isClaudeCliVersionAtLeast( + currentVersion: string | null, + minimumVersion: string +): boolean { + return currentVersion !== null && compareClaudeCliVersions(currentVersion, minimumVersion) >= 0; +} + +export function getClaudeAuthStatus(envOverrides?: NodeJS.ProcessEnv): ClaudeAuthStatus | null { + const output = runClaudeCliCommand(['auth', 'status'], envOverrides); + if (!output) { + return null; + } + + try { + const parsed = JSON.parse(output) as Partial; + if (typeof parsed.loggedIn !== 'boolean') { + return null; + } + + return { + loggedIn: parsed.loggedIn, + authMethod: parsed.authMethod ?? null, + apiProvider: parsed.apiProvider ?? null, + email: parsed.email ?? null, + orgId: parsed.orgId ?? null, + orgName: parsed.orgName ?? null, + subscriptionType: parsed.subscriptionType ?? null, + }; + } catch { + return null; + } +} + /** * Show Claude not found error */ diff --git a/src/web-server/routes/channels-routes.ts b/src/web-server/routes/channels-routes.ts index 9d61f852..044dd5ca 100644 --- a/src/web-server/routes/channels-routes.ts +++ b/src/web-server/routes/channels-routes.ts @@ -2,48 +2,110 @@ import { Router, type Request, type Response } from 'express'; import { getOfficialChannelsConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; import { clearConfiguredOfficialChannelTokensEverywhere, - getOfficialChannelEnvPath, - getOfficialChannelReadiness, + getOfficialChannelTokenStatus, hasConfiguredOfficialChannelToken, setConfiguredOfficialChannelToken, } from '../../channels/official-channels-store'; import { + buildOfficialChannelsLaunchPreview, + buildOfficialChannelsReadinessSummary, + buildOfficialChannelSetupSummary, expandOfficialChannelSelection, + getOfficialChannelsAccountStatusCaveat, getOfficialChannelDisplayName, getOfficialChannelEnvKey, getOfficialChannelPluginSpec, getOfficialChannelSummary, + getOfficialChannelsSupportMessage, getOfficialChannelUnavailableReason, - getOfficialChannelsSupportedProfiles, + getOfficialChannelsEnvironmentStatus, getOfficialChannelManualSetupCommands, getOfficialChannelTokenIds, - isBunAvailable, isOfficialChannelId, } from '../../channels/official-channels-runtime'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; const router = Router(); -function buildChannelsStatus() { - return { - bunInstalled: isBunAvailable(), - supportedProfiles: getOfficialChannelsSupportedProfiles(), - channels: expandOfficialChannelSelection('all').map((channelId) => ({ +function buildChannelsStatus(config = getOfficialChannelsConfig()) { + const environment = getOfficialChannelsEnvironmentStatus(); + const channels = expandOfficialChannelSelection('all').map((channelId) => { + const tokenStatus = getOfficialChannelTokenIds().includes(channelId) + ? getOfficialChannelTokenStatus(channelId) + : undefined; + const selected = config.selected.includes(channelId); + + return { id: channelId, + selected, displayName: getOfficialChannelDisplayName(channelId), pluginSpec: getOfficialChannelPluginSpec(channelId), summary: getOfficialChannelSummary(channelId), requiresToken: getOfficialChannelTokenIds().includes(channelId), envKey: getOfficialChannelEnvKey(channelId), - tokenConfigured: getOfficialChannelTokenIds().includes(channelId) - ? hasConfiguredOfficialChannelToken(channelId) - : getOfficialChannelReadiness(channelId), - tokenPath: getOfficialChannelTokenIds().includes(channelId) - ? getOfficialChannelEnvPath(channelId) - : undefined, + tokenConfigured: + getOfficialChannelTokenIds().includes(channelId) && + hasConfiguredOfficialChannelToken(channelId), + tokenAvailable: tokenStatus?.available ?? false, + tokenSource: tokenStatus?.source, + tokenPath: tokenStatus?.tokenPath, + savedInClaudeState: tokenStatus?.savedInClaudeState ?? false, + processEnvAvailable: tokenStatus?.processEnvAvailable ?? false, unavailableReason: getOfficialChannelUnavailableReason(channelId), manualSetupCommands: getOfficialChannelManualSetupCommands(channelId), - })), + setup: buildOfficialChannelSetupSummary({ + id: channelId, + displayName: getOfficialChannelDisplayName(channelId), + selected, + requiresToken: getOfficialChannelTokenIds().includes(channelId), + tokenAvailable: tokenStatus?.available ?? false, + tokenSource: tokenStatus?.source, + savedInClaudeState: tokenStatus?.savedInClaudeState ?? false, + processEnvAvailable: tokenStatus?.processEnvAvailable ?? false, + unavailableReason: getOfficialChannelUnavailableReason(channelId), + }), + }; + }); + + return { + bunInstalled: environment.bunInstalled, + supportedProfiles: environment.supportedProfiles, + supportMessage: getOfficialChannelsSupportMessage(), + accountStatusCaveat: getOfficialChannelsAccountStatusCaveat(), + stateScopeMessage: environment.stateScopeMessage, + claudeVersion: environment.claudeVersion, + auth: environment.auth, + summary: buildOfficialChannelsReadinessSummary({ + config, + environment, + channels: channels.map((channel) => ({ + id: channel.id, + displayName: channel.displayName, + selected: channel.selected, + requiresToken: channel.requiresToken, + tokenAvailable: channel.tokenAvailable, + tokenSource: channel.tokenSource, + savedInClaudeState: channel.savedInClaudeState, + processEnvAvailable: channel.processEnvAvailable, + unavailableReason: channel.unavailableReason, + })), + }), + launchPreview: buildOfficialChannelsLaunchPreview({ + config, + environment, + channels: channels.map((channel) => ({ + id: channel.id, + displayName: channel.displayName, + selected: channel.selected, + requiresToken: channel.requiresToken, + tokenAvailable: channel.tokenAvailable, + tokenSource: channel.tokenSource, + savedInClaudeState: channel.savedInClaudeState, + processEnvAvailable: channel.processEnvAvailable, + unavailableReason: channel.unavailableReason, + })), + }), + channels, }; } @@ -60,9 +122,10 @@ router.use((req: Request, res: Response, next) => { }); router.get('/', (_req: Request, res: Response): void => { + const config = getOfficialChannelsConfig(); res.json({ - config: getOfficialChannelsConfig(), - status: buildChannelsStatus(), + config, + status: buildChannelsStatus(config), }); }); @@ -85,7 +148,8 @@ router.put('/', (req: Request, res: Response): void => { try { const updated = mutateUnifiedConfig((config) => { config.channels = { - selected: selected ? [...new Set(selected)] : (config.channels?.selected ?? []), + selected: + selected !== undefined ? [...new Set(selected)] : (config.channels?.selected ?? []), unattended: unattended ?? config.channels?.unattended ?? false, }; }); @@ -132,7 +196,6 @@ router.delete('/:channelId/token', (req: Request, res: Response): void => { res.json({ success: true, tokenConfigured: false, - tokenPath: getOfficialChannelEnvPath(channelId), clearedPaths, }); } catch (error) { diff --git a/tests/unit/channels/official-channels-runtime.test.ts b/tests/unit/channels/official-channels-runtime.test.ts index b3b0b814..329b3146 100644 --- a/tests/unit/channels/official-channels-runtime.test.ts +++ b/tests/unit/channels/official-channels-runtime.test.ts @@ -1,15 +1,63 @@ import { describe, expect, it } from 'bun:test'; import { + MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION, OFFICIAL_CHANNELS, + buildOfficialChannelsLaunchPreview, + buildOfficialChannelsReadinessSummary, + buildOfficialChannelSetupSummary, buildOfficialChannelsArgs, expandOfficialChannelSelection, hasExplicitChannelsFlag, hasExplicitPermissionOverride, isDiscordChannelsSessionSupported, + resolveOfficialChannelsAuthSummary, resolveOfficialChannelsLaunchPlan, - resolveOfficialChannelsSyncConfigDir, + resolveOfficialChannelsVersionSummary, + type OfficialChannelsAuthSummary, + type OfficialChannelsEnvironmentStatus, + type OfficialChannelsVersionSummary, } from '../../../src/channels/official-channels-runtime'; +function buildSupportedVersionSummary( + overrides: Partial = {} +): OfficialChannelsVersionSummary { + return { + current: '2.1.81', + minimum: MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION, + state: 'supported', + message: 'Claude Code v2.1.81', + ...overrides, + }; +} + +function buildEligibleAuthSummary( + overrides: Partial = {} +): OfficialChannelsAuthSummary { + return { + checked: true, + loggedIn: true, + authMethod: 'claude.ai', + subscriptionType: 'pro', + state: 'eligible', + eligible: true, + message: 'Authenticated with claude.ai.', + ...overrides, + }; +} + +function buildEnvironment( + overrides: Partial = {} +): OfficialChannelsEnvironmentStatus { + return { + bunInstalled: true, + supportedProfiles: ['default', 'account'], + stateScopeMessage: 'state scope', + claudeVersion: buildSupportedVersionSummary(overrides.claudeVersion), + auth: buildEligibleAuthSummary(overrides.auth), + ...overrides, + }; +} + describe('official channels runtime planning', () => { it('supports only native Claude default/account sessions', () => { expect(isDiscordChannelsSessionSupported('claude', 'default')).toBe(true); @@ -26,6 +74,7 @@ describe('official channels runtime planning', () => { expect(hasExplicitChannelsFlag(['--permission-mode', 'acceptEdits'])).toBe(false); expect(hasExplicitPermissionOverride(['--dangerously-skip-permissions'])).toBe(true); + expect(hasExplicitPermissionOverride(['--allow-dangerously-skip-permissions'])).toBe(true); expect(hasExplicitPermissionOverride(['--permission-mode', 'acceptEdits'])).toBe(true); expect(hasExplicitPermissionOverride(['--permission-mode=acceptEdits'])).toBe(true); }); @@ -48,7 +97,7 @@ describe('official channels runtime planning', () => { config: { selected: ['telegram', 'discord'], unattended: true }, target: 'claude', profileType: 'default', - bunAvailable: true, + environment: buildEnvironment(), channelReadiness: { telegram: true, discord: true, @@ -63,11 +112,11 @@ describe('official channels runtime planning', () => { it('keeps explicit permission choice and still returns ready channels', () => { const plan = resolveOfficialChannelsLaunchPlan({ - args: ['--permission-mode', 'acceptEdits'], + args: ['--allow-dangerously-skip-permissions'], config: { selected: ['discord'], unattended: true }, target: 'claude', profileType: 'account', - bunAvailable: true, + environment: buildEnvironment(), channelReadiness: { telegram: false, discord: true, @@ -86,7 +135,7 @@ describe('official channels runtime planning', () => { config: { selected: ['discord'], unattended: false }, target: 'claude', profileType: 'settings', - bunAvailable: true, + environment: buildEnvironment(), channelReadiness: { telegram: true, discord: true, @@ -98,7 +147,7 @@ describe('official channels runtime planning', () => { config: { selected: ['discord'], unattended: false }, target: 'claude', profileType: 'default', - bunAvailable: false, + environment: buildEnvironment({ bunInstalled: false }), channelReadiness: { telegram: true, discord: true, @@ -110,7 +159,7 @@ describe('official channels runtime planning', () => { config: { selected: ['telegram', 'discord'], unattended: false }, target: 'claude', profileType: 'default', - bunAvailable: true, + environment: buildEnvironment(), channelReadiness: { telegram: false, discord: true, @@ -127,13 +176,115 @@ describe('official channels runtime planning', () => { expect(missingToken.skippedMessages.join(' ')).toContain('TELEGRAM_BOT_TOKEN is not configured'); }); + it('skips launch when Claude Code version is unsupported or auth is ineligible', () => { + const unsupportedVersion = resolveOfficialChannelsLaunchPlan({ + args: [], + config: { selected: ['discord'], unattended: true }, + target: 'claude', + profileType: 'default', + environment: buildEnvironment({ + claudeVersion: buildSupportedVersionSummary({ + current: '2.1.79', + state: 'unsupported', + message: + 'Official Channels require Claude Code v2.1.80+ (found v2.1.79).', + }), + }), + channelReadiness: { + telegram: true, + discord: true, + imessage: true, + }, + }); + const ineligibleAuth = resolveOfficialChannelsLaunchPlan({ + args: [], + config: { selected: ['discord'], unattended: true }, + target: 'claude', + profileType: 'default', + environment: buildEnvironment({ + auth: buildEligibleAuthSummary({ + authMethod: 'console-key', + state: 'ineligible', + eligible: false, + message: 'Official Channels require claude.ai login. Current auth method: console-key.', + }), + }), + channelReadiness: { + telegram: true, + discord: true, + imessage: true, + }, + }); + const unknownVersion = resolveOfficialChannelsLaunchPlan({ + args: [], + config: { selected: ['discord'], unattended: true }, + target: 'claude', + profileType: 'default', + environment: buildEnvironment({ + claudeVersion: buildSupportedVersionSummary({ + current: null, + state: 'unknown', + message: 'Unable to detect Claude Code version. Official Channels require v2.1.80+.', + }), + }), + channelReadiness: { + telegram: true, + discord: true, + imessage: true, + }, + }); + const unknownAuth = resolveOfficialChannelsLaunchPlan({ + args: [], + config: { selected: ['discord'], unattended: true }, + target: 'claude', + profileType: 'default', + environment: buildEnvironment({ + auth: buildEligibleAuthSummary({ + checked: false, + loggedIn: false, + authMethod: null, + subscriptionType: null, + state: 'unknown', + eligible: false, + message: + 'Unable to verify Claude auth status. Official Channels require claude.ai login.', + }), + }), + channelReadiness: { + telegram: true, + discord: true, + imessage: true, + }, + }); + + expect(unsupportedVersion.applied).toBe(false); + expect(unsupportedVersion.skippedMessages).toEqual([ + 'Official Channels require Claude Code v2.1.80+ (found v2.1.79).', + ]); + + expect(ineligibleAuth.applied).toBe(false); + expect(ineligibleAuth.skippedMessages).toEqual([ + 'Official Channels require claude.ai login. Current auth method: console-key.', + ]); + + expect(unknownVersion.applied).toBe(false); + expect(unknownVersion.skippedMessages).toEqual([ + 'Unable to detect Claude Code version. Official Channels require v2.1.80+.', + ]); + + expect(unknownAuth.applied).toBe(false); + expect(unknownAuth.skippedMessages).toEqual([ + 'Unable to verify Claude auth status. Official Channels require claude.ai login.', + ]); + }); + it('leaves explicit channel arguments untouched', () => { const plan = resolveOfficialChannelsLaunchPlan({ args: ['--channels', 'plugin:custom'], config: { selected: ['discord'], unattended: true }, target: 'claude', profileType: 'default', - bunAvailable: true, + environment: buildEnvironment(), channelReadiness: { telegram: true, discord: true, @@ -146,19 +297,252 @@ describe('official channels runtime planning', () => { expect(plan.skippedMessages).toEqual([]); }); - it('falls back to process.env.CLAUDE_CONFIG_DIR for sync when no explicit dir is passed', () => { - const originalConfigDir = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = '/tmp/external-claude-config'; + it('summarizes version compatibility states', () => { + const unknown = resolveOfficialChannelsVersionSummary(null); + const supported = resolveOfficialChannelsVersionSummary(MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION); + const unsupported = resolveOfficialChannelsVersionSummary('2.1.79'); - try { - expect(resolveOfficialChannelsSyncConfigDir()).toBe('/tmp/external-claude-config'); - expect(resolveOfficialChannelsSyncConfigDir('/tmp/explicit')).toBe('/tmp/explicit'); - } finally { - if (originalConfigDir !== undefined) { - process.env.CLAUDE_CONFIG_DIR = originalConfigDir; - } else { - delete process.env.CLAUDE_CONFIG_DIR; - } - } + expect(unknown.state).toBe('unknown'); + expect(unknown.message).toContain(`v${MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION}+`); + + expect(supported.state).toBe('supported'); + expect(supported.message).toBe(`Claude Code v${MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION}`); + + expect(unsupported.state).toBe('unsupported'); + expect(unsupported.message).toContain(`found v2.1.79`); + }); + + it('summarizes auth eligibility states and org requirements', () => { + const unknown = resolveOfficialChannelsAuthSummary(null); + const loggedOut = resolveOfficialChannelsAuthSummary({ + loggedIn: false, + authMethod: null, + subscriptionType: null, + }); + const wrongAuth = resolveOfficialChannelsAuthSummary({ + loggedIn: true, + authMethod: 'api-key', + subscriptionType: 'pro', + }); + const team = resolveOfficialChannelsAuthSummary({ + loggedIn: true, + authMethod: 'claude.ai', + subscriptionType: 'team', + }); + + expect(unknown.state).toBe('unknown'); + expect(loggedOut.state).toBe('ineligible'); + expect(loggedOut.message).toContain('claude auth login'); + + expect(wrongAuth.state).toBe('ineligible'); + expect(wrongAuth.message).toContain('Current auth method: api-key'); + + expect(team.state).toBe('eligible'); + expect(team.orgRequirementMessage).toContain('enabled by an admin'); + }); + + it('builds source-aware setup summaries for token and iMessage channels', () => { + expect( + buildOfficialChannelSetupSummary({ + id: 'discord', + displayName: 'Discord', + selected: true, + requiresToken: true, + tokenAvailable: true, + tokenSource: 'process_env', + savedInClaudeState: false, + processEnvAvailable: true, + }) + ).toMatchObject({ + state: 'ready', + label: 'Ready from current CCS process env', + }); + + expect( + buildOfficialChannelSetupSummary({ + id: 'telegram', + displayName: 'Telegram', + selected: true, + requiresToken: true, + tokenAvailable: false, + tokenSource: 'missing', + savedInClaudeState: false, + processEnvAvailable: false, + }) + ).toMatchObject({ + state: 'needs_token', + label: 'Needs token', + }); + + expect( + buildOfficialChannelSetupSummary({ + id: 'imessage', + displayName: 'iMessage', + selected: true, + requiresToken: false, + tokenAvailable: true, + }) + ).toMatchObject({ + state: 'needs_claude_setup', + label: 'Claude-side setup remaining', + }); + }); + + it('builds an overall readiness summary that stays explicit about blockers and partial readiness', () => { + const needsSetup = buildOfficialChannelsReadinessSummary({ + config: { selected: ['discord'], unattended: false }, + environment: buildEnvironment({ bunInstalled: false }), + channels: [ + { + id: 'discord', + displayName: 'Discord', + selected: true, + requiresToken: true, + tokenAvailable: false, + tokenSource: 'missing', + savedInClaudeState: false, + processEnvAvailable: false, + }, + ], + }); + const limited = buildOfficialChannelsReadinessSummary({ + config: { selected: ['imessage'], unattended: false }, + environment: buildEnvironment(), + channels: [ + { + id: 'imessage', + displayName: 'iMessage', + selected: true, + requiresToken: false, + tokenAvailable: true, + savedInClaudeState: false, + processEnvAvailable: false, + }, + ], + }); + const ready = buildOfficialChannelsReadinessSummary({ + config: { selected: ['telegram', 'discord'], unattended: false }, + environment: buildEnvironment(), + channels: [ + { + id: 'telegram', + displayName: 'Telegram', + selected: true, + requiresToken: true, + tokenAvailable: true, + tokenSource: 'saved_env', + savedInClaudeState: true, + processEnvAvailable: false, + }, + { + id: 'discord', + displayName: 'Discord', + selected: true, + requiresToken: true, + tokenAvailable: true, + tokenSource: 'process_env', + savedInClaudeState: false, + processEnvAvailable: true, + }, + ], + }); + + expect(needsSetup).toMatchObject({ + state: 'needs_setup', + title: 'Needs setup before CCS can auto-add these channels', + }); + expect(needsSetup.blockers.join(' ')).toContain('Install Bun'); + expect(needsSetup.blockers.join(' ')).toContain('Missing bot token'); + + expect(limited).toMatchObject({ + state: 'limited', + title: 'Selected, but some channels still need manual setup', + }); + expect(limited.blockers.join(' ')).toContain('iMessage still needs Claude-side install'); + + expect(ready).toMatchObject({ + state: 'ready', + title: 'Ready for the next native Claude run', + }); + expect(ready.message).toContain('Discord currently depends on this same CCS process env'); + }); + + it('builds a launch preview for the default `ccs` path', () => { + const preview = buildOfficialChannelsLaunchPreview({ + config: { selected: ['telegram', 'discord'], unattended: true }, + environment: buildEnvironment(), + channels: [ + { + id: 'telegram', + displayName: 'Telegram', + selected: true, + requiresToken: true, + tokenAvailable: true, + tokenSource: 'saved_env', + savedInClaudeState: true, + processEnvAvailable: false, + }, + { + id: 'discord', + displayName: 'Discord', + selected: true, + requiresToken: true, + tokenAvailable: true, + tokenSource: 'process_env', + savedInClaudeState: false, + processEnvAvailable: true, + }, + ], + }); + + expect(preview).toMatchObject({ + state: 'ready', + title: 'CCS will auto-add Telegram, Discord', + command: 'ccs', + permissionBypassIncluded: true, + appendedArgs: [ + '--channels', + OFFICIAL_CHANNELS.telegram.pluginSpec, + OFFICIAL_CHANNELS.discord.pluginSpec, + '--dangerously-skip-permissions', + ], + }); + }); + + it('keeps launch preview explicit when only part of the selection can be applied', () => { + const preview = buildOfficialChannelsLaunchPreview({ + config: { selected: ['telegram', 'discord'], unattended: false }, + environment: buildEnvironment(), + channels: [ + { + id: 'telegram', + displayName: 'Telegram', + selected: true, + requiresToken: true, + tokenAvailable: false, + tokenSource: 'missing', + savedInClaudeState: false, + processEnvAvailable: false, + }, + { + id: 'discord', + displayName: 'Discord', + selected: true, + requiresToken: true, + tokenAvailable: true, + tokenSource: 'saved_env', + savedInClaudeState: true, + processEnvAvailable: false, + }, + ], + }); + + expect(preview).toMatchObject({ + state: 'partial', + title: 'CCS will auto-add Discord', + command: 'ccs', + appendedArgs: ['--channels', OFFICIAL_CHANNELS.discord.pluginSpec], + skippedMessages: ['Telegram auto-enable skipped because TELEGRAM_BOT_TOKEN is not configured.'], + }); }); }); diff --git a/tests/unit/channels/official-channels-store.test.ts b/tests/unit/channels/official-channels-store.test.ts index 854629b6..e114c000 100644 --- a/tests/unit/channels/official-channels-store.test.ts +++ b/tests/unit/channels/official-channels-store.test.ts @@ -5,12 +5,13 @@ import * as path from 'path'; import { clearConfiguredOfficialChannelToken, clearConfiguredOfficialChannelTokensEverywhere, + getOfficialChannelTokenStatus, getOfficialChannelEnvPath, hasConfiguredOfficialChannelToken, readConfiguredOfficialChannelToken, + readOfficialChannelTokenFromProcessEnv, readOfficialChannelTokenFromEnvContent, setConfiguredOfficialChannelToken, - syncOfficialChannelEnvToConfigDir, } from '../../../src/channels/official-channels-store'; describe('official channels token store', () => { @@ -55,6 +56,71 @@ describe('official channels token store', () => { expect(readConfiguredOfficialChannelToken('telegram')).toBe('telegram-secret'); }); + it('uses the official state-dir override when one is configured', () => { + const originalDiscordStateDir = process.env.DISCORD_STATE_DIR; + process.env.DISCORD_STATE_DIR = path.join(tempHome, 'discord-state'); + + try { + const envPath = setConfiguredOfficialChannelToken('discord', 'discord-secret'); + + expect(envPath).toBe(path.join(tempHome, 'discord-state', '.env')); + expect(getOfficialChannelEnvPath('discord')).toBe(path.join(tempHome, 'discord-state', '.env')); + expect(readConfiguredOfficialChannelToken('discord')).toBe('discord-secret'); + } finally { + if (originalDiscordStateDir !== undefined) { + process.env.DISCORD_STATE_DIR = originalDiscordStateDir; + } else { + delete process.env.DISCORD_STATE_DIR; + } + } + }); + + it('treats a current-process env token as available readiness without marking it as saved', () => { + const originalDiscordToken = process.env.DISCORD_BOT_TOKEN; + process.env.DISCORD_BOT_TOKEN = 'discord-from-env'; + + try { + expect(readOfficialChannelTokenFromProcessEnv('discord')).toBe('discord-from-env'); + expect(hasConfiguredOfficialChannelToken('discord')).toBe(false); + expect(getOfficialChannelTokenStatus('discord')).toEqual({ + available: true, + source: 'process_env', + envKey: 'DISCORD_BOT_TOKEN', + savedInClaudeState: false, + processEnvAvailable: true, + }); + } finally { + if (originalDiscordToken !== undefined) { + process.env.DISCORD_BOT_TOKEN = originalDiscordToken; + } else { + delete process.env.DISCORD_BOT_TOKEN; + } + } + }); + + it('prefers current-process env tokens over saved Claude state for readiness source', () => { + const originalTelegramToken = process.env.TELEGRAM_BOT_TOKEN; + setConfiguredOfficialChannelToken('telegram', 'telegram-saved'); + process.env.TELEGRAM_BOT_TOKEN = 'telegram-from-env'; + + try { + expect(getOfficialChannelTokenStatus('telegram')).toEqual({ + available: true, + source: 'saved_env', + envKey: 'TELEGRAM_BOT_TOKEN', + tokenPath: path.join(tempHome, '.claude', 'channels', 'telegram', '.env'), + savedInClaudeState: true, + processEnvAvailable: true, + }); + } finally { + if (originalTelegramToken !== undefined) { + process.env.TELEGRAM_BOT_TOKEN = originalTelegramToken; + } else { + delete process.env.TELEGRAM_BOT_TOKEN; + } + } + }); + it('removes only the channel token entry and deletes the file when nothing remains', () => { const envPath = getOfficialChannelEnvPath('discord'); fs.mkdirSync(path.dirname(envPath), { recursive: true }); @@ -69,46 +135,51 @@ describe('official channels token store', () => { expect(fs.existsSync(envPath)).toBe(false); }); - it('syncs the canonical env file into an alternate CLAUDE_CONFIG_DIR for account sessions', () => { - setConfiguredOfficialChannelToken('discord', 'discord-secret'); - - const targetConfigDir = path.join(tempHome, '.ccs', 'instances', 'work'); - const targetPath = path.join(targetConfigDir, 'channels', 'discord', '.env'); - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.writeFileSync(targetPath, '# keep\nOTHER_KEY=value\n', 'utf8'); - - const result = syncOfficialChannelEnvToConfigDir('discord', targetConfigDir); - - expect(result.synced).toBe(true); - expect(result.targetPath).toBe(targetPath); - expect(fs.readFileSync(targetPath, 'utf8')).toBe( - '# keep\nOTHER_KEY=value\n\nDISCORD_BOT_TOKEN=discord-secret\n' - ); - expect(fs.statSync(targetPath).mode & 0o777).toBe(0o600); - }); - it('clears previously synced copies across managed Claude config dirs', () => { + const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; setConfiguredOfficialChannelToken('discord', 'discord-secret'); setConfiguredOfficialChannelToken('telegram', 'telegram-secret'); const instanceConfigDir = path.join(tempHome, '.ccs', 'instances', 'work'); - const instanceEnvPath = path.join(instanceConfigDir, 'channels', 'discord', '.env'); - const telegramEnvPath = path.join(instanceConfigDir, 'channels', 'telegram', '.env'); + const processConfigDir = path.join(tempHome, '.claude-account-session'); + const staleDiscordInstancePath = getOfficialChannelEnvPath('discord', instanceConfigDir); + const staleTelegramInstancePath = getOfficialChannelEnvPath('telegram', instanceConfigDir); + const staleDiscordProcessPath = getOfficialChannelEnvPath('discord', processConfigDir); - syncOfficialChannelEnvToConfigDir('discord', instanceConfigDir); - syncOfficialChannelEnvToConfigDir('telegram', instanceConfigDir); - expect(fs.existsSync(instanceEnvPath)).toBe(true); - expect(fs.existsSync(telegramEnvPath)).toBe(true); + process.env.CLAUDE_CONFIG_DIR = processConfigDir; - const clearedPaths = clearConfiguredOfficialChannelTokensEverywhere(); + try { + fs.mkdirSync(path.dirname(staleDiscordInstancePath), { recursive: true }); + fs.writeFileSync(staleDiscordInstancePath, 'DISCORD_BOT_TOKEN=discord-secret\n', 'utf8'); - expect(clearedPaths).toContain(getOfficialChannelEnvPath('discord')); - expect(clearedPaths).toContain(getOfficialChannelEnvPath('telegram')); - expect(clearedPaths).toContain(instanceEnvPath); - expect(clearedPaths).toContain(telegramEnvPath); - expect(fs.existsSync(getOfficialChannelEnvPath('discord'))).toBe(false); - expect(fs.existsSync(getOfficialChannelEnvPath('telegram'))).toBe(false); - expect(fs.existsSync(instanceEnvPath)).toBe(false); - expect(fs.existsSync(telegramEnvPath)).toBe(false); + fs.mkdirSync(path.dirname(staleTelegramInstancePath), { recursive: true }); + fs.writeFileSync(staleTelegramInstancePath, 'TELEGRAM_BOT_TOKEN=telegram-secret\n', 'utf8'); + + fs.mkdirSync(path.dirname(staleDiscordProcessPath), { recursive: true }); + fs.writeFileSync(staleDiscordProcessPath, 'DISCORD_BOT_TOKEN=discord-secret\n', 'utf8'); + + expect(fs.existsSync(staleDiscordInstancePath)).toBe(true); + expect(fs.existsSync(staleTelegramInstancePath)).toBe(true); + expect(fs.existsSync(staleDiscordProcessPath)).toBe(true); + + const clearedPaths = clearConfiguredOfficialChannelTokensEverywhere(); + + expect(clearedPaths).toContain(getOfficialChannelEnvPath('discord')); + expect(clearedPaths).toContain(getOfficialChannelEnvPath('telegram')); + expect(clearedPaths).toContain(staleDiscordInstancePath); + expect(clearedPaths).toContain(staleTelegramInstancePath); + expect(clearedPaths).toContain(staleDiscordProcessPath); + expect(fs.existsSync(getOfficialChannelEnvPath('discord'))).toBe(false); + expect(fs.existsSync(getOfficialChannelEnvPath('telegram'))).toBe(false); + expect(fs.existsSync(staleDiscordInstancePath)).toBe(false); + expect(fs.existsSync(staleTelegramInstancePath)).toBe(false); + expect(fs.existsSync(staleDiscordProcessPath)).toBe(false); + } finally { + if (originalClaudeConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } else { + delete process.env.CLAUDE_CONFIG_DIR; + } + } }); }); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 2ed57bd1..e7afd2ce 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -84,4 +84,23 @@ describe('help command parity', () => { expect(rendered.includes('ccs config --host 0.0.0.0')).toBe(true); expect(rendered.includes('Force all-interface binding for remote devices')).toBe(true); }); + + test('root help documents official channels native-only scope and process-env tokens', async () => { + const lines: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map((arg) => String(arg)).join(' ')); + }; + + await handleHelpCommand(); + + const rendered = stripAnsi(lines.join('\n')); + expect(rendered.includes('Dashboard -> Settings -> Channels (fastest path)')).toBe(true); + expect( + rendered.includes('Fastest path: turn on the channel, save the token if needed, then run ccs.') + ).toBe(true); + expect(rendered.includes('Not supported for ccs glm')).toBe(true); + expect(rendered.includes('Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work')).toBe( + true + ); + }); }); diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index aeb3a062..13c5661c 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -266,3 +266,74 @@ describe('continuity-inheritance-config', () => { } }); }); + +describe('official-channels-config', () => { + it('keeps explicit channels.selected empty even when legacy discord_channels.enabled is true', () => { + const originalCcsHome = process.env.CCS_HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-official-channels-home-')); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'channels:', + ' selected: []', + ' unattended: false', + 'discord_channels:', + ' enabled: true', + ' unattended: true', + '', + ].join('\n') + ); + + process.env.CCS_HOME = tempHome; + try { + const config = loadOrCreateUnifiedConfig(); + expect(config.channels?.selected).toEqual([]); + expect(config.channels?.unattended).toBe(false); + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('treats the canonical channels section as authoritative even without selected', () => { + const originalCcsHome = process.env.CCS_HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-official-channels-canonical-')); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'channels:', + ' unattended: false', + 'discord_channels:', + ' enabled: true', + ' unattended: true', + '', + ].join('\n') + ); + + process.env.CCS_HOME = tempHome; + try { + const config = loadOrCreateUnifiedConfig(); + expect(config.channels?.selected).toEqual([]); + expect(config.channels?.unattended).toBe(false); + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/web-server/channels-routes.test.ts b/tests/unit/web-server/channels-routes.test.ts new file mode 100644 index 00000000..49633dab --- /dev/null +++ b/tests/unit/web-server/channels-routes.test.ts @@ -0,0 +1,163 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; +import channelsRoutes from '../../../src/web-server/routes/channels-routes'; +import { getOfficialChannelsConfig } from '../../../src/config/unified-config-loader'; + +async function putJson(baseUrl: string, routePath: string, body: unknown): Promise { + return fetch(`${baseUrl}${routePath}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('web-server channels-routes', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalCcsUnified: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/channels', channelsRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const handleError = (error: Error) => reject(error); + server.once('error', handleError); + server.once('listening', () => { + server.off('error', handleError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-channels-routes-')); + originalCcsHome = process.env.CCS_HOME; + originalCcsUnified = process.env.CCS_UNIFIED_CONFIG; + + process.env.CCS_HOME = tempHome; + process.env.CCS_UNIFIED_CONFIG = '1'; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalCcsUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalCcsUnified; + else delete process.env.CCS_UNIFIED_CONFIG; + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('persists an empty selected array when clearing all official channels', async () => { + let response = await putJson(baseUrl, '/api/channels', { + selected: ['discord', 'telegram'], + unattended: true, + }); + expect(response.status).toBe(200); + + response = await putJson(baseUrl, '/api/channels', { + selected: [], + }); + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + config?: { + selected?: string[]; + unattended?: boolean; + }; + }; + + expect(payload.config?.selected).toEqual([]); + expect(payload.config?.unattended).toBe(true); + expect(getOfficialChannelsConfig()).toEqual({ + selected: [], + unattended: true, + }); + }); + + it('reports current-process env tokens as available readiness in GET status', async () => { + const originalDiscordToken = process.env.DISCORD_BOT_TOKEN; + process.env.DISCORD_BOT_TOKEN = 'discord-from-env'; + + try { + await putJson(baseUrl, '/api/channels', { + selected: ['discord'], + }); + + const response = await fetch(`${baseUrl}/api/channels`); + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + status?: { + summary?: { + title?: string; + }; + launchPreview?: { + state?: string; + title?: string; + appendedArgs?: string[]; + }; + supportMessage?: string; + accountStatusCaveat?: string; + channels?: Array<{ + id?: string; + tokenConfigured?: boolean; + tokenAvailable?: boolean; + tokenSource?: string; + setup?: { + label?: string; + }; + }>; + }; + }; + + expect(payload.status?.summary?.title).toBe('Ready for the next native Claude run'); + expect(payload.status?.launchPreview).toEqual( + expect.objectContaining({ + state: 'ready', + title: 'CCS will auto-add Discord', + appendedArgs: ['--channels', 'plugin:discord@claude-plugins-official'], + }) + ); + expect(payload.status?.supportMessage).toContain('ccs glm'); + expect(payload.status?.accountStatusCaveat).toContain('current CCS process'); + expect(payload.status?.channels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'discord', + tokenConfigured: false, + tokenAvailable: true, + tokenSource: 'process_env', + setup: expect.objectContaining({ + label: 'Ready from current CCS process env', + }), + }), + ]) + ); + } finally { + if (originalDiscordToken !== undefined) process.env.DISCORD_BOT_TOKEN = originalDiscordToken; + else delete process.env.DISCORD_BOT_TOKEN; + } + }); +}); diff --git a/ui/src/pages/settings/hooks/use-official-channels-config.ts b/ui/src/pages/settings/hooks/use-official-channels-config.ts index da182578..877edf9c 100644 --- a/ui/src/pages/settings/hooks/use-official-channels-config.ts +++ b/ui/src/pages/settings/hooks/use-official-channels-config.ts @@ -6,6 +6,15 @@ const DEFAULT_CONFIG: OfficialChannelsConfig = { unattended: false, }; +async function readErrorMessage(response: Response, fallback: string): Promise { + try { + const data = (await response.json()) as { error?: unknown }; + return typeof data.error === 'string' && data.error.trim().length > 0 ? data.error : fallback; + } catch { + return fallback; + } +} + export function useOfficialChannelsConfig() { const [config, setConfig] = useState(DEFAULT_CONFIG); const [status, setStatus] = useState(null); @@ -19,13 +28,13 @@ export function useOfficialChannelsConfig() { window.setTimeout(() => setSuccess(null), 1500); }, []); - const fetchConfig = useCallback(async () => { + const fetchConfig = useCallback(async (): Promise => { try { setLoading(true); setError(null); const res = await fetch('/api/channels'); if (!res.ok) { - throw new Error('Failed to load Official Channels settings'); + throw new Error(await readErrorMessage(res, 'Failed to load Official Channels settings')); } const data = (await res.json()) as { @@ -35,15 +44,20 @@ export function useOfficialChannelsConfig() { setConfig(data.config ?? DEFAULT_CONFIG); setStatus(data.status ?? null); + return true; } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); + return false; } finally { setLoading(false); } }, []); const updateConfig = useCallback( - async (updates: Partial, successMessage = 'Settings saved') => { + async ( + updates: Partial, + successMessage = 'Settings saved' + ): Promise => { try { setSaving(true); setError(null); @@ -55,24 +69,25 @@ export function useOfficialChannelsConfig() { }); if (!res.ok) { - const data = (await res.json()) as { error?: string }; - throw new Error(data.error || 'Failed to save Official Channels settings'); + throw new Error(await readErrorMessage(res, 'Failed to save Official Channels settings')); } const data = (await res.json()) as { config?: OfficialChannelsConfig }; - setConfig(data.config ?? { ...config, ...updates }); + setConfig((current) => data.config ?? { ...current, ...updates }); flashSuccess(successMessage); + return true; } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); + return false; } finally { setSaving(false); } }, - [config, flashSuccess] + [flashSuccess] ); const saveToken = useCallback( - async (channelId: OfficialChannelId, token: string) => { + async (channelId: OfficialChannelId, token: string): Promise => { try { setSaving(true); setError(null); @@ -84,14 +99,19 @@ export function useOfficialChannelsConfig() { }); if (!res.ok) { - const data = (await res.json()) as { error?: string }; - throw new Error(data.error || `Failed to save ${channelId} token`); + throw new Error(await readErrorMessage(res, `Failed to save ${channelId} token`)); + } + + const refreshed = await fetchConfig(); + if (!refreshed) { + return false; } - await fetchConfig(); flashSuccess(`${channelId} token saved`); + return true; } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); + return false; } finally { setSaving(false); } @@ -100,7 +120,7 @@ export function useOfficialChannelsConfig() { ); const clearToken = useCallback( - async (channelId: OfficialChannelId) => { + async (channelId: OfficialChannelId): Promise => { try { setSaving(true); setError(null); @@ -110,14 +130,19 @@ export function useOfficialChannelsConfig() { }); if (!res.ok) { - const data = (await res.json()) as { error?: string }; - throw new Error(data.error || `Failed to clear ${channelId} token`); + throw new Error(await readErrorMessage(res, `Failed to clear ${channelId} token`)); + } + + const refreshed = await fetchConfig(); + if (!refreshed) { + return false; } - await fetchConfig(); flashSuccess(`${channelId} token cleared`); + return true; } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); + return false; } finally { setSaving(false); } diff --git a/ui/src/pages/settings/sections/channels.tsx b/ui/src/pages/settings/sections/channels.tsx index 695390e9..36d1a001 100644 --- a/ui/src/pages/settings/sections/channels.tsx +++ b/ui/src/pages/settings/sections/channels.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -26,6 +27,62 @@ const EMPTY_DRAFTS: TokenDrafts = { imessage: '', }; +function getSummaryClasses(state: 'ready' | 'needs_setup' | 'limited'): string { + if (state === 'ready') { + return 'border-green-200 bg-green-50 text-green-900 dark:border-green-900/60 dark:bg-green-950/40 dark:text-green-100'; + } + if (state === 'limited') { + return 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100'; + } + + return 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-100'; +} + +function getSetupBadgeVariant(state: string): 'default' | 'secondary' | 'destructive' | 'outline' { + if (state === 'ready') { + return 'default'; + } + if (state === 'not_selected') { + return 'secondary'; + } + if (state === 'unavailable') { + return 'destructive'; + } + + return 'outline'; +} + +function getLaunchPreviewBadgeVariant( + state: 'disabled' | 'blocked' | 'partial' | 'ready' +): 'default' | 'secondary' | 'destructive' | 'outline' { + if (state === 'ready') { + return 'default'; + } + if (state === 'partial') { + return 'outline'; + } + if (state === 'blocked') { + return 'destructive'; + } + + return 'secondary'; +} + +function getSelectedChannelLabel( + selected: OfficialChannelId[], + channels: Array<{ id: OfficialChannelId; displayName: string }> | undefined +): string { + if (selected.length === 0) { + return 'None selected'; + } + + return selected + .map( + (channelId) => channels?.find((channel) => channel.id === channelId)?.displayName ?? channelId + ) + .join(', '); +} + export default function ChannelsSection() { const { config, @@ -41,6 +98,7 @@ export default function ChannelsSection() { } = useOfficialChannelsConfig(); const { fetchRawConfig } = useRawConfig(); const [tokenDrafts, setTokenDrafts] = useState(EMPTY_DRAFTS); + const selectedChannelLabel = getSelectedChannelLabel(config.selected, status?.channels); useEffect(() => { void fetchConfig(); @@ -56,11 +114,13 @@ export default function ChannelsSection() { ? [...new Set([...config.selected, channelId])] : config.selected.filter((value) => value !== channelId); - await updateConfig( + const updated = await updateConfig( { selected: nextSelected }, - checked ? `${channelId} enabled` : `${channelId} disabled` + checked ? `${channelId} selected for auto-enable` : `${channelId} removed from auto-enable` ); - await fetchRawConfig(); + if (updated) { + await Promise.all([fetchConfig(), fetchRawConfig()]); + } }; const updateTokenDraft = (channelId: OfficialChannelId, value: string) => { @@ -68,15 +128,19 @@ export default function ChannelsSection() { }; const handleSaveToken = async (channelId: OfficialChannelId): Promise => { - await saveToken(channelId, tokenDrafts[channelId]); - setTokenDrafts((current) => ({ ...current, [channelId]: '' })); - await fetchRawConfig(); + const saved = await saveToken(channelId, tokenDrafts[channelId]); + if (saved) { + setTokenDrafts((current) => ({ ...current, [channelId]: '' })); + await fetchRawConfig(); + } }; const handleClearToken = async (channelId: OfficialChannelId): Promise => { - await clearToken(channelId); - setTokenDrafts((current) => ({ ...current, [channelId]: '' })); - await fetchRawConfig(); + const cleared = await clearToken(channelId); + if (cleared) { + setTokenDrafts((current) => ({ ...current, [channelId]: '' })); + await fetchRawConfig(); + } }; if (loading) { @@ -115,66 +179,145 @@ export default function ChannelsSection() {
-
+
-

- Auto-enable Anthropic's official Claude channels for compatible native Claude - sessions. CCS stores only channel selection in config.yaml; bot tokens - stay in Claude's per-channel env files. -

-
- -
-
-

Selected

-

- {config.selected.length > 0 ? config.selected.join(', ') : 'None'} +

+

Official Channels

+

+ Configure official Claude channels here, then run ccs normally on a + supported native Claude session.

-

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

+ CCS stores only channel selection in config.yaml. Claude keeps the + machine-level channel state under ~/.claude/channels/.

-
-
- Bun - - {status?.bunInstalled ? 'Installed' : 'Missing'} - -
-
- Supported profiles - {status?.supportedProfiles.join(', ')} -
-
-
-
-
- -
- -

- Opt-in only. CCS adds the bypass flag once when at least one selected channel - is being auto-enabled and you did not already pass a permission flag yourself. -

+ {status && ( +
+
+
+
+ + {status.summary.title} + + {selectedChannelLabel} +
+

{status.summary.message}

+

{status.summary.nextStep}

+
+
+

Machine checks

+
+
+ Bun + {status.bunInstalled ? 'Installed' : 'Missing'} +
+
+ Claude Code + + {status.claudeVersion.current + ? `v${status.claudeVersion.current}` + : 'Unknown'} + +
+
+ Claude auth + {status.auth.authMethod ?? 'Unknown'} +
+
- - void updateConfig( - { unattended: checked }, - checked ? 'Unattended mode enabled' : 'Unattended mode disabled' - ) - } - /> + {status.summary.blockers.length > 0 && ( +
+ {status.summary.blockers.map((blocker) => ( +

{blocker}

+ ))} +
+ )}
-
+ )} + + {status && ( +
+

Fastest path

+
+

1. Turn on the channels you want below.

+

2. Save Telegram or Discord bot tokens here if that channel needs one.

+

+ 3. Run ccs or a native Claude account profile. CCS adds{' '} + --channels for you on supported runs. +

+

{status.supportMessage}

+
+
+ + Advanced notes and scope + +
+

{status.accountStatusCaveat}

+

{status.stateScopeMessage}

+
+
+
+ )} + + {status && ( +
+
+
+

+ If you run ccs now +

+

{status.launchPreview.detail}

+
+ + {status.launchPreview.title} + +
+
+
+ You type:{' '} + {status.launchPreview.command} +
+
+ CCS adds:{' '} + {status.launchPreview.appendedArgs.length > 0 + ? status.launchPreview.appendedArgs.join(' ') + : '(nothing yet)'} +
+
+ {status.launchPreview.skippedMessages.length > 0 && ( +
+ {status.launchPreview.skippedMessages.map((message) => ( +

{message}

+ ))} +
+ )} +
+ )} + + {status?.claudeVersion.message && status.claudeVersion.state !== 'supported' && ( + + + {status.claudeVersion.message} + + )} + + {status?.auth.message && status.auth.state !== 'eligible' && ( + + + {status.auth.message} + + )} + + {status?.auth.orgRequirementMessage && ( + + + {status.auth.orgRequirementMessage} + + )}
{status?.channels.map((channel) => { @@ -190,23 +333,40 @@ export default function ChannelsSection() {

{channel.pluginSpec}

- {channel.unavailableReason && ( -

{channel.unavailableReason}

- )}
- void toggleChannel(channel.id, checked)} - /> +
+ + {channel.setup.label} + + void toggleChannel(channel.id, checked)} + /> +
+
+ +
+

{channel.setup.detail}

+

{channel.setup.nextStep}

{channel.requiresToken && (

- Save {channel.envKey} in Claude's official channel env - file. The dashboard never reads the token value back after save. + {!channel.tokenConfigured && channel.tokenSource === 'process_env' + ? `The current CCS process already has ${channel.envKey}. Save it here only if you want persistent Claude channel state.` + : channel.tokenConfigured && channel.processEnvAvailable + ? `${channel.envKey} is saved in Claude channel state, and the current CCS process env also provides it.` + : `Save ${channel.envKey} in Claude's official channel env file. The dashboard never reads the token value back after save.`}

+ {channel.tokenConfigured && ( +

+ Saving here writes the same .env file as{' '} + /{channel.id}:configure, so you do not need to run the + configure command again after a successful save. +

+ )} -
- {channel.tokenPath} -
+ {channel.tokenPath && channel.tokenSource !== 'process_env' && ( +
+ {channel.tokenPath} +
+ )}
)} -
-

Claude-side setup

- {(channel.manualSetupCommands ?? []).map((command) => ( -
- {command} -
- ))} -
+
+ + Claude-side setup commands + +
+ {(channel.manualSetupCommands ?? []).map((command) => ( +
+ {command} +
+ ))} +
+
); })} @@ -259,12 +427,42 @@ export default function ChannelsSection() { - CCS does not persist a global Claude setting for channels. It only prepares channel - env files and injects runtime flags when the selected channels are compatible and - ready. + CCS injects --channels only for the current Claude session. Telegram, + Discord, and iMessage stop receiving messages when that Claude session exits. +
+
+
+ +
+ +

+ Optional advanced behavior. CCS adds --dangerously-skip-permissions{' '} + only when at least one selected channel is being auto-enabled and you did not + already pass a permission flag yourself. +

+
+
+ + void (async () => { + const updated = await updateConfig( + { unattended: checked }, + checked ? 'Unattended mode enabled' : 'Unattended mode disabled' + ); + if (updated) { + await fetchRawConfig(); + } + })() + } + /> +
+
+