From a97fc42b10d3ce783b2bf84bce499b7bb9dca141 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Mar 2026 10:02:11 -0400 Subject: [PATCH] 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(); + } + })() + } + /> +
+
+