feat(channels): auto-enable official Claude channels

This commit is contained in:
Tam Nhu Tran
2026-03-25 16:31:55 -04:00
parent 0e2f47802b
commit a97fc42b10
18 changed files with 2653 additions and 337 deletions
+15 -36
View File
@@ -36,17 +36,11 @@ import { fail, info, warn } from './utils/ui';
import { isCopilotSubcommandToken } from './copilot/constants'; import { isCopilotSubcommandToken } from './copilot/constants';
import { import {
buildOfficialChannelsArgs, buildOfficialChannelsArgs,
getOfficialChannelDisplayName, getOfficialChannelsEnvironmentStatus,
getOfficialChannelTokenIds,
isBunAvailable,
officialChannelRequiresMacOS, officialChannelRequiresMacOS,
resolveOfficialChannelsLaunchPlan, resolveOfficialChannelsLaunchPlan,
resolveOfficialChannelsSyncConfigDir,
} from './channels/official-channels-runtime'; } from './channels/official-channels-runtime';
import { import { getOfficialChannelReadiness } from './channels/official-channels-store';
getOfficialChannelReadiness,
syncOfficialChannelEnvToConfigDir,
} from './channels/official-channels-store';
// Import centralized error handling // Import centralized error handling
import { handleError, runCleanup } from './errors'; import { handleError, runCleanup } from './errors';
@@ -149,6 +143,9 @@ function resolveNativeClaudeLaunchArgs(
targetConfigDir?: string targetConfigDir?: string
): string[] { ): string[] {
const config = getOfficialChannelsConfig(); const config = getOfficialChannelsConfig();
const environment = getOfficialChannelsEnvironmentStatus(
targetConfigDir ? { CLAUDE_CONFIG_DIR: targetConfigDir } : undefined
);
const channelReadiness = { const channelReadiness = {
telegram: getOfficialChannelReadiness('telegram'), telegram: getOfficialChannelReadiness('telegram'),
discord: getOfficialChannelReadiness('discord'), discord: getOfficialChannelReadiness('discord'),
@@ -159,7 +156,7 @@ function resolveNativeClaudeLaunchArgs(
config, config,
target: 'claude', target: 'claude',
profileType, profileType,
bunAvailable: isBunAvailable(), environment,
channelReadiness, channelReadiness,
}); });
@@ -167,37 +164,19 @@ function resolveNativeClaudeLaunchArgs(
console.error(warn(message)); 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) { if (!plan.applied) {
return args; return args;
} }
const activeConfigDir = resolveOfficialChannelsSyncConfigDir(targetConfigDir); return buildOfficialChannelsArgs(args, plan.appliedChannels, plan.wantsPermissionBypass);
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);
} }
async function main(): Promise<void> { async function main(): Promise<void> {
+468 -17
View File
@@ -2,6 +2,13 @@ import { spawnSync } from 'child_process';
import type { TargetType } from '../targets/target-adapter'; import type { TargetType } from '../targets/target-adapter';
import type { ProfileType } from '../types/profile'; import type { ProfileType } from '../types/profile';
import type { OfficialChannelId, OfficialChannelsConfig } from '../config/unified-config-types'; 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 { export interface OfficialChannelDefinition {
id: OfficialChannelId; id: OfficialChannelId;
@@ -9,6 +16,7 @@ export interface OfficialChannelDefinition {
pluginSpec: string; pluginSpec: string;
envKey?: string; envKey?: string;
envDir: string; envDir: string;
stateDirEnvKey: string;
requiresMacOS?: boolean; requiresMacOS?: boolean;
manualSetupCommands: string[]; manualSetupCommands: string[];
} }
@@ -20,6 +28,7 @@ export const OFFICIAL_CHANNELS: Record<OfficialChannelId, OfficialChannelDefinit
pluginSpec: 'plugin:telegram@claude-plugins-official', pluginSpec: 'plugin:telegram@claude-plugins-official',
envKey: 'TELEGRAM_BOT_TOKEN', envKey: 'TELEGRAM_BOT_TOKEN',
envDir: 'telegram', envDir: 'telegram',
stateDirEnvKey: 'TELEGRAM_STATE_DIR',
manualSetupCommands: [ manualSetupCommands: [
'/plugin install telegram@claude-plugins-official', '/plugin install telegram@claude-plugins-official',
'/telegram:configure <token>', '/telegram:configure <token>',
@@ -33,6 +42,7 @@ export const OFFICIAL_CHANNELS: Record<OfficialChannelId, OfficialChannelDefinit
pluginSpec: 'plugin:discord@claude-plugins-official', pluginSpec: 'plugin:discord@claude-plugins-official',
envKey: 'DISCORD_BOT_TOKEN', envKey: 'DISCORD_BOT_TOKEN',
envDir: 'discord', envDir: 'discord',
stateDirEnvKey: 'DISCORD_STATE_DIR',
manualSetupCommands: [ manualSetupCommands: [
'/plugin install discord@claude-plugins-official', '/plugin install discord@claude-plugins-official',
'/discord:configure <token>', '/discord:configure <token>',
@@ -45,6 +55,7 @@ export const OFFICIAL_CHANNELS: Record<OfficialChannelId, OfficialChannelDefinit
displayName: 'iMessage', displayName: 'iMessage',
pluginSpec: 'plugin:imessage@claude-plugins-official', pluginSpec: 'plugin:imessage@claude-plugins-official',
envDir: 'imessage', envDir: 'imessage',
stateDirEnvKey: 'IMESSAGE_STATE_DIR',
requiresMacOS: true, requiresMacOS: true,
manualSetupCommands: [ manualSetupCommands: [
'/plugin install imessage@claude-plugins-official', '/plugin install imessage@claude-plugins-official',
@@ -54,6 +65,71 @@ export const OFFICIAL_CHANNELS: Record<OfficialChannelId, OfficialChannelDefinit
}; };
export const OFFICIAL_CHANNEL_IDS = Object.keys(OFFICIAL_CHANNELS) as OfficialChannelId[]; export const OFFICIAL_CHANNEL_IDS = Object.keys(OFFICIAL_CHANNELS) as OfficialChannelId[];
export const MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION = '2.1.80';
export interface OfficialChannelsVersionSummary {
current: string | null;
minimum: string;
state: 'supported' | 'unsupported' | 'unknown';
message: string;
}
export interface OfficialChannelsAuthSummary {
checked: boolean;
loggedIn: boolean;
authMethod: string | null;
subscriptionType: string | null;
state: 'eligible' | 'ineligible' | 'unknown';
eligible: boolean;
message: string;
orgRequirementMessage?: string;
}
export interface OfficialChannelsEnvironmentStatus {
bunInstalled: boolean;
supportedProfiles: string[];
stateScopeMessage: string;
claudeVersion: OfficialChannelsVersionSummary;
auth: OfficialChannelsAuthSummary;
}
export interface OfficialChannelSetupSummary {
state: 'not_selected' | 'ready' | 'needs_token' | 'needs_claude_setup' | 'unavailable';
label: string;
detail: string;
nextStep: string;
}
export interface OfficialChannelsReadinessSummary {
state: 'ready' | 'needs_setup' | 'limited';
title: string;
message: string;
nextStep: string;
blockers: string[];
}
export interface OfficialChannelsLaunchPreview {
state: 'disabled' | 'blocked' | 'partial' | 'ready';
title: string;
detail: string;
command: string;
appendedArgs: string[];
appliedChannels: OfficialChannelId[];
permissionBypassIncluded: boolean;
skippedMessages: string[];
}
export interface OfficialChannelsStatusChannelInput {
id: OfficialChannelId;
displayName: string;
selected: boolean;
requiresToken: boolean;
tokenAvailable: boolean;
tokenSource?: OfficialChannelTokenSource;
savedInClaudeState?: boolean;
processEnvAvailable?: boolean;
unavailableReason?: string;
}
export interface DiscordChannelsLaunchPlan { export interface DiscordChannelsLaunchPlan {
applied: boolean; applied: boolean;
@@ -67,7 +143,7 @@ interface DiscordChannelsLaunchInput {
config: OfficialChannelsConfig; config: OfficialChannelsConfig;
target: TargetType; target: TargetType;
profileType: ProfileType; profileType: ProfileType;
bunAvailable: boolean; environment: OfficialChannelsEnvironmentStatus;
channelReadiness: Record<OfficialChannelId, boolean>; channelReadiness: Record<OfficialChannelId, boolean>;
} }
@@ -114,14 +190,125 @@ export function hasExplicitChannelsFlag(args: string[]): boolean {
export function hasExplicitPermissionOverride(args: string[]): boolean { export function hasExplicitPermissionOverride(args: string[]): boolean {
return args.some( return args.some(
(arg) => (arg) =>
arg === '--allow-dangerously-skip-permissions' ||
arg === '--dangerously-skip-permissions' || arg === '--dangerously-skip-permissions' ||
arg === '--permission-mode' || arg === '--permission-mode' ||
arg.startsWith('--permission-mode=') arg.startsWith('--permission-mode=')
); );
} }
export function resolveOfficialChannelsSyncConfigDir(targetConfigDir?: string): string | undefined { function isTeamOrEnterpriseSubscription(subscriptionType: string | null): boolean {
return targetConfigDir ?? process.env.CLAUDE_CONFIG_DIR; 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( export function buildOfficialChannelsArgs(
@@ -145,7 +332,7 @@ export function buildOfficialChannelsArgs(
export function resolveOfficialChannelsLaunchPlan( export function resolveOfficialChannelsLaunchPlan(
input: DiscordChannelsLaunchInput input: DiscordChannelsLaunchInput
): DiscordChannelsLaunchPlan { ): DiscordChannelsLaunchPlan {
const { args, config, target, profileType, bunAvailable, channelReadiness } = input; const { args, config, target, profileType, environment, channelReadiness } = input;
const skippedMessages: string[] = []; const skippedMessages: string[] = [];
if (config.selected.length === 0) { if (config.selected.length === 0) {
@@ -162,9 +349,7 @@ export function resolveOfficialChannelsLaunchPlan(
applied: false, applied: false,
wantsPermissionBypass: false, wantsPermissionBypass: false,
appliedChannels: [], appliedChannels: [],
skippedMessages: [ skippedMessages: [getOfficialChannelsCompatibilityMessage()],
'Official Channels auto-enable only applies to native Claude default/account sessions.',
],
}; };
} }
@@ -177,7 +362,7 @@ export function resolveOfficialChannelsLaunchPlan(
}; };
} }
if (!bunAvailable) { if (!environment.bunInstalled) {
return { return {
applied: false, applied: false,
wantsPermissionBypass: 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[] = []; const appliedChannels: OfficialChannelId[] = [];
for (const channelId of normalizeOfficialChannelIds(config.selected)) { for (const channelId of normalizeOfficialChannelIds(config.selected)) {
@@ -244,15 +447,19 @@ export function getOfficialChannelEnvDir(channelId: OfficialChannelId): string {
return OFFICIAL_CHANNELS[channelId].envDir; return OFFICIAL_CHANNELS[channelId].envDir;
} }
export function getOfficialChannelStateDirEnvKey(channelId: OfficialChannelId): string {
return OFFICIAL_CHANNELS[channelId].stateDirEnvKey;
}
export function getOfficialChannelSummary(channelId: OfficialChannelId): string { export function getOfficialChannelSummary(channelId: OfficialChannelId): string {
if (channelId === 'telegram') { 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') { 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( export function getOfficialChannelUnavailableReason(
@@ -268,14 +475,258 @@ export function getOfficialChannelUnavailableReason(
export function getOfficialChannelReadyMessage(channelId: OfficialChannelId): string { export function getOfficialChannelReadyMessage(channelId: OfficialChannelId): string {
if (channelId === 'imessage') { if (channelId === 'imessage') {
return isMacOS() 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.'; : 'Unavailable on this platform.';
} }
const envKey = getOfficialChannelEnvKey(channelId); const envKey = getOfficialChannelEnvKey(channelId);
return envKey return envKey
? `${envKey} must be configured before CCS can auto-enable this channel.` ? `${envKey} must be configured before CCS can auto-enable this channel. Claude-side pairing and access policy are still required.`
: 'Ready.'; : '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<OfficialChannelId, boolean>;
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[] { export function expandOfficialChannelSelection(selection: string): OfficialChannelId[] {
@@ -341,11 +792,11 @@ export function getOfficialChannelConfiguredPlaceholder(channelId: OfficialChann
} }
export function getOfficialChannelsSectionDescription(): string { 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 { 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 { export function getOfficialChannelsSetHelp(): string {
@@ -399,7 +850,7 @@ export function getOfficialChannelsBunMissingMessage(): string {
} }
export function getOfficialChannelsCompatibilityMessage(): 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 { export function getOfficialChannelsNoSelectionMessage(): string {
+114 -59
View File
@@ -6,22 +6,42 @@ import type { OfficialChannelId } from '../config/unified-config-types';
import { import {
getOfficialChannelEnvDir, getOfficialChannelEnvDir,
getOfficialChannelEnvKey, getOfficialChannelEnvKey,
getOfficialChannelStateDirEnvKey,
getOfficialChannelTokenIds, getOfficialChannelTokenIds,
isOfficialChannelTokenRequired, isOfficialChannelTokenRequired,
} from './official-channels-runtime'; } from './official-channels-runtime';
export interface DiscordChannelsSyncResult { export type OfficialChannelTokenSource = 'saved_env' | 'process_env' | 'missing';
synced: boolean;
targetPath: string; export interface OfficialChannelTokenStatus {
reason?: 'missing_env' | 'missing_token' | 'already_current' | 'write_failed'; available: boolean;
error?: string; 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( export function getOfficialChannelEnvPath(
channelId: OfficialChannelId, channelId: OfficialChannelId,
configDir = getDefaultClaudeConfigDir() configDir = getDefaultClaudeConfigDir(),
envOverrides?: NodeJS.ProcessEnv | null
): string { ): 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 { function readFileIfExists(filePath: string): string | null {
@@ -137,6 +157,19 @@ function listManagedClaudeConfigDirs(): string[] {
return [...dirs]; return [...dirs];
} }
function listManagedOfficialChannelEnvPaths(channelId: OfficialChannelId): string[] {
const envPaths = new Set<string>([
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 { export function normalizeDiscordBotToken(value: string): string | null {
const normalized = value.trim(); const normalized = value.trim();
if (!normalized || /[\r\n]/.test(normalized)) { if (!normalized || /[\r\n]/.test(normalized)) {
@@ -171,10 +204,82 @@ export function readConfiguredOfficialChannelToken(channelId: OfficialChannelId)
return content ? readOfficialChannelTokenFromEnvContent(channelId, content) : null; 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 { export function hasConfiguredOfficialChannelToken(channelId: OfficialChannelId): boolean {
return readConfiguredOfficialChannelToken(channelId) !== null; 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( export function setConfiguredOfficialChannelToken(
channelId: OfficialChannelId, channelId: OfficialChannelId,
token: string token: string
@@ -207,9 +312,8 @@ export function clearConfiguredOfficialChannelTokensEverywhere(
const clearedPaths: string[] = []; const clearedPaths: string[] = [];
const channels = channelId ? [channelId] : getOfficialChannelTokenIds(); const channels = channelId ? [channelId] : getOfficialChannelTokenIds();
for (const configDir of listManagedClaudeConfigDirs()) { for (const tokenChannelId of channels) {
for (const tokenChannelId of channels) { for (const envPath of listManagedOfficialChannelEnvPaths(tokenChannelId)) {
const envPath = getOfficialChannelEnvPath(tokenChannelId, configDir);
if (clearOfficialChannelTokenAtPath(tokenChannelId, envPath)) { if (clearOfficialChannelTokenAtPath(tokenChannelId, envPath)) {
clearedPaths.push(envPath); clearedPaths.push(envPath);
} }
@@ -218,52 +322,3 @@ export function clearConfiguredOfficialChannelTokensEverywhere(
return clearedPaths; 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;
}
+157 -30
View File
@@ -8,18 +8,22 @@ import type { OfficialChannelId } from '../config/unified-config-types';
import { DEFAULT_OFFICIAL_CHANNELS_CONFIG } from '../config/unified-config-types'; import { DEFAULT_OFFICIAL_CHANNELS_CONFIG } from '../config/unified-config-types';
import { import {
clearConfiguredOfficialChannelTokensEverywhere, clearConfiguredOfficialChannelTokensEverywhere,
getOfficialChannelEnvPath, getOfficialChannelTokenStatus,
hasConfiguredOfficialChannelToken, hasConfiguredOfficialChannelToken,
setConfiguredOfficialChannelToken, setConfiguredOfficialChannelToken,
} from '../channels/official-channels-store'; } from '../channels/official-channels-store';
import { import {
buildOfficialChannelsLaunchPreview,
buildOfficialChannelsReadinessSummary,
buildOfficialChannelSetupSummary,
expandOfficialChannelSelection, expandOfficialChannelSelection,
getChannelConfigSelectionLabel, getChannelConfigSelectionLabel,
getOfficialChannelChoices, getOfficialChannelChoices,
getOfficialChannelsAccountStatusCaveat,
getOfficialChannelsSupportMessage,
getOfficialChannelDisplayName, getOfficialChannelDisplayName,
getOfficialChannelEnvKey, getOfficialChannelEnvKey,
getOfficialChannelManualSetupCommands, getOfficialChannelManualSetupCommands,
getOfficialChannelReadyMessage,
getOfficialChannelsCompatibilityMessage, getOfficialChannelsCompatibilityMessage,
getOfficialChannelsDocsSummary, getOfficialChannelsDocsSummary,
getOfficialChannelsLegacyEnableHelp, getOfficialChannelsLegacyEnableHelp,
@@ -28,12 +32,12 @@ import {
getOfficialChannelClearTokenHelp, getOfficialChannelClearTokenHelp,
getOfficialChannelMacOSHelp, getOfficialChannelMacOSHelp,
getOfficialChannelSummary, getOfficialChannelSummary,
getOfficialChannelsEnvironmentStatus,
getOfficialChannelsRuntimeNote, getOfficialChannelsRuntimeNote,
getOfficialChannelsSectionDescription, getOfficialChannelsSectionDescription,
getOfficialChannelsSupportedProfiles, getOfficialChannelsSupportedProfiles,
getOfficialChannelUnavailableReason, getOfficialChannelUnavailableReason,
getOfficialChannelTokenIds, getOfficialChannelTokenIds,
isBunAvailable,
isOfficialChannelId, isOfficialChannelId,
isOfficialChannelSelectionValid, isOfficialChannelSelectionValid,
} from '../channels/official-channels-runtime'; } from '../channels/official-channels-runtime';
@@ -128,6 +132,9 @@ function showHelp(): void {
console.log(''); console.log('');
console.log(` ${getOfficialChannelsSectionDescription()}`); console.log(` ${getOfficialChannelsSectionDescription()}`);
console.log(` ${dim(getOfficialChannelsDocsSummary())}`); 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('');
console.log(subheader('Usage:')); console.log(subheader('Usage:'));
console.log(` ${color('ccs config channels', 'command')} [options]`); console.log(` ${color('ccs config channels', 'command')} [options]`);
@@ -135,8 +142,12 @@ function showHelp(): void {
console.log(subheader('Options:')); console.log(subheader('Options:'));
console.log(` ${color('--set <csv|all>', 'command')} ${getOfficialChannelsSetHelp()}`); console.log(` ${color('--set <csv|all>', 'command')} ${getOfficialChannelsSetHelp()}`);
console.log(` ${color('--clear', 'command')} Clear all selected channels`); console.log(` ${color('--clear', 'command')} Clear all selected channels`);
console.log(` ${color('--enable', 'command')} Legacy alias: add Discord`); console.log(
console.log(` ${color('--disable', 'command')} Legacy alias: remove Discord`); ` ${color('--enable', 'command')} Legacy compatibility alias: add Discord`
);
console.log(
` ${color('--disable', 'command')} Legacy compatibility alias: remove Discord`
);
console.log( console.log(
` ${color('--unattended', 'command')} Also add --dangerously-skip-permissions` ` ${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(` ${color('--help, -h', 'command')} Show this help`);
console.log(''); console.log('');
console.log(subheader('Examples:')); console.log(subheader('Examples:'));
console.log(
` $ ${color('ccs config', 'command')} ${dim('# Dashboard -> Settings -> Channels (fastest path)')}`
);
console.log( console.log(
` $ ${color('ccs config channels', 'command')} ${dim('# Show status')}` ` $ ${color('ccs config channels', 'command')} ${dim('# Show status')}`
); );
@@ -163,60 +177,173 @@ function showHelp(): void {
console.log( console.log(
` $ ${color('ccs config channels --clear-token discord', 'command')} ${dim('# Clear one token')}` ` $ ${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(''); console.log('');
} }
function showStatus(): void { function showStatus(): void {
const config = getOfficialChannelsConfig(); const config = getOfficialChannelsConfig();
const selected = config.selected; 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('');
console.log(header('Official Channels Configuration')); console.log(header('Official Channels Configuration'));
console.log(''); 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( console.log(
` Channels: ${selected.length > 0 ? ok(getChannelConfigSelectionLabel(selected)) : warn('Disabled')}` ` Channels: ${selected.length > 0 ? ok(getChannelConfigSelectionLabel(selected)) : warn('Disabled')}`
); );
console.log(` Unattended: ${config.unattended ? warn('Enabled') : info('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('');
console.log(subheader('Applies To:')); console.log(subheader('Applies To:'));
console.log(` ${dim(getOfficialChannelsCompatibilityMessage())}`); console.log(` ${dim(getOfficialChannelsSupportMessage())}`);
console.log( console.log(
` ${dim(`Supported profiles: ${getOfficialChannelsSupportedProfiles().join(', ')}`)}` ` ${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('');
console.log(subheader('Channels:')); console.log(subheader('Channels:'));
for (const channelId of expandOfficialChannelSelection('all')) { for (const channel of channelRows) {
const displayName = getOfficialChannelDisplayName(channelId); const status =
const enabled = selected.includes(channelId); channel.setup.state === 'ready'
const envKey = getOfficialChannelEnvKey(channelId); ? ok(channel.setup.label)
const tokenConfigured = envKey ? hasConfiguredOfficialChannelToken(channelId) : true; : channel.setup.state === 'not_selected'
const unavailableReason = getOfficialChannelUnavailableReason(channelId); ? info(channel.setup.label)
const status = unavailableReason : warn(channel.setup.label);
? warn(unavailableReason) console.log(` ${channel.selected ? '[x]' : '[ ]'} ${channel.displayName}: ${status}`);
: envKey console.log(` ${dim(getOfficialChannelSummary(channel.id))}`);
? tokenConfigured console.log(` ${dim(channel.setup.detail)}`);
? ok('Ready') console.log(` ${dim(channel.setup.nextStep)}`);
: warn(`${envKey} missing`) if (channel.requiresToken) {
: ok('Ready'); const envKey = getOfficialChannelEnvKey(channel.id) ?? '';
console.log(` ${enabled ? '[x]' : '[ ]'} ${displayName}: ${status}`); if (channel.tokenStatus.source === 'saved_env') {
console.log(` ${dim(getOfficialChannelSummary(channelId))}`); console.log(` ${dim(`${envKey}: saved in Claude channel state`)}`);
if (envKey) { if (channel.tokenStatus.processEnvAvailable) {
console.log( console.log(` ${dim(`${envKey}: also available from current CCS process env`)}`);
` ${dim(`${envKey}: ${tokenConfigured ? 'configured' : 'not configured'}`)}` }
); if (channel.tokenStatus.tokenPath) {
console.log(` ${dim(getOfficialChannelEnvPath(channelId))}`); 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('');
console.log(subheader('Notes:')); console.log(subheader('Notes:'));
console.log(` ${dim(getOfficialChannelsLegacyEnableHelp())}`); console.log(` ${dim(getOfficialChannelsLegacyEnableHelp())}`);
console.log(` ${dim(environment.stateScopeMessage)}`);
console.log(` ${dim(getOfficialChannelMacOSHelp())}`); console.log(` ${dim(getOfficialChannelMacOSHelp())}`);
console.log(` ${dim(getOfficialChannelsRuntimeNote())}`); console.log(` ${dim(getOfficialChannelsRuntimeNote())}`);
console.log(` ${dim(getOfficialChannelsCompatibilityMessage())}`);
console.log(` ${dim(getOfficialChannelsAccountStatusCaveat())}`);
console.log(''); console.log('');
console.log(subheader('Manual Claude Setup:')); console.log(subheader('Claude-side Setup:'));
for (const channelId of expandOfficialChannelSelection('all')) { for (const channelId of expandOfficialChannelSelection('all')) {
console.log(` ${dim(`${getOfficialChannelDisplayName(channelId)}:`)}`); console.log(` ${dim(`${getOfficialChannelDisplayName(channelId)}:`)}`);
for (const command of getOfficialChannelManualSetupCommands(channelId)) { for (const command of getOfficialChannelManualSetupCommands(channelId)) {
+2
View File
@@ -92,6 +92,8 @@ export function showConfigCommandHelp(): void {
console.log(' --set-token <s> Save channel token (telegram=<t> or discord=<t>)'); console.log(' --set-token <s> Save channel token (telegram=<t> or discord=<t>)');
console.log(' --clear-token Remove all saved channel tokens'); console.log(' --clear-token Remove all saved channel tokens');
console.log(' --clear-token <c> Remove one saved channel token'); console.log(' --clear-token <c> 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('');
console.log(' auth Manage dashboard authentication'); console.log(' auth Manage dashboard authentication');
console.log(' auth setup Configure username and password'); console.log(' auth setup Configure username and password');
+10 -5
View File
@@ -312,7 +312,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs config auth setup', 'Configure dashboard login'], ['ccs config auth setup', 'Configure dashboard login'],
['ccs config auth show', 'Show dashboard auth status'], ['ccs config auth show', 'Show dashboard auth status'],
['ccs config channels', 'Show Official Channels 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=<token>', 'Save TELEGRAM_BOT_TOKEN'], ['ccs config channels --set-token telegram=<token>', 'Save TELEGRAM_BOT_TOKEN'],
['ccs config image-analysis', 'Show image analysis settings'], ['ccs config image-analysis', 'Show image analysis settings'],
['ccs config image-analysis --enable', 'Enable image analysis'], ['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)', [ printSubSection('Official Channels (official Claude plugins)', [
['ccs config', 'Dashboard -> Settings -> Channels (fastest path)'],
['ccs config channels', 'Show current status'], ['ccs config channels', 'Show current status'],
[ [
'ccs config channels --set telegram,discord', '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 --set all', 'Enable Telegram, Discord, and iMessage'],
['ccs config channels --unattended', 'Also add --dangerously-skip-permissions'], ['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=<token>', 'Save DISCORD_BOT_TOKEN'], ['ccs config channels --set-token discord=<token>', 'Save DISCORD_BOT_TOKEN'],
['ccs config channels --clear-token [channel]', 'Remove one or all saved channel tokens'], ['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/<channel>/.env.'], ['', 'Telegram/Discord tokens live in ~/.claude/channels/<channel>/.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.'], ['', '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 // License
console.log(dim('License: MIT')); console.log(dim('License: MIT'));
console.log(''); console.log('');
process.exit(0);
} }
+18 -9
View File
@@ -79,10 +79,15 @@ function getLockFilePath(): string {
function acquireLock(): string | null { function acquireLock(): string | null {
const lockPath = getLockFilePath(); const lockPath = getLockFilePath();
const lockDir = path.dirname(lockPath);
const lockToken = crypto.randomUUID(); const lockToken = crypto.randomUUID();
const lockData = `${process.pid}\n${Date.now()}\n${lockToken}`; const lockData = `${process.pid}\n${Date.now()}\n${lockToken}`;
try { try {
if (!fs.existsSync(lockDir)) {
fs.mkdirSync(lockDir, { recursive: true, mode: 0o700 });
}
// Check if lock exists // Check if lock exists
if (fs.existsSync(lockPath)) { if (fs.existsSync(lockPath)) {
const content = fs.readFileSync(lockPath, 'utf8'); const content = fs.readFileSync(lockPath, 'utf8');
@@ -304,17 +309,21 @@ interface LegacyDiscordChannelsConfig {
function normalizeOfficialChannelsConfig( function normalizeOfficialChannelsConfig(
partial: Partial<UnifiedConfig> & { discord_channels?: LegacyDiscordChannelsConfig } partial: Partial<UnifiedConfig> & { discord_channels?: LegacyDiscordChannelsConfig }
): OfficialChannelsConfig { ): OfficialChannelsConfig {
const rawSelected = Array.isArray(partial.channels?.selected) const hasCanonicalChannelsSection = partial.channels !== undefined;
? partial.channels.selected.filter((value): value is OfficialChannelId => const hasExplicitSelectedField =
isOfficialChannelId(value) 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 { return {
selected: selected: hasCanonicalChannelsSection
rawSelected.length > 0 ? normalizeOfficialChannelIds(rawSelected)
? normalizeOfficialChannelIds(rawSelected) : resolveLegacyDiscordSelection(partial.discord_channels?.enabled),
: resolveLegacyDiscordSelection(partial.discord_channels?.enabled),
unattended: unattended:
partial.channels?.unattended ?? partial.channels?.unattended ??
partial.discord_channels?.unattended ?? partial.discord_channels?.unattended ??
+127 -2
View File
@@ -1,7 +1,18 @@
import * as fs from 'fs'; import * as fs from 'fs';
import { execSync } from 'child_process'; import { execFileSync, execSync } from 'child_process';
import { expandPath } from './helpers'; 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 * 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<ClaudeAuthStatus>;
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 * Show Claude not found error
*/ */
+83 -20
View File
@@ -2,48 +2,110 @@ import { Router, type Request, type Response } from 'express';
import { getOfficialChannelsConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; import { getOfficialChannelsConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
import { import {
clearConfiguredOfficialChannelTokensEverywhere, clearConfiguredOfficialChannelTokensEverywhere,
getOfficialChannelEnvPath, getOfficialChannelTokenStatus,
getOfficialChannelReadiness,
hasConfiguredOfficialChannelToken, hasConfiguredOfficialChannelToken,
setConfiguredOfficialChannelToken, setConfiguredOfficialChannelToken,
} from '../../channels/official-channels-store'; } from '../../channels/official-channels-store';
import { import {
buildOfficialChannelsLaunchPreview,
buildOfficialChannelsReadinessSummary,
buildOfficialChannelSetupSummary,
expandOfficialChannelSelection, expandOfficialChannelSelection,
getOfficialChannelsAccountStatusCaveat,
getOfficialChannelDisplayName, getOfficialChannelDisplayName,
getOfficialChannelEnvKey, getOfficialChannelEnvKey,
getOfficialChannelPluginSpec, getOfficialChannelPluginSpec,
getOfficialChannelSummary, getOfficialChannelSummary,
getOfficialChannelsSupportMessage,
getOfficialChannelUnavailableReason, getOfficialChannelUnavailableReason,
getOfficialChannelsSupportedProfiles, getOfficialChannelsEnvironmentStatus,
getOfficialChannelManualSetupCommands, getOfficialChannelManualSetupCommands,
getOfficialChannelTokenIds, getOfficialChannelTokenIds,
isBunAvailable,
isOfficialChannelId, isOfficialChannelId,
} from '../../channels/official-channels-runtime'; } from '../../channels/official-channels-runtime';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
const router = Router(); const router = Router();
function buildChannelsStatus() { function buildChannelsStatus(config = getOfficialChannelsConfig()) {
return { const environment = getOfficialChannelsEnvironmentStatus();
bunInstalled: isBunAvailable(), const channels = expandOfficialChannelSelection('all').map((channelId) => {
supportedProfiles: getOfficialChannelsSupportedProfiles(), const tokenStatus = getOfficialChannelTokenIds().includes(channelId)
channels: expandOfficialChannelSelection('all').map((channelId) => ({ ? getOfficialChannelTokenStatus(channelId)
: undefined;
const selected = config.selected.includes(channelId);
return {
id: channelId, id: channelId,
selected,
displayName: getOfficialChannelDisplayName(channelId), displayName: getOfficialChannelDisplayName(channelId),
pluginSpec: getOfficialChannelPluginSpec(channelId), pluginSpec: getOfficialChannelPluginSpec(channelId),
summary: getOfficialChannelSummary(channelId), summary: getOfficialChannelSummary(channelId),
requiresToken: getOfficialChannelTokenIds().includes(channelId), requiresToken: getOfficialChannelTokenIds().includes(channelId),
envKey: getOfficialChannelEnvKey(channelId), envKey: getOfficialChannelEnvKey(channelId),
tokenConfigured: getOfficialChannelTokenIds().includes(channelId) tokenConfigured:
? hasConfiguredOfficialChannelToken(channelId) getOfficialChannelTokenIds().includes(channelId) &&
: getOfficialChannelReadiness(channelId), hasConfiguredOfficialChannelToken(channelId),
tokenPath: getOfficialChannelTokenIds().includes(channelId) tokenAvailable: tokenStatus?.available ?? false,
? getOfficialChannelEnvPath(channelId) tokenSource: tokenStatus?.source,
: undefined, tokenPath: tokenStatus?.tokenPath,
savedInClaudeState: tokenStatus?.savedInClaudeState ?? false,
processEnvAvailable: tokenStatus?.processEnvAvailable ?? false,
unavailableReason: getOfficialChannelUnavailableReason(channelId), unavailableReason: getOfficialChannelUnavailableReason(channelId),
manualSetupCommands: getOfficialChannelManualSetupCommands(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 => { router.get('/', (_req: Request, res: Response): void => {
const config = getOfficialChannelsConfig();
res.json({ res.json({
config: getOfficialChannelsConfig(), config,
status: buildChannelsStatus(), status: buildChannelsStatus(config),
}); });
}); });
@@ -85,7 +148,8 @@ router.put('/', (req: Request, res: Response): void => {
try { try {
const updated = mutateUnifiedConfig((config) => { const updated = mutateUnifiedConfig((config) => {
config.channels = { 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, unattended: unattended ?? config.channels?.unattended ?? false,
}; };
}); });
@@ -132,7 +196,6 @@ router.delete('/:channelId/token', (req: Request, res: Response): void => {
res.json({ res.json({
success: true, success: true,
tokenConfigured: false, tokenConfigured: false,
tokenPath: getOfficialChannelEnvPath(channelId),
clearedPaths, clearedPaths,
}); });
} catch (error) { } catch (error) {
@@ -1,15 +1,63 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'bun:test';
import { import {
MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION,
OFFICIAL_CHANNELS, OFFICIAL_CHANNELS,
buildOfficialChannelsLaunchPreview,
buildOfficialChannelsReadinessSummary,
buildOfficialChannelSetupSummary,
buildOfficialChannelsArgs, buildOfficialChannelsArgs,
expandOfficialChannelSelection, expandOfficialChannelSelection,
hasExplicitChannelsFlag, hasExplicitChannelsFlag,
hasExplicitPermissionOverride, hasExplicitPermissionOverride,
isDiscordChannelsSessionSupported, isDiscordChannelsSessionSupported,
resolveOfficialChannelsAuthSummary,
resolveOfficialChannelsLaunchPlan, resolveOfficialChannelsLaunchPlan,
resolveOfficialChannelsSyncConfigDir, resolveOfficialChannelsVersionSummary,
type OfficialChannelsAuthSummary,
type OfficialChannelsEnvironmentStatus,
type OfficialChannelsVersionSummary,
} from '../../../src/channels/official-channels-runtime'; } from '../../../src/channels/official-channels-runtime';
function buildSupportedVersionSummary(
overrides: Partial<OfficialChannelsVersionSummary> = {}
): 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> = {}
): 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> = {}
): OfficialChannelsEnvironmentStatus {
return {
bunInstalled: true,
supportedProfiles: ['default', 'account'],
stateScopeMessage: 'state scope',
claudeVersion: buildSupportedVersionSummary(overrides.claudeVersion),
auth: buildEligibleAuthSummary(overrides.auth),
...overrides,
};
}
describe('official channels runtime planning', () => { describe('official channels runtime planning', () => {
it('supports only native Claude default/account sessions', () => { it('supports only native Claude default/account sessions', () => {
expect(isDiscordChannelsSessionSupported('claude', 'default')).toBe(true); expect(isDiscordChannelsSessionSupported('claude', 'default')).toBe(true);
@@ -26,6 +74,7 @@ describe('official channels runtime planning', () => {
expect(hasExplicitChannelsFlag(['--permission-mode', 'acceptEdits'])).toBe(false); expect(hasExplicitChannelsFlag(['--permission-mode', 'acceptEdits'])).toBe(false);
expect(hasExplicitPermissionOverride(['--dangerously-skip-permissions'])).toBe(true); 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);
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 }, config: { selected: ['telegram', 'discord'], unattended: true },
target: 'claude', target: 'claude',
profileType: 'default', profileType: 'default',
bunAvailable: true, environment: buildEnvironment(),
channelReadiness: { channelReadiness: {
telegram: true, telegram: true,
discord: true, discord: true,
@@ -63,11 +112,11 @@ describe('official channels runtime planning', () => {
it('keeps explicit permission choice and still returns ready channels', () => { it('keeps explicit permission choice and still returns ready channels', () => {
const plan = resolveOfficialChannelsLaunchPlan({ const plan = resolveOfficialChannelsLaunchPlan({
args: ['--permission-mode', 'acceptEdits'], args: ['--allow-dangerously-skip-permissions'],
config: { selected: ['discord'], unattended: true }, config: { selected: ['discord'], unattended: true },
target: 'claude', target: 'claude',
profileType: 'account', profileType: 'account',
bunAvailable: true, environment: buildEnvironment(),
channelReadiness: { channelReadiness: {
telegram: false, telegram: false,
discord: true, discord: true,
@@ -86,7 +135,7 @@ describe('official channels runtime planning', () => {
config: { selected: ['discord'], unattended: false }, config: { selected: ['discord'], unattended: false },
target: 'claude', target: 'claude',
profileType: 'settings', profileType: 'settings',
bunAvailable: true, environment: buildEnvironment(),
channelReadiness: { channelReadiness: {
telegram: true, telegram: true,
discord: true, discord: true,
@@ -98,7 +147,7 @@ describe('official channels runtime planning', () => {
config: { selected: ['discord'], unattended: false }, config: { selected: ['discord'], unattended: false },
target: 'claude', target: 'claude',
profileType: 'default', profileType: 'default',
bunAvailable: false, environment: buildEnvironment({ bunInstalled: false }),
channelReadiness: { channelReadiness: {
telegram: true, telegram: true,
discord: true, discord: true,
@@ -110,7 +159,7 @@ describe('official channels runtime planning', () => {
config: { selected: ['telegram', 'discord'], unattended: false }, config: { selected: ['telegram', 'discord'], unattended: false },
target: 'claude', target: 'claude',
profileType: 'default', profileType: 'default',
bunAvailable: true, environment: buildEnvironment(),
channelReadiness: { channelReadiness: {
telegram: false, telegram: false,
discord: true, discord: true,
@@ -127,13 +176,115 @@ describe('official channels runtime planning', () => {
expect(missingToken.skippedMessages.join(' ')).toContain('TELEGRAM_BOT_TOKEN is not configured'); 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', () => { it('leaves explicit channel arguments untouched', () => {
const plan = resolveOfficialChannelsLaunchPlan({ const plan = resolveOfficialChannelsLaunchPlan({
args: ['--channels', 'plugin:custom'], args: ['--channels', 'plugin:custom'],
config: { selected: ['discord'], unattended: true }, config: { selected: ['discord'], unattended: true },
target: 'claude', target: 'claude',
profileType: 'default', profileType: 'default',
bunAvailable: true, environment: buildEnvironment(),
channelReadiness: { channelReadiness: {
telegram: true, telegram: true,
discord: true, discord: true,
@@ -146,19 +297,252 @@ describe('official channels runtime planning', () => {
expect(plan.skippedMessages).toEqual([]); expect(plan.skippedMessages).toEqual([]);
}); });
it('falls back to process.env.CLAUDE_CONFIG_DIR for sync when no explicit dir is passed', () => { it('summarizes version compatibility states', () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR; const unknown = resolveOfficialChannelsVersionSummary(null);
process.env.CLAUDE_CONFIG_DIR = '/tmp/external-claude-config'; const supported = resolveOfficialChannelsVersionSummary(MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION);
const unsupported = resolveOfficialChannelsVersionSummary('2.1.79');
try { expect(unknown.state).toBe('unknown');
expect(resolveOfficialChannelsSyncConfigDir()).toBe('/tmp/external-claude-config'); expect(unknown.message).toContain(`v${MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION}+`);
expect(resolveOfficialChannelsSyncConfigDir('/tmp/explicit')).toBe('/tmp/explicit');
} finally { expect(supported.state).toBe('supported');
if (originalConfigDir !== undefined) { expect(supported.message).toBe(`Claude Code v${MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION}`);
process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
} else { expect(unsupported.state).toBe('unsupported');
delete process.env.CLAUDE_CONFIG_DIR; 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.'],
});
}); });
}); });
@@ -5,12 +5,13 @@ import * as path from 'path';
import { import {
clearConfiguredOfficialChannelToken, clearConfiguredOfficialChannelToken,
clearConfiguredOfficialChannelTokensEverywhere, clearConfiguredOfficialChannelTokensEverywhere,
getOfficialChannelTokenStatus,
getOfficialChannelEnvPath, getOfficialChannelEnvPath,
hasConfiguredOfficialChannelToken, hasConfiguredOfficialChannelToken,
readConfiguredOfficialChannelToken, readConfiguredOfficialChannelToken,
readOfficialChannelTokenFromProcessEnv,
readOfficialChannelTokenFromEnvContent, readOfficialChannelTokenFromEnvContent,
setConfiguredOfficialChannelToken, setConfiguredOfficialChannelToken,
syncOfficialChannelEnvToConfigDir,
} from '../../../src/channels/official-channels-store'; } from '../../../src/channels/official-channels-store';
describe('official channels token store', () => { describe('official channels token store', () => {
@@ -55,6 +56,71 @@ describe('official channels token store', () => {
expect(readConfiguredOfficialChannelToken('telegram')).toBe('telegram-secret'); 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', () => { it('removes only the channel token entry and deletes the file when nothing remains', () => {
const envPath = getOfficialChannelEnvPath('discord'); const envPath = getOfficialChannelEnvPath('discord');
fs.mkdirSync(path.dirname(envPath), { recursive: true }); fs.mkdirSync(path.dirname(envPath), { recursive: true });
@@ -69,46 +135,51 @@ describe('official channels token store', () => {
expect(fs.existsSync(envPath)).toBe(false); 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', () => { it('clears previously synced copies across managed Claude config dirs', () => {
const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
setConfiguredOfficialChannelToken('discord', 'discord-secret'); setConfiguredOfficialChannelToken('discord', 'discord-secret');
setConfiguredOfficialChannelToken('telegram', 'telegram-secret'); setConfiguredOfficialChannelToken('telegram', 'telegram-secret');
const instanceConfigDir = path.join(tempHome, '.ccs', 'instances', 'work'); const instanceConfigDir = path.join(tempHome, '.ccs', 'instances', 'work');
const instanceEnvPath = path.join(instanceConfigDir, 'channels', 'discord', '.env'); const processConfigDir = path.join(tempHome, '.claude-account-session');
const telegramEnvPath = path.join(instanceConfigDir, 'channels', 'telegram', '.env'); const staleDiscordInstancePath = getOfficialChannelEnvPath('discord', instanceConfigDir);
const staleTelegramInstancePath = getOfficialChannelEnvPath('telegram', instanceConfigDir);
const staleDiscordProcessPath = getOfficialChannelEnvPath('discord', processConfigDir);
syncOfficialChannelEnvToConfigDir('discord', instanceConfigDir); process.env.CLAUDE_CONFIG_DIR = processConfigDir;
syncOfficialChannelEnvToConfigDir('telegram', instanceConfigDir);
expect(fs.existsSync(instanceEnvPath)).toBe(true);
expect(fs.existsSync(telegramEnvPath)).toBe(true);
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')); fs.mkdirSync(path.dirname(staleTelegramInstancePath), { recursive: true });
expect(clearedPaths).toContain(getOfficialChannelEnvPath('telegram')); fs.writeFileSync(staleTelegramInstancePath, 'TELEGRAM_BOT_TOKEN=telegram-secret\n', 'utf8');
expect(clearedPaths).toContain(instanceEnvPath);
expect(clearedPaths).toContain(telegramEnvPath); fs.mkdirSync(path.dirname(staleDiscordProcessPath), { recursive: true });
expect(fs.existsSync(getOfficialChannelEnvPath('discord'))).toBe(false); fs.writeFileSync(staleDiscordProcessPath, 'DISCORD_BOT_TOKEN=discord-secret\n', 'utf8');
expect(fs.existsSync(getOfficialChannelEnvPath('telegram'))).toBe(false);
expect(fs.existsSync(instanceEnvPath)).toBe(false); expect(fs.existsSync(staleDiscordInstancePath)).toBe(true);
expect(fs.existsSync(telegramEnvPath)).toBe(false); 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;
}
}
}); });
}); });
@@ -84,4 +84,23 @@ describe('help command parity', () => {
expect(rendered.includes('ccs config --host 0.0.0.0')).toBe(true); expect(rendered.includes('ccs config --host 0.0.0.0')).toBe(true);
expect(rendered.includes('Force all-interface binding for remote devices')).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
);
});
}); });
+71
View File
@@ -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 });
}
});
});
@@ -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<Response> {
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<void>((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<void>((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;
}
});
});
@@ -6,6 +6,15 @@ const DEFAULT_CONFIG: OfficialChannelsConfig = {
unattended: false, unattended: false,
}; };
async function readErrorMessage(response: Response, fallback: string): Promise<string> {
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() { export function useOfficialChannelsConfig() {
const [config, setConfig] = useState<OfficialChannelsConfig>(DEFAULT_CONFIG); const [config, setConfig] = useState<OfficialChannelsConfig>(DEFAULT_CONFIG);
const [status, setStatus] = useState<OfficialChannelsStatus | null>(null); const [status, setStatus] = useState<OfficialChannelsStatus | null>(null);
@@ -19,13 +28,13 @@ export function useOfficialChannelsConfig() {
window.setTimeout(() => setSuccess(null), 1500); window.setTimeout(() => setSuccess(null), 1500);
}, []); }, []);
const fetchConfig = useCallback(async () => { const fetchConfig = useCallback(async (): Promise<boolean> => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const res = await fetch('/api/channels'); const res = await fetch('/api/channels');
if (!res.ok) { 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 { const data = (await res.json()) as {
@@ -35,15 +44,20 @@ export function useOfficialChannelsConfig() {
setConfig(data.config ?? DEFAULT_CONFIG); setConfig(data.config ?? DEFAULT_CONFIG);
setStatus(data.status ?? null); setStatus(data.status ?? null);
return true;
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : 'Unknown error');
return false;
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, []);
const updateConfig = useCallback( const updateConfig = useCallback(
async (updates: Partial<OfficialChannelsConfig>, successMessage = 'Settings saved') => { async (
updates: Partial<OfficialChannelsConfig>,
successMessage = 'Settings saved'
): Promise<boolean> => {
try { try {
setSaving(true); setSaving(true);
setError(null); setError(null);
@@ -55,24 +69,25 @@ export function useOfficialChannelsConfig() {
}); });
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { error?: string }; throw new Error(await readErrorMessage(res, 'Failed to save Official Channels settings'));
throw new Error(data.error || 'Failed to save Official Channels settings');
} }
const data = (await res.json()) as { config?: OfficialChannelsConfig }; const data = (await res.json()) as { config?: OfficialChannelsConfig };
setConfig(data.config ?? { ...config, ...updates }); setConfig((current) => data.config ?? { ...current, ...updates });
flashSuccess(successMessage); flashSuccess(successMessage);
return true;
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : 'Unknown error');
return false;
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, },
[config, flashSuccess] [flashSuccess]
); );
const saveToken = useCallback( const saveToken = useCallback(
async (channelId: OfficialChannelId, token: string) => { async (channelId: OfficialChannelId, token: string): Promise<boolean> => {
try { try {
setSaving(true); setSaving(true);
setError(null); setError(null);
@@ -84,14 +99,19 @@ export function useOfficialChannelsConfig() {
}); });
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { error?: string }; throw new Error(await readErrorMessage(res, `Failed to save ${channelId} token`));
throw new Error(data.error || `Failed to save ${channelId} token`); }
const refreshed = await fetchConfig();
if (!refreshed) {
return false;
} }
await fetchConfig();
flashSuccess(`${channelId} token saved`); flashSuccess(`${channelId} token saved`);
return true;
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : 'Unknown error');
return false;
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -100,7 +120,7 @@ export function useOfficialChannelsConfig() {
); );
const clearToken = useCallback( const clearToken = useCallback(
async (channelId: OfficialChannelId) => { async (channelId: OfficialChannelId): Promise<boolean> => {
try { try {
setSaving(true); setSaving(true);
setError(null); setError(null);
@@ -110,14 +130,19 @@ export function useOfficialChannelsConfig() {
}); });
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { error?: string }; throw new Error(await readErrorMessage(res, `Failed to clear ${channelId} token`));
throw new Error(data.error || `Failed to clear ${channelId} token`); }
const refreshed = await fetchConfig();
if (!refreshed) {
return false;
} }
await fetchConfig();
flashSuccess(`${channelId} token cleared`); flashSuccess(`${channelId} token cleared`);
return true;
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : 'Unknown error');
return false;
} finally { } finally {
setSaving(false); setSaving(false);
} }
+287 -89
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -26,6 +27,62 @@ const EMPTY_DRAFTS: TokenDrafts = {
imessage: '', 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() { export default function ChannelsSection() {
const { const {
config, config,
@@ -41,6 +98,7 @@ export default function ChannelsSection() {
} = useOfficialChannelsConfig(); } = useOfficialChannelsConfig();
const { fetchRawConfig } = useRawConfig(); const { fetchRawConfig } = useRawConfig();
const [tokenDrafts, setTokenDrafts] = useState<TokenDrafts>(EMPTY_DRAFTS); const [tokenDrafts, setTokenDrafts] = useState<TokenDrafts>(EMPTY_DRAFTS);
const selectedChannelLabel = getSelectedChannelLabel(config.selected, status?.channels);
useEffect(() => { useEffect(() => {
void fetchConfig(); void fetchConfig();
@@ -56,11 +114,13 @@ export default function ChannelsSection() {
? [...new Set([...config.selected, channelId])] ? [...new Set([...config.selected, channelId])]
: config.selected.filter((value) => value !== channelId); : config.selected.filter((value) => value !== channelId);
await updateConfig( const updated = await updateConfig(
{ selected: nextSelected }, { 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) => { const updateTokenDraft = (channelId: OfficialChannelId, value: string) => {
@@ -68,15 +128,19 @@ export default function ChannelsSection() {
}; };
const handleSaveToken = async (channelId: OfficialChannelId): Promise<void> => { const handleSaveToken = async (channelId: OfficialChannelId): Promise<void> => {
await saveToken(channelId, tokenDrafts[channelId]); const saved = await saveToken(channelId, tokenDrafts[channelId]);
setTokenDrafts((current) => ({ ...current, [channelId]: '' })); if (saved) {
await fetchRawConfig(); setTokenDrafts((current) => ({ ...current, [channelId]: '' }));
await fetchRawConfig();
}
}; };
const handleClearToken = async (channelId: OfficialChannelId): Promise<void> => { const handleClearToken = async (channelId: OfficialChannelId): Promise<void> => {
await clearToken(channelId); const cleared = await clearToken(channelId);
setTokenDrafts((current) => ({ ...current, [channelId]: '' })); if (cleared) {
await fetchRawConfig(); setTokenDrafts((current) => ({ ...current, [channelId]: '' }));
await fetchRawConfig();
}
}; };
if (loading) { if (loading) {
@@ -115,66 +179,145 @@ export default function ChannelsSection() {
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="space-y-6 p-5"> <div className="space-y-6 p-5">
<div className="flex items-center gap-3"> <div className="flex items-start gap-3">
<MessageSquare className="h-5 w-5 text-primary" /> <MessageSquare className="h-5 w-5 text-primary" />
<p className="text-sm text-muted-foreground"> <div className="space-y-1">
Auto-enable Anthropic&apos;s official Claude channels for compatible native Claude <p className="font-medium">Official Channels</p>
sessions. CCS stores only channel selection in <code>config.yaml</code>; bot tokens <p className="text-sm text-muted-foreground">
stay in Claude&apos;s per-channel env files. Configure official Claude channels here, then run <code>ccs</code> normally on a
</p> supported native Claude session.
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border bg-muted/30 p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Selected</p>
<p className="mt-1 font-medium">
{config.selected.length > 0 ? config.selected.join(', ') : 'None'}
</p> </p>
<p className="mt-2 text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Applies only to native Claude <code>default</code> and <code>account</code>{' '} CCS stores only channel selection in <code>config.yaml</code>. Claude keeps the
sessions. machine-level channel state under <code>~/.claude/channels/</code>.
</p> </p>
</div> </div>
<div className="rounded-lg border bg-muted/30 p-4 space-y-2">
<div className="flex items-center justify-between text-sm">
<span>Bun</span>
<span className={status?.bunInstalled ? 'text-green-600' : 'text-amber-600'}>
{status?.bunInstalled ? 'Installed' : 'Missing'}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span>Supported profiles</span>
<span>{status?.supportedProfiles.join(', ')}</span>
</div>
</div>
</div> </div>
<div className="rounded-lg border p-4"> {status && (
<div className="flex items-start justify-between gap-4 rounded-lg bg-muted/30 p-4"> <div className={`rounded-xl border p-4 ${getSummaryClasses(status.summary.state)}`}>
<div className="flex gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" /> <div className="space-y-2">
<div> <div className="flex items-center gap-2">
<Label className="text-sm font-medium"> <Badge variant={status.summary.state === 'ready' ? 'default' : 'outline'}>
Also add <code>--dangerously-skip-permissions</code> {status.summary.title}
</Label> </Badge>
<p className="mt-1 text-sm text-muted-foreground"> <span className="text-sm font-medium">{selectedChannelLabel}</span>
Opt-in only. CCS adds the bypass flag once when at least one selected channel </div>
is being auto-enabled and you did not already pass a permission flag yourself. <p className="text-sm">{status.summary.message}</p>
</p> <p className="text-sm opacity-90">{status.summary.nextStep}</p>
</div>
<div className="min-w-[220px] rounded-lg border border-current/10 bg-background/60 p-3 text-sm text-foreground">
<p className="font-medium">Machine checks</p>
<div className="mt-2 space-y-1 text-muted-foreground">
<div className="flex items-center justify-between gap-4">
<span>Bun</span>
<span>{status.bunInstalled ? 'Installed' : 'Missing'}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span>Claude Code</span>
<span>
{status.claudeVersion.current
? `v${status.claudeVersion.current}`
: 'Unknown'}
</span>
</div>
<div className="flex items-center justify-between gap-4">
<span>Claude auth</span>
<span>{status.auth.authMethod ?? 'Unknown'}</span>
</div>
</div>
</div> </div>
</div> </div>
<Switch {status.summary.blockers.length > 0 && (
checked={config.unattended} <div className="mt-3 space-y-1 text-sm">
disabled={saving} {status.summary.blockers.map((blocker) => (
onCheckedChange={(checked) => <p key={blocker}>{blocker}</p>
void updateConfig( ))}
{ unattended: checked }, </div>
checked ? 'Unattended mode enabled' : 'Unattended mode disabled' )}
)
}
/>
</div> </div>
</div> )}
{status && (
<div className="rounded-lg border bg-muted/20 p-4">
<p className="font-medium">Fastest path</p>
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
<p>1. Turn on the channels you want below.</p>
<p>2. Save Telegram or Discord bot tokens here if that channel needs one.</p>
<p>
3. Run <code>ccs</code> or a native Claude account profile. CCS adds{' '}
<code>--channels</code> for you on supported runs.
</p>
<p>{status.supportMessage}</p>
</div>
<details className="mt-3 rounded-lg border bg-background p-4">
<summary className="cursor-pointer text-sm font-medium">
Advanced notes and scope
</summary>
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
<p>{status.accountStatusCaveat}</p>
<p>{status.stateScopeMessage}</p>
</div>
</details>
</div>
)}
{status && (
<div className="rounded-lg border bg-background p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<p className="font-medium">
If you run <code>ccs</code> now
</p>
<p className="text-sm text-muted-foreground">{status.launchPreview.detail}</p>
</div>
<Badge variant={getLaunchPreviewBadgeVariant(status.launchPreview.state)}>
{status.launchPreview.title}
</Badge>
</div>
<div className="mt-3 space-y-2">
<div className="rounded-md bg-muted px-3 py-2 font-mono text-sm">
<span className="text-muted-foreground">You type:</span>{' '}
{status.launchPreview.command}
</div>
<div className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all">
<span className="text-muted-foreground">CCS adds:</span>{' '}
{status.launchPreview.appendedArgs.length > 0
? status.launchPreview.appendedArgs.join(' ')
: '(nothing yet)'}
</div>
</div>
{status.launchPreview.skippedMessages.length > 0 && (
<div className="mt-3 space-y-1 text-sm text-muted-foreground">
{status.launchPreview.skippedMessages.map((message) => (
<p key={message}>{message}</p>
))}
</div>
)}
</div>
)}
{status?.claudeVersion.message && status.claudeVersion.state !== 'supported' && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>{status.claudeVersion.message}</AlertDescription>
</Alert>
)}
{status?.auth.message && status.auth.state !== 'eligible' && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>{status.auth.message}</AlertDescription>
</Alert>
)}
{status?.auth.orgRequirementMessage && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>{status.auth.orgRequirementMessage}</AlertDescription>
</Alert>
)}
<div className="space-y-4"> <div className="space-y-4">
{status?.channels.map((channel) => { {status?.channels.map((channel) => {
@@ -190,23 +333,40 @@ export default function ChannelsSection() {
<p className="mt-2 font-mono text-xs text-muted-foreground"> <p className="mt-2 font-mono text-xs text-muted-foreground">
{channel.pluginSpec} {channel.pluginSpec}
</p> </p>
{channel.unavailableReason && (
<p className="mt-2 text-sm text-amber-600">{channel.unavailableReason}</p>
)}
</div> </div>
<Switch <div className="flex items-center gap-3">
checked={enabled} <Badge variant={getSetupBadgeVariant(channel.setup.state)}>
disabled={saving || Boolean(channel.unavailableReason)} {channel.setup.label}
onCheckedChange={(checked) => void toggleChannel(channel.id, checked)} </Badge>
/> <Switch
checked={enabled}
disabled={saving || (Boolean(channel.unavailableReason) && !enabled)}
onCheckedChange={(checked) => void toggleChannel(channel.id, checked)}
/>
</div>
</div>
<div className="rounded-lg bg-muted/30 p-4 text-sm text-muted-foreground space-y-2">
<p>{channel.setup.detail}</p>
<p>{channel.setup.nextStep}</p>
</div> </div>
{channel.requiresToken && ( {channel.requiresToken && (
<div className="space-y-3 rounded-lg bg-muted/30 p-4"> <div className="space-y-3 rounded-lg bg-muted/30 p-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Save <code>{channel.envKey}</code> in Claude&apos;s official channel env {!channel.tokenConfigured && channel.tokenSource === 'process_env'
file. The dashboard never reads the token value back after save. ? `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.`}
</p> </p>
{channel.tokenConfigured && (
<p className="text-sm text-muted-foreground">
Saving here writes the same <code>.env</code> file as{' '}
<code>/{channel.id}:configure</code>, so you do not need to run the
configure command again after a successful save.
</p>
)}
<Input <Input
type="password" type="password"
value={tokenDraft} value={tokenDraft}
@@ -214,13 +374,17 @@ export default function ChannelsSection() {
placeholder={ placeholder={
channel.tokenConfigured channel.tokenConfigured
? `Configured. Enter a new ${channel.envKey} to replace it.` ? `Configured. Enter a new ${channel.envKey} to replace it.`
: `Paste ${channel.envKey}` : !channel.tokenConfigured && channel.tokenSource === 'process_env'
? `Using current CCS process env. Enter a new ${channel.envKey} to save it for Claude.`
: `Paste ${channel.envKey}`
} }
disabled={saving} disabled={saving}
/> />
<div className="text-xs text-muted-foreground break-all"> {channel.tokenPath && channel.tokenSource !== 'process_env' && (
{channel.tokenPath} <div className="text-xs text-muted-foreground break-all">
</div> {channel.tokenPath}
</div>
)}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
onClick={() => void handleSaveToken(channel.id)} onClick={() => void handleSaveToken(channel.id)}
@@ -235,23 +399,27 @@ export default function ChannelsSection() {
disabled={saving || !channel.tokenConfigured} disabled={saving || !channel.tokenConfigured}
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Clear Token Clear Saved Token
</Button> </Button>
</div> </div>
</div> </div>
)} )}
<div className="space-y-2"> <details className="rounded-lg border bg-background p-4">
<p className="text-sm font-medium">Claude-side setup</p> <summary className="cursor-pointer text-sm font-medium">
{(channel.manualSetupCommands ?? []).map((command) => ( Claude-side setup commands
<div </summary>
key={command} <div className="mt-3 space-y-2">
className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all" {(channel.manualSetupCommands ?? []).map((command) => (
> <div
{command} key={command}
</div> className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all"
))} >
</div> {command}
</div>
))}
</div>
</details>
</div> </div>
); );
})} })}
@@ -259,12 +427,42 @@ export default function ChannelsSection() {
<Alert> <Alert>
<AlertDescription> <AlertDescription>
CCS does not persist a global Claude setting for channels. It only prepares channel CCS injects <code>--channels</code> only for the current Claude session. Telegram,
env files and injects runtime flags when the selected channels are compatible and Discord, and iMessage stop receiving messages when that Claude session exits.
ready.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<div className="rounded-lg border p-4">
<div className="flex items-start justify-between gap-4 rounded-lg bg-muted/30 p-4">
<div className="flex gap-3">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<div>
<Label className="text-sm font-medium">Skip permission prompts on launch</Label>
<p className="mt-1 text-sm text-muted-foreground">
Optional advanced behavior. CCS adds <code>--dangerously-skip-permissions</code>{' '}
only when at least one selected channel is being auto-enabled and you did not
already pass a permission flag yourself.
</p>
</div>
</div>
<Switch
checked={config.unattended}
disabled={saving}
onCheckedChange={(checked) =>
void (async () => {
const updated = await updateConfig(
{ unattended: checked },
checked ? 'Unattended mode enabled' : 'Unattended mode disabled'
);
if (updated) {
await fetchRawConfig();
}
})()
}
/>
</div>
</div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button variant="outline" onClick={() => void refreshAll()} disabled={saving}> <Button variant="outline" onClick={() => void refreshAll()} disabled={saving}>
<RefreshCw className={`mr-2 h-4 w-4 ${saving ? 'animate-spin' : ''}`} /> <RefreshCw className={`mr-2 h-4 w-4 ${saving ? 'animate-spin' : ''}`} />
+51
View File
@@ -71,20 +71,71 @@ export interface OfficialChannelsConfig {
export interface OfficialChannelStatus { export interface OfficialChannelStatus {
id: OfficialChannelId; id: OfficialChannelId;
selected?: boolean;
displayName: string; displayName: string;
pluginSpec: string; pluginSpec: string;
summary: string; summary: string;
requiresToken: boolean; requiresToken: boolean;
envKey?: string; envKey?: string;
tokenConfigured: boolean; tokenConfigured: boolean;
tokenAvailable?: boolean;
tokenSource?: 'saved_env' | 'process_env' | 'missing';
tokenPath?: string; tokenPath?: string;
savedInClaudeState?: boolean;
processEnvAvailable?: boolean;
unavailableReason?: string; unavailableReason?: string;
setup: {
state: 'not_selected' | 'ready' | 'needs_token' | 'needs_claude_setup' | 'unavailable';
label: string;
detail: string;
nextStep: string;
};
manualSetupCommands: string[]; manualSetupCommands: string[];
} }
export interface OfficialChannelsVersionStatus {
current: string | null;
minimum: string;
state: 'supported' | 'unsupported' | 'unknown';
message: string;
}
export interface OfficialChannelsAuthStatus {
checked: boolean;
loggedIn: boolean;
authMethod: string | null;
subscriptionType: string | null;
state: 'eligible' | 'ineligible' | 'unknown';
eligible: boolean;
message: string;
orgRequirementMessage?: string;
}
export interface OfficialChannelsStatus { export interface OfficialChannelsStatus {
bunInstalled: boolean; bunInstalled: boolean;
supportedProfiles: string[]; supportedProfiles: string[];
supportMessage: string;
accountStatusCaveat: string;
stateScopeMessage: string;
claudeVersion: OfficialChannelsVersionStatus;
auth: OfficialChannelsAuthStatus;
summary: {
state: 'ready' | 'needs_setup' | 'limited';
title: string;
message: string;
nextStep: string;
blockers: string[];
};
launchPreview: {
state: 'disabled' | 'blocked' | 'partial' | 'ready';
title: string;
detail: string;
command: string;
appendedArgs: string[];
appliedChannels: OfficialChannelId[];
permissionBypassIncluded: boolean;
skippedMessages: string[];
};
channels: OfficialChannelStatus[]; channels: OfficialChannelStatus[];
} }
@@ -0,0 +1,518 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
import ChannelsSection from '@/pages/settings/sections/channels';
function jsonResponse(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: {
'Content-Type': 'application/json',
},
});
}
function textResponse(payload: string, status = 200): Response {
return new Response(payload, {
status,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
});
}
function requestUrl(input: RequestInfo | URL): string {
if (typeof input === 'string') {
return input;
}
if (input instanceof URL) {
return input.toString();
}
return input.url;
}
describe('ChannelsSection', () => {
const fetchMock = vi.fn<typeof fetch>();
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
fetchMock.mockReset();
});
it('keeps the token draft when saving fails and shows the backend error', async () => {
fetchMock.mockImplementation(async (input, init) => {
const url = requestUrl(input);
if (url.endsWith('/api/channels') && (!init || init.method === undefined)) {
return jsonResponse({
config: { selected: ['discord'], unattended: false },
status: {
bunInstalled: true,
supportedProfiles: ['default', 'account'],
supportMessage: 'Native Claude only. Not for ccs glm.',
accountStatusCaveat: 'Dashboard status reflects the current CCS process.',
stateScopeMessage: 'Machine-level Claude state',
claudeVersion: {
current: '2.1.81',
minimum: '2.1.80',
state: 'supported',
message: 'Claude Code v2.1.81',
},
auth: {
checked: true,
loggedIn: true,
authMethod: 'claude.ai',
subscriptionType: 'pro',
state: 'eligible',
eligible: true,
message: 'Authenticated with claude.ai.',
},
summary: {
state: 'needs_setup',
title: 'Needs setup before CCS can auto-add these channels',
message: 'Missing bot token for Discord.',
nextStep: 'Save DISCORD_BOT_TOKEN below.',
blockers: ['Missing bot token for Discord.'],
},
launchPreview: {
state: 'blocked',
title: 'Running `ccs` now will not auto-add channels',
detail: 'Discord still needs a saved token before CCS can add it.',
command: 'ccs',
appendedArgs: [],
appliedChannels: [],
permissionBypassIncluded: false,
skippedMessages: ['Discord still needs a saved token before CCS can add it.'],
},
channels: [
{
id: 'discord',
selected: true,
displayName: 'Discord',
pluginSpec: 'plugin:discord@claude-plugins-official',
summary: 'Bot token required.',
requiresToken: true,
envKey: 'DISCORD_BOT_TOKEN',
tokenConfigured: false,
tokenAvailable: false,
tokenSource: 'missing',
tokenPath: '/tmp/.claude/channels/discord/.env',
savedInClaudeState: false,
processEnvAvailable: false,
setup: {
state: 'needs_token',
label: 'Needs token',
detail: 'DISCORD_BOT_TOKEN is missing.',
nextStep: 'Save DISCORD_BOT_TOKEN below.',
},
manualSetupCommands: ['/discord:configure <token>'],
},
],
},
});
}
if (url.endsWith('/api/config/raw')) {
return textResponse('channels:\n selected:\n - discord\n');
}
if (url.endsWith('/api/channels/discord/token') && init?.method === 'PUT') {
return jsonResponse({ error: 'Discord rejected token' }, 500);
}
throw new Error(`Unexpected fetch: ${url}`);
});
render(<ChannelsSection />, { withSettingsProvider: true });
const tokenInput = await screen.findByPlaceholderText('Paste DISCORD_BOT_TOKEN');
await userEvent.type(tokenInput, 'discord-secret');
await userEvent.click(screen.getByRole('button', { name: 'Save Token' }));
expect(await screen.findByText('Discord rejected token')).toBeInTheDocument();
expect(tokenInput).toHaveValue('discord-secret');
});
it('keeps the token draft when refresh fails after a successful token save', async () => {
let channelsRequestCount = 0;
fetchMock.mockImplementation(async (input, init) => {
const url = requestUrl(input);
if (url.endsWith('/api/channels') && (!init || init.method === undefined)) {
channelsRequestCount += 1;
if (channelsRequestCount === 1) {
return jsonResponse({
config: { selected: ['discord'], unattended: false },
status: {
bunInstalled: true,
supportedProfiles: ['default', 'account'],
supportMessage: 'Native Claude only. Not for ccs glm.',
accountStatusCaveat: 'Dashboard status reflects the current CCS process.',
stateScopeMessage: 'Machine-level Claude state',
claudeVersion: {
current: '2.1.81',
minimum: '2.1.80',
state: 'supported',
message: 'Claude Code v2.1.81',
},
auth: {
checked: true,
loggedIn: true,
authMethod: 'claude.ai',
subscriptionType: 'pro',
state: 'eligible',
eligible: true,
message: 'Authenticated with claude.ai.',
},
summary: {
state: 'needs_setup',
title: 'Needs setup before CCS can auto-add these channels',
message: 'Missing bot token for Discord.',
nextStep: 'Save DISCORD_BOT_TOKEN below.',
blockers: ['Missing bot token for Discord.'],
},
launchPreview: {
state: 'blocked',
title: 'Running `ccs` now will not auto-add channels',
detail: 'Discord still needs a saved token before CCS can add it.',
command: 'ccs',
appendedArgs: [],
appliedChannels: [],
permissionBypassIncluded: false,
skippedMessages: ['Discord still needs a saved token before CCS can add it.'],
},
channels: [
{
id: 'discord',
selected: true,
displayName: 'Discord',
pluginSpec: 'plugin:discord@claude-plugins-official',
summary: 'Bot token required.',
requiresToken: true,
envKey: 'DISCORD_BOT_TOKEN',
tokenConfigured: false,
tokenAvailable: false,
tokenSource: 'missing',
tokenPath: '/tmp/.claude/channels/discord/.env',
savedInClaudeState: false,
processEnvAvailable: false,
setup: {
state: 'needs_token',
label: 'Needs token',
detail: 'DISCORD_BOT_TOKEN is missing.',
nextStep: 'Save DISCORD_BOT_TOKEN below.',
},
manualSetupCommands: ['/discord:configure <token>'],
},
],
},
});
}
return jsonResponse({ error: 'Failed to load Official Channels settings' }, 500);
}
if (url.endsWith('/api/config/raw')) {
return textResponse('channels:\n selected:\n - discord\n');
}
if (url.endsWith('/api/channels/discord/token') && init?.method === 'PUT') {
return jsonResponse({ success: true, tokenConfigured: true, tokenPath: '/tmp/.env' });
}
throw new Error(`Unexpected fetch: ${url}`);
});
render(<ChannelsSection />, { withSettingsProvider: true });
const tokenInput = await screen.findByPlaceholderText('Paste DISCORD_BOT_TOKEN');
await userEvent.type(tokenInput, 'discord-secret');
await userEvent.click(screen.getByRole('button', { name: 'Save Token' }));
expect(
await screen.findByText('Failed to load Official Channels settings')
).toBeInTheDocument();
expect(tokenInput).toHaveValue('discord-secret');
expect(screen.queryByText('discord token saved')).not.toBeInTheDocument();
});
it('lets users turn off an unsupported selected channel', async () => {
let channelsRequestCount = 0;
fetchMock.mockImplementation(async (input, init) => {
const url = requestUrl(input);
if (url.endsWith('/api/channels') && (!init || init.method === undefined)) {
channelsRequestCount += 1;
return jsonResponse(
channelsRequestCount === 1
? {
config: { selected: ['imessage'], unattended: false },
status: {
bunInstalled: true,
supportedProfiles: ['default', 'account'],
supportMessage: 'Native Claude only. Not for ccs glm.',
accountStatusCaveat: 'Dashboard status reflects the current CCS process.',
stateScopeMessage: 'Machine-level Claude state',
claudeVersion: {
current: '2.1.81',
minimum: '2.1.80',
state: 'supported',
message: 'Claude Code v2.1.81',
},
auth: {
checked: true,
loggedIn: true,
authMethod: 'claude.ai',
subscriptionType: 'pro',
state: 'eligible',
eligible: true,
message: 'Authenticated with claude.ai.',
},
summary: {
state: 'limited',
title: 'Selected, but some channels still need manual setup',
message: 'iMessage still needs Claude-side install.',
nextStep: 'Review the channel cards below.',
blockers: ['iMessage still needs Claude-side install.'],
},
launchPreview: {
state: 'blocked',
title: 'Running `ccs` now will not auto-add channels',
detail: 'iMessage requires macOS on this machine.',
command: 'ccs',
appendedArgs: [],
appliedChannels: [],
permissionBypassIncluded: false,
skippedMessages: ['iMessage requires macOS on this machine.'],
},
channels: [
{
id: 'imessage',
selected: true,
displayName: 'iMessage',
pluginSpec: 'plugin:imessage@claude-plugins-official',
summary: 'macOS only.',
requiresToken: false,
tokenConfigured: false,
tokenAvailable: true,
savedInClaudeState: false,
processEnvAvailable: false,
unavailableReason: 'Requires macOS.',
setup: {
state: 'unavailable',
label: 'Requires macOS.',
detail: 'iMessage is selected, but this machine cannot use it right now.',
nextStep: 'Turn it off here, or switch to a supported machine.',
},
manualSetupCommands: ['/imessage:access allow +15551234567'],
},
],
},
}
: {
config: { selected: [], unattended: false },
status: {
bunInstalled: true,
supportedProfiles: ['default', 'account'],
supportMessage: 'Native Claude only. Not for ccs glm.',
accountStatusCaveat: 'Dashboard status reflects the current CCS process.',
stateScopeMessage: 'Machine-level Claude state',
claudeVersion: {
current: '2.1.81',
minimum: '2.1.80',
state: 'supported',
message: 'Claude Code v2.1.81',
},
auth: {
checked: true,
loggedIn: true,
authMethod: 'claude.ai',
subscriptionType: 'pro',
state: 'eligible',
eligible: true,
message: 'Authenticated with claude.ai.',
},
summary: {
state: 'needs_setup',
title: 'No channels selected yet',
message: 'Choose at least one official channel before CCS can auto-add it.',
nextStep: 'Turn on Telegram, Discord, and/or iMessage below.',
blockers: ['Select at least one channel for auto-enable.'],
},
launchPreview: {
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: [],
},
channels: [
{
id: 'imessage',
selected: false,
displayName: 'iMessage',
pluginSpec: 'plugin:imessage@claude-plugins-official',
summary: 'macOS only.',
requiresToken: false,
tokenConfigured: false,
tokenAvailable: true,
savedInClaudeState: false,
processEnvAvailable: false,
unavailableReason: 'Requires macOS.',
setup: {
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.',
},
manualSetupCommands: ['/imessage:access allow +15551234567'],
},
],
},
}
);
}
if (url.endsWith('/api/config/raw')) {
return textResponse('channels:\n selected:\n - imessage\n');
}
if (url.endsWith('/api/channels') && init?.method === 'PUT') {
return jsonResponse({
config: { selected: [], unattended: false },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
render(<ChannelsSection />, { withSettingsProvider: true });
const switches = await screen.findAllByRole('switch');
const imessageSwitch = switches[0];
expect(imessageSwitch).not.toBeDisabled();
await userEvent.click(imessageSwitch);
await waitFor(() => {
const updateCall = fetchMock.mock.calls.find(
([input, init]) => requestUrl(input).endsWith('/api/channels') && init?.method === 'PUT'
);
expect(updateCall).toBeDefined();
expect(JSON.parse(String(updateCall?.[1]?.body))).toEqual({ selected: [] });
});
});
it('surfaces process-env readiness and the native-only limitation clearly', async () => {
fetchMock.mockImplementation(async (input, init) => {
const url = requestUrl(input);
if (url.endsWith('/api/channels') && (!init || init.method === undefined)) {
return jsonResponse({
config: { selected: ['discord'], unattended: false },
status: {
bunInstalled: true,
supportedProfiles: ['default', 'account'],
supportMessage:
'Works only for native Claude default/account sessions. Not for ccs glm.',
accountStatusCaveat: 'Dashboard status reflects the current CCS process.',
stateScopeMessage: 'Machine-level Claude state',
claudeVersion: {
current: '2.1.81',
minimum: '2.1.80',
state: 'supported',
message: 'Claude Code v2.1.81',
},
auth: {
checked: true,
loggedIn: true,
authMethod: 'claude.ai',
subscriptionType: 'pro',
state: 'eligible',
eligible: true,
message: 'Authenticated with claude.ai.',
},
summary: {
state: 'ready',
title: 'Ready for the next native Claude run',
message: 'CCS can auto-add Discord the next time you run ccs.',
nextStep: 'Run ccs from a supported native Claude session.',
blockers: [],
},
launchPreview: {
state: 'ready',
title: 'CCS will auto-add Discord',
detail:
'Running `ccs` will add the selected official channels automatically on this machine.',
command: 'ccs',
appendedArgs: ['--channels', 'plugin:discord@claude-plugins-official'],
appliedChannels: ['discord'],
permissionBypassIncluded: false,
skippedMessages: [],
},
channels: [
{
id: 'discord',
selected: true,
displayName: 'Discord',
pluginSpec: 'plugin:discord@claude-plugins-official',
summary: 'Bot token required.',
requiresToken: true,
envKey: 'DISCORD_BOT_TOKEN',
tokenConfigured: false,
tokenAvailable: true,
tokenSource: 'process_env',
savedInClaudeState: false,
processEnvAvailable: true,
setup: {
state: 'ready',
label: 'Ready from current CCS process env',
detail: 'DISCORD_BOT_TOKEN is available from the current CCS process env.',
nextStep: 'Run CCS from this same env.',
},
manualSetupCommands: ['/discord:configure <token>'],
},
],
},
});
}
if (url.endsWith('/api/config/raw')) {
return textResponse('channels:\n selected:\n - discord\n');
}
throw new Error(`Unexpected fetch: ${url}`);
});
render(<ChannelsSection />, { withSettingsProvider: true });
expect(await screen.findByText('Ready for the next native Claude run')).toBeInTheDocument();
expect(screen.getByText('Fastest path')).toBeInTheDocument();
expect(screen.getByText(/If you run/i)).toBeInTheDocument();
expect(screen.getByText('CCS will auto-add Discord')).toBeInTheDocument();
expect(screen.getByText(/CCS adds:/i)).toBeInTheDocument();
expect(screen.getByText(/Not for ccs glm/i)).toBeInTheDocument();
expect(screen.getByText('Ready from current CCS process env')).toBeInTheDocument();
expect(screen.getByText('Skip permission prompts on launch')).toBeInTheDocument();
expect(
screen.getByPlaceholderText(
'Using current CCS process env. Enter a new DISCORD_BOT_TOKEN to save it for Claude.'
)
).toBeInTheDocument();
});
});