mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 10:19:37 +00:00
feat(channels): auto-enable official Claude channels
This commit is contained in:
+15
-36
@@ -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> {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ??
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'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'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'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' : ''}`} />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user