diff --git a/bunfig.toml b/bunfig.toml index b40c7204..8c6b12ef 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -4,4 +4,5 @@ # Exclude e2e tests - they require manual setup and are slow # Run e2e tests with: bun run test:e2e root = "./tests" +preload = ["./tests/shared/fixtures/test-environment.js"] timeout = 10000 diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 37049183..2f35dd80 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,8 +1,8 @@ # CCS Codebase Summary -Last Updated: 2026-03-18 +Last Updated: 2026-03-24 -Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, and account-context validation hardening. +Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, and Official Claude Channels runtime support. ## Repository Structure @@ -84,6 +84,10 @@ src/ │ ├── unified-config-loader.ts # Central config loader (546 lines) │ └── migration-manager.ts # Config migration logic │ +├── channels/ # Official Claude channel integration +│ ├── official-channels-runtime.ts # Runtime gating, plugin specs, setup guidance +│ └── official-channels-store.ts # Claude channel token/env storage helpers +│ ├── cliproxy/ # CLIProxyAPI integration (heavily modularized) │ ├── index.ts # Barrel export (137 lines, extensive) │ ├── auth/ # OAuth handlers, token management @@ -174,6 +178,7 @@ src/ │ ├── index.ts │ ├── accounts-route.ts │ ├── auth-route.ts + │ ├── channels-routes.ts │ ├── cliproxy-route.ts │ ├── copilot-route.ts │ ├── doctor-route.ts @@ -229,6 +234,15 @@ src/ - `plugins/marketplaces/`, `plugins/cache/`, and `installed_plugins.json` stay shared through the `~/.ccs/shared/` topology. - `known_marketplaces.json` is now instance-local under `~/.ccs/instances//plugins/` so Claude Code validates `installLocation` against the active `CLAUDE_CONFIG_DIR` instead of a last-writer-wins shared file. +### Official Claude Channels + +- Runtime contract lives in `src/channels/official-channels-runtime.ts` and is consumed from `src/ccs.ts`, `src/commands/config-channels-command.ts`, and `src/web-server/routes/channels-routes.ts`. +- Canonical config lives under `channels.*` in `~/.ccs/config.yaml`; legacy `discord_channels.*` remains read-compatible only when canonical fields are absent. +- Telegram and Discord bot tokens are intentionally written into Claude-managed machine state under `~/.claude/channels//.env`, unless the official `*_STATE_DIR` environment override redirects that channel elsewhere. +- iMessage is tokenless, macOS-only, and still depends on Claude-side plugin install plus OS permissions. +- Auto-enable is gated on Bun availability, verified Claude Code v2.1.80+, verified `claude.ai` auth, native Claude `default/account` sessions, and per-channel setup readiness. +- The dashboard channels section surfaces Bun/version/auth/state-scope status from `/api/channels`, preserves token drafts when save-follow-up refresh fails, and keeps unsupported selected iMessage visible only so it can be turned off. + ### Target Adapter Module The targets module provides an extensible interface for dispatching profiles to different CLI implementations. @@ -420,6 +434,7 @@ ui/src/ │ │ ├── hooks/ │ │ │ ├── index.ts │ │ │ ├── context-hooks.ts +│ │ │ ├── use-official-channels-config.ts │ │ │ ├── use-settings-tab.ts │ │ │ ├── use-proxy-config.ts │ │ │ ├── use-websearch-config.ts @@ -429,6 +444,7 @@ ui/src/ │ │ │ ├── section-skeleton.tsx │ │ │ └── tab-navigation.tsx │ │ └── sections/ +│ │ ├── channels.tsx │ │ ├── globalenv-section.tsx │ │ ├── websearch/ │ │ │ ├── index.tsx diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 9f698758..744de589 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -1,6 +1,6 @@ # CCS Product Development Requirements (PDR) -Last Updated: 2026-03-19 +Last Updated: 2026-03-24 ## Product Overview @@ -8,7 +8,7 @@ Last Updated: 2026-03-19 **Tagline**: The universal AI profile manager for Claude Code -**Description**: CLI wrapper enabling seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, OpenRouter, Qwen, Kimi, DeepSeek) with a React-based dashboard for configuration management. Supports both local and remote CLIProxyAPI instances with hybrid quota management. +**Description**: CLI wrapper enabling seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, OpenRouter, Qwen, Kimi, DeepSeek) with a React-based dashboard for configuration management. Supports both local and remote CLIProxyAPI instances, hybrid quota management, and official Claude channel runtime setup for Telegram, Discord, and iMessage. **Current Version**: v7.34.x (Image Analysis Hook + Performance Improvements) @@ -37,6 +37,7 @@ CCS provides: 5. **Visual Dashboard**: React SPA for configuration management 6. **Automatic WebSearch**: Real backend fallback chain for third-party providers 7. **Usage Analytics**: Token tracking, cost analysis, model breakdown +8. **Official Claude Channels**: Runtime auto-enable plus dashboard token/config flow for Telegram, Discord, and macOS-only iMessage --- @@ -129,6 +130,16 @@ CCS provides: - Security: single-quoted output, key sanitization, shell-specific escaping - Cross-platform compatibility (macOS, Linux, Windows) +### FR-012: Official Claude Channels +- Support Telegram, Discord, and iMessage selection via `ccs config channels` and the dashboard +- Auto-inject `--channels` only for native Claude `default` and `account` sessions +- Store Telegram/Discord bot tokens in Claude's own `~/.claude/channels//.env` state or the official `*_STATE_DIR` override path when one is configured +- Treat iMessage as macOS-only, tokenless, and dependent on Claude-side install plus OS permissions +- Require Bun, Claude Code v2.1.80+, and verified `claude.ai` auth before runtime auto-enable +- Keep `--dangerously-skip-permissions` optional and never add it when the user already made an explicit permission choice +- Surface platform/auth/version/setup blockers clearly in both CLI and dashboard flows +- Preserve dashboard token drafts when save/refresh fails, and let already-selected unsupported iMessage entries be turned off without allowing re-enable on unsupported platforms + --- ## Non-Functional Requirements @@ -172,11 +183,13 @@ CCS provides: - CLIProxyAPI binary (auto-managed) - Exa/Tavily/Brave API keys for higher-quality WebSearch - Gemini CLI for legacy WebSearch fallback +- Bun plus Claude Code v2.1.80+ with `claude.ai` auth for Official Channels auto-enable ### TR-003: Configuration - YAML-based config (`~/.ccs/config.yaml`) - JSON settings per profile - Environment variable overrides +- Official channel bot tokens stored in Claude-managed `~/.claude/channels//.env` --- diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index e93db791..47e4433a 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-03-23 +Last Updated: 2026-03-24 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-03-24**: Official Claude Channels now follow Anthropic's actual runtime contract. CCS blocks auto-enable unless Bun is available, Claude Code is verified at v2.1.80+, and `claude.ai` auth is verified; treats `--allow-dangerously-skip-permissions` as an explicit override; keeps Telegram/Discord bot tokens in Claude's shared `~/.claude/channels/` state (or official `*_STATE_DIR` overrides); and upgrades the dashboard/CLI status flow with Bun/version/auth/state-scope guidance, safer token draft retention on refresh failures, and a non-macOS iMessage toggle that can still be turned off when already selected. - **2026-03-23**: CLIProxy providers that do not expose an email no longer require a user-supplied nickname on first auth. CCS now derives a stable internal account identifier for Kiro/Copilot-style flows, preserves later rename support, hardens account discovery/registry sync around that identifier, and updates AI Provider CRUD to use stable entry IDs instead of dashboard list indexes. - **2026-03-23**: Sensitive dashboard management routes now fail closed to localhost-only access whenever dashboard auth is disabled. Remote access remains available after `ccs config auth setup`, but AI Provider management, CLIProxy auth/status helpers, and other write-capable settings endpoints no longer trust unauthenticated non-loopback requests. - **2026-03-19**: **#649** CCS splits CLIProxy provider-key authoring into a dedicated `CLIProxy -> AI Providers` dashboard route. `/cliproxy` now stays focused on OAuth accounts and variants, `/cliproxy/ai-providers` owns Gemini/Codex/Claude/Vertex/OpenAI-compatible key management, and `/providers` stays reserved for CCS-native API Profiles. diff --git a/src/ccs.ts b/src/ccs.ts index 5e412bef..4d273c2b 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -29,11 +29,18 @@ import { getWebSearchHookEnv, ensureProfileHooks, } from './utils/websearch-manager'; -import { getGlobalEnvConfig } from './config/unified-config-loader'; +import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector'; import { getImageAnalysisHookEnv } from './utils/hooks'; import { fail, info, warn } from './utils/ui'; import { isCopilotSubcommandToken } from './copilot/constants'; +import { + buildOfficialChannelsArgs, + getOfficialChannelsEnvironmentStatus, + officialChannelRequiresMacOS, + resolveOfficialChannelsLaunchPlan, +} from './channels/official-channels-runtime'; +import { getOfficialChannelReadiness } from './channels/official-channels-store'; // Import centralized error handling import { handleError, runCleanup } from './errors'; @@ -130,6 +137,48 @@ async function showCachedUpdateNotification(): Promise { return false; } +function resolveNativeClaudeLaunchArgs( + args: string[], + profileType: 'default' | 'account', + targetConfigDir?: string +): string[] { + const config = getOfficialChannelsConfig(); + const environment = getOfficialChannelsEnvironmentStatus( + targetConfigDir ? { CLAUDE_CONFIG_DIR: targetConfigDir } : undefined + ); + const channelReadiness = { + telegram: getOfficialChannelReadiness('telegram'), + discord: getOfficialChannelReadiness('discord'), + imessage: !officialChannelRequiresMacOS('imessage') || process.platform === 'darwin', + }; + const plan = resolveOfficialChannelsLaunchPlan({ + args, + config, + target: 'claude', + profileType, + environment, + channelReadiness, + }); + + for (const message of plan.skippedMessages) { + console.error(warn(message)); + } + + if ( + config.selected.length > 0 && + environment.auth.state === 'eligible' && + environment.auth.orgRequirementMessage + ) { + console.error(warn(environment.auth.orgRequirementMessage)); + } + + if (!plan.applied) { + return args; + } + + return buildOfficialChannelsArgs(args, plan.appliedChannels, plan.wantsPermissionBypass); +} + async function main(): Promise { // Register target adapters registerTarget(new ClaudeAdapter()); @@ -838,7 +887,8 @@ async function main(): Promise { CCS_WEBSEARCH_SKIP: '1', CCS_IMAGE_ANALYSIS_SKIP: '1', }; - execClaude(claudeCli, remainingArgs, envVars); + const launchArgs = resolveNativeClaudeLaunchArgs(remainingArgs, 'account', instancePath); + execClaude(claudeCli, launchArgs, envVars); } else { // DEFAULT: No profile configured, use Claude's own defaults // Skip WebSearch hook - native Claude has server-side WebSearch @@ -906,7 +956,12 @@ async function main(): Promise { return; } - execClaude(claudeCli, remainingArgs, envVars); + const launchArgs = resolveNativeClaudeLaunchArgs( + remainingArgs, + 'default', + envVars.CLAUDE_CONFIG_DIR + ); + execClaude(claudeCli, launchArgs, envVars); } } catch (error) { const err = error as ProfileError; diff --git a/src/channels/official-channels-runtime.ts b/src/channels/official-channels-runtime.ts new file mode 100644 index 00000000..f708f66e --- /dev/null +++ b/src/channels/official-channels-runtime.ts @@ -0,0 +1,866 @@ +import { spawnSync } from 'child_process'; +import type { TargetType } from '../targets/target-adapter'; +import type { ProfileType } from '../types/profile'; +import type { OfficialChannelId, OfficialChannelsConfig } from '../config/unified-config-types'; +import type { OfficialChannelTokenSource } from './official-channels-store'; +import { + getClaudeAuthStatus, + getClaudeCliVersion, + isClaudeCliVersionAtLeast, + type ClaudeAuthStatus, +} from '../utils/claude-detector'; + +export interface OfficialChannelDefinition { + id: OfficialChannelId; + displayName: string; + pluginSpec: string; + envKey?: string; + envDir: string; + stateDirEnvKey: string; + requiresMacOS?: boolean; + manualSetupCommands: string[]; +} + +export const OFFICIAL_CHANNELS: Record = { + telegram: { + id: 'telegram', + displayName: 'Telegram', + pluginSpec: 'plugin:telegram@claude-plugins-official', + envKey: 'TELEGRAM_BOT_TOKEN', + envDir: 'telegram', + stateDirEnvKey: 'TELEGRAM_STATE_DIR', + manualSetupCommands: [ + '/plugin install telegram@claude-plugins-official', + '/telegram:configure ', + '/telegram:access pair ', + '/telegram:access policy allowlist', + ], + }, + discord: { + id: 'discord', + displayName: 'Discord', + pluginSpec: 'plugin:discord@claude-plugins-official', + envKey: 'DISCORD_BOT_TOKEN', + envDir: 'discord', + stateDirEnvKey: 'DISCORD_STATE_DIR', + manualSetupCommands: [ + '/plugin install discord@claude-plugins-official', + '/discord:configure ', + '/discord:access pair ', + '/discord:access policy allowlist', + ], + }, + imessage: { + id: 'imessage', + displayName: 'iMessage', + pluginSpec: 'plugin:imessage@claude-plugins-official', + envDir: 'imessage', + stateDirEnvKey: 'IMESSAGE_STATE_DIR', + requiresMacOS: true, + manualSetupCommands: [ + '/plugin install imessage@claude-plugins-official', + '/imessage:access allow +15551234567', + ], + }, +}; + +export const OFFICIAL_CHANNEL_IDS = Object.keys(OFFICIAL_CHANNELS) as OfficialChannelId[]; +export 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 { + applied: boolean; + wantsPermissionBypass: boolean; + appliedChannels: OfficialChannelId[]; + skippedMessages: string[]; +} + +interface DiscordChannelsLaunchInput { + args: string[]; + config: OfficialChannelsConfig; + target: TargetType; + profileType: ProfileType; + environment: OfficialChannelsEnvironmentStatus; + channelReadiness: Record; +} + +export function isBunAvailable(): boolean { + const result = spawnSync('bun', ['--version'], { stdio: 'ignore' }); + return result.status === 0; +} + +export function isMacOS(): boolean { + return process.platform === 'darwin'; +} + +export function isDiscordChannelsSessionSupported( + target: TargetType, + profileType: ProfileType +): boolean { + return target === 'claude' && (profileType === 'default' || profileType === 'account'); +} + +export function isOfficialChannelId(value: string): value is OfficialChannelId { + return value in OFFICIAL_CHANNELS; +} + +export function normalizeOfficialChannelIds(values: readonly string[]): OfficialChannelId[] { + const seen = new Set(); + const normalized: OfficialChannelId[] = []; + + for (const channelId of OFFICIAL_CHANNEL_IDS) { + if (!values.includes(channelId) || seen.has(channelId)) { + continue; + } + + seen.add(channelId); + normalized.push(channelId); + } + + return normalized; +} + +export function hasExplicitChannelsFlag(args: string[]): boolean { + return args.some((arg) => arg === '--channels' || arg.startsWith('--channels=')); +} + +export function hasExplicitPermissionOverride(args: string[]): boolean { + return args.some( + (arg) => + arg === '--allow-dangerously-skip-permissions' || + arg === '--dangerously-skip-permissions' || + arg === '--permission-mode' || + arg.startsWith('--permission-mode=') + ); +} + +function isTeamOrEnterpriseSubscription(subscriptionType: string | null): boolean { + const normalized = subscriptionType?.trim().toLowerCase() ?? ''; + return normalized.includes('team') || normalized.includes('enterprise'); +} + +export function resolveOfficialChannelsVersionSummary( + version: string | null +): OfficialChannelsVersionSummary { + if (!version) { + return { + current: null, + minimum: MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION, + state: 'unknown', + message: `Unable to detect Claude Code version. Official Channels require v${MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION}+.`, + }; + } + + if (isClaudeCliVersionAtLeast(version, MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION)) { + return { + current: version, + minimum: MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION, + state: 'supported', + message: `Claude Code v${version}`, + }; + } + + return { + current: version, + minimum: MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION, + state: 'unsupported', + message: `Official Channels require Claude Code v${MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION}+ (found v${version}).`, + }; +} + +export function resolveOfficialChannelsAuthSummary( + authStatus: ClaudeAuthStatus | null +): OfficialChannelsAuthSummary { + if (!authStatus) { + return { + checked: false, + loggedIn: false, + authMethod: null, + subscriptionType: null, + state: 'unknown', + eligible: false, + message: 'Unable to verify Claude auth status. Official Channels require claude.ai login.', + }; + } + + if (!authStatus.loggedIn) { + return { + checked: true, + loggedIn: false, + authMethod: authStatus.authMethod ?? null, + subscriptionType: authStatus.subscriptionType ?? null, + state: 'ineligible', + eligible: false, + message: 'Official Channels require claude.ai login. Run `claude auth login` first.', + }; + } + + if (authStatus.authMethod !== 'claude.ai') { + return { + checked: true, + loggedIn: true, + authMethod: authStatus.authMethod ?? null, + subscriptionType: authStatus.subscriptionType ?? null, + state: 'ineligible', + eligible: false, + message: `Official Channels require claude.ai login. Current auth method: ${authStatus.authMethod ?? 'unknown'}.`, + }; + } + + return { + checked: true, + loggedIn: true, + authMethod: authStatus.authMethod, + subscriptionType: authStatus.subscriptionType ?? null, + state: 'eligible', + eligible: true, + message: 'Authenticated with claude.ai.', + ...(isTeamOrEnterpriseSubscription(authStatus.subscriptionType ?? null) + ? { + orgRequirementMessage: + 'Team and Enterprise orgs also need channels enabled by an admin before messages will arrive.', + } + : {}), + }; +} + +export function getOfficialChannelsStateScopeMessage(): string { + return "Telegram and Discord tokens live in Claude's machine-level channel state under ~/.claude/channels/. Native Claude sessions share that state unless you manually override the official *_STATE_DIR variables."; +} + +export function getOfficialChannelsSupportMessage(): string { + return 'Works only for native Claude default/account sessions. It does not apply to API, OAuth, or Droid targets such as `ccs glm`, `ccs gemini`, `ccs codex`, or `ccs --target droid`.'; +} + +export function getOfficialChannelsAccountStatusCaveat(): string { + return 'Dashboard status reflects the base Claude install visible to the current CCS process. Isolated native account sessions can still differ until that account signs in with claude.ai.'; +} + +export function getOfficialChannelsEnvironmentStatus( + authEnvOverrides?: NodeJS.ProcessEnv +): OfficialChannelsEnvironmentStatus { + return { + bunInstalled: isBunAvailable(), + supportedProfiles: getOfficialChannelsSupportedProfiles(), + stateScopeMessage: getOfficialChannelsStateScopeMessage(), + claudeVersion: resolveOfficialChannelsVersionSummary(getClaudeCliVersion()), + auth: resolveOfficialChannelsAuthSummary(getClaudeAuthStatus(authEnvOverrides)), + }; +} + +export function buildOfficialChannelsArgs( + args: string[], + channels: OfficialChannelId[], + includePermissionBypass: boolean +): string[] { + const nextArgs = [ + ...args, + '--channels', + ...channels.map((channel) => OFFICIAL_CHANNELS[channel].pluginSpec), + ]; + + if (includePermissionBypass) { + nextArgs.push('--dangerously-skip-permissions'); + } + + return nextArgs; +} + +export function resolveOfficialChannelsLaunchPlan( + input: DiscordChannelsLaunchInput +): DiscordChannelsLaunchPlan { + const { args, config, target, profileType, environment, channelReadiness } = input; + const skippedMessages: string[] = []; + + if (config.selected.length === 0) { + return { + applied: false, + wantsPermissionBypass: false, + appliedChannels: [], + skippedMessages, + }; + } + + if (!isDiscordChannelsSessionSupported(target, profileType)) { + return { + applied: false, + wantsPermissionBypass: false, + appliedChannels: [], + skippedMessages: [getOfficialChannelsCompatibilityMessage()], + }; + } + + if (hasExplicitChannelsFlag(args)) { + return { + applied: false, + wantsPermissionBypass: false, + appliedChannels: [], + skippedMessages, + }; + } + + if (!environment.bunInstalled) { + return { + applied: false, + wantsPermissionBypass: false, + appliedChannels: [], + skippedMessages: ['Official Channels auto-enable skipped because Bun is not installed.'], + }; + } + + if (environment.claudeVersion.state !== 'supported') { + return { + applied: false, + wantsPermissionBypass: false, + appliedChannels: [], + skippedMessages: [environment.claudeVersion.message], + }; + } + + if (environment.auth.state !== 'eligible') { + return { + applied: false, + wantsPermissionBypass: false, + appliedChannels: [], + skippedMessages: [environment.auth.message], + }; + } + + const appliedChannels: OfficialChannelId[] = []; + + for (const channelId of normalizeOfficialChannelIds(config.selected)) { + const channel = OFFICIAL_CHANNELS[channelId]; + + if (channel.requiresMacOS && !isMacOS()) { + skippedMessages.push(`${channel.displayName} auto-enable skipped because it requires macOS.`); + continue; + } + + if (!channelReadiness[channelId]) { + skippedMessages.push( + channel.envKey + ? `${channel.displayName} auto-enable skipped because ${channel.envKey} is not configured.` + : `${channel.displayName} auto-enable skipped because it is not ready on this machine.` + ); + continue; + } + + appliedChannels.push(channelId); + } + + return { + applied: appliedChannels.length > 0, + wantsPermissionBypass: config.unattended && !hasExplicitPermissionOverride(args), + appliedChannels, + skippedMessages, + }; +} + +export function getOfficialChannelTokenIds(): OfficialChannelId[] { + return OFFICIAL_CHANNEL_IDS.filter((channelId) => Boolean(OFFICIAL_CHANNELS[channelId].envKey)); +} + +export function getOfficialChannelManualSetupCommands(channelId: OfficialChannelId): string[] { + return OFFICIAL_CHANNELS[channelId].manualSetupCommands; +} + +export function getOfficialChannelDisplayName(channelId: OfficialChannelId): string { + return OFFICIAL_CHANNELS[channelId].displayName; +} + +export function getOfficialChannelPluginSpec(channelId: OfficialChannelId): string { + return OFFICIAL_CHANNELS[channelId].pluginSpec; +} + +export function getOfficialChannelEnvKey(channelId: OfficialChannelId): string | undefined { + return OFFICIAL_CHANNELS[channelId].envKey; +} + +export function officialChannelRequiresMacOS(channelId: OfficialChannelId): boolean { + return Boolean(OFFICIAL_CHANNELS[channelId].requiresMacOS); +} + +export function getOfficialChannelEnvDir(channelId: OfficialChannelId): string { + return OFFICIAL_CHANNELS[channelId].envDir; +} + +export function getOfficialChannelStateDirEnvKey(channelId: OfficialChannelId): string { + return OFFICIAL_CHANNELS[channelId].stateDirEnvKey; +} + +export function getOfficialChannelSummary(channelId: OfficialChannelId): string { + if (channelId === 'telegram') { + return 'Bot token required. Runtime-only while Claude is running; Telegram pairing and access policy still happen in Claude.'; + } + if (channelId === 'discord') { + return 'Bot token required. Runtime-only while Claude is running; Discord pairing and access policy still happen in Claude.'; + } + + 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( + channelId: OfficialChannelId +): string | undefined { + if (channelId === 'imessage' && !isMacOS()) { + return 'Requires macOS.'; + } + + return undefined; +} + +export function getOfficialChannelReadyMessage(channelId: OfficialChannelId): string { + if (channelId === 'imessage') { + return isMacOS() + ? 'Needs Claude-side install plus Full Disk Access and the first-reply Automation prompt.' + : 'Unavailable on this platform.'; + } + + const envKey = getOfficialChannelEnvKey(channelId); + return envKey + ? `${envKey} must be configured before CCS can auto-enable this channel. Claude-side pairing and access policy are still required.` + : 'Claude-side setup required.'; +} + +export function buildOfficialChannelSetupSummary( + channel: OfficialChannelsStatusChannelInput +): OfficialChannelSetupSummary { + if (!channel.selected) { + return { + state: 'not_selected', + label: 'Not selected', + detail: 'CCS will not auto-add this channel until you turn it on here.', + nextStep: 'Turn this channel on if you want CCS to add it on supported native Claude runs.', + }; + } + + if (channel.unavailableReason) { + return { + state: 'unavailable', + label: channel.unavailableReason, + detail: `${channel.displayName} is selected, but this machine cannot use it right now.`, + nextStep: 'Turn it off here, or switch to a supported machine before relying on it.', + }; + } + + if (channel.id === 'imessage') { + return { + state: 'needs_claude_setup', + label: 'Claude-side setup remaining', + detail: + 'CCS can add iMessage on the next native Claude run, but plugin install, sender allowlist, Full Disk Access, and the first-reply Automation prompt are still local steps.', + nextStep: 'Complete the one-time Claude and macOS setup below before relying on iMessage.', + }; + } + + const envKey = getOfficialChannelEnvKey(channel.id); + if (channel.requiresToken && !channel.tokenAvailable) { + return { + state: 'needs_token', + label: 'Needs token', + detail: `${envKey} is missing. CCS cannot auto-add ${channel.displayName} until you save it here or provide it in the current CCS process env.`, + nextStep: `Save ${envKey} below, or export it before launching CCS.`, + }; + } + + const sourceDetail = channel.savedInClaudeState + ? `${envKey} is saved in Claude channel state.` + : `${envKey} is available from the current CCS process env.`; + + return { + state: 'ready', + label: channel.savedInClaudeState + ? 'Ready for next native run' + : 'Ready from current CCS process env', + detail: channel.savedInClaudeState + ? `${sourceDetail}${channel.processEnvAvailable ? ` The current CCS process env also provides ${envKey}.` : ''} CCS can auto-add ${channel.displayName} on the next supported native Claude run. Claude-side pairing and access policy still happen in Claude.` + : `${sourceDetail} CCS can auto-add ${channel.displayName} on the next supported native Claude run. Claude-side pairing and access policy still happen in Claude.`, + nextStep: channel.savedInClaudeState + ? 'Run `ccs` or a native Claude account profile. Claude-side pairing and access policy may still be required.' + : 'Run CCS from this same env, or save the token here if you want persistent Claude state.', + }; +} + +export function buildOfficialChannelsReadinessSummary(input: { + config: OfficialChannelsConfig; + environment: OfficialChannelsEnvironmentStatus; + channels: OfficialChannelsStatusChannelInput[]; +}): OfficialChannelsReadinessSummary { + const { config, environment, channels } = input; + + if (config.selected.length === 0) { + return { + state: 'needs_setup', + title: 'No channels selected yet', + message: + 'Choose at least one official channel before CCS can auto-add it on supported native Claude runs.', + nextStep: 'Turn on Telegram, Discord, and/or iMessage below.', + blockers: ['Select at least one channel for auto-enable.'], + }; + } + + const blockers: string[] = []; + if (!environment.bunInstalled) { + blockers.push('Install Bun to use Anthropic official channel plugins.'); + } + if (environment.claudeVersion.state !== 'supported') { + blockers.push(environment.claudeVersion.message); + } + if (environment.auth.state !== 'eligible') { + blockers.push(environment.auth.message); + } + + const selectedChannels = channels.filter((channel) => channel.selected); + const missingTokenChannels = selectedChannels.filter( + (channel) => channel.requiresToken && !channel.tokenAvailable + ); + if (missingTokenChannels.length > 0) { + blockers.push( + `Missing bot token for ${missingTokenChannels.map((channel) => channel.displayName).join(', ')}.` + ); + } + + if (blockers.length > 0) { + return { + state: 'needs_setup', + title: 'Needs setup before CCS can auto-add these channels', + message: blockers[0] ?? 'Official Channels still need setup.', + nextStep: 'Resolve the blockers below, then launch a supported native Claude session again.', + blockers, + }; + } + + const limitedNotes: string[] = []; + const unavailableSelectedChannels = selectedChannels.filter((channel) => + Boolean(channel.unavailableReason) + ); + if (unavailableSelectedChannels.length > 0) { + limitedNotes.push( + `${unavailableSelectedChannels.map((channel) => channel.displayName).join(', ')} cannot run on this machine.` + ); + } + if (selectedChannels.some((channel) => channel.id === 'imessage')) { + limitedNotes.push( + 'iMessage still needs Claude-side install plus local macOS permissions before it is dependable.' + ); + } + + if (limitedNotes.length > 0) { + return { + state: 'limited', + title: 'Selected, but some channels still need manual setup', + message: limitedNotes[0] ?? 'Some selected channels still need additional setup.', + nextStep: 'Review the channel cards below before relying on this from a native Claude run.', + blockers: limitedNotes, + }; + } + + const selectedLabels = selectedChannels.map((channel) => channel.displayName).join(', '); + const envOnlyChannels = selectedChannels.filter( + (channel) => channel.processEnvAvailable && !channel.savedInClaudeState + ); + return { + state: 'ready', + title: 'Ready for the next native Claude run', + message: + envOnlyChannels.length === 0 + ? `CCS can auto-add ${selectedLabels} the next time you run \`ccs\` or a native Claude account profile.` + : envOnlyChannels.length === selectedChannels.length + ? `CCS can auto-add ${selectedLabels} on the next supported native Claude run from this same CCS process env.` + : `CCS can auto-add ${selectedLabels} on the next supported native Claude run. ${envOnlyChannels.map((channel) => channel.displayName).join(', ')} currently depends on this same CCS process env.`, + nextStep: + envOnlyChannels.length === 0 + ? 'Claude-side pairing and access policy may still be required inside Claude, but CCS-side prerequisites are ready.' + : envOnlyChannels.length === selectedChannels.length + ? 'Run CCS from this same env, or save the token here first if you want persistent Claude channel state.' + : 'Save env-only tokens here if you want persistent Claude channel state across shells.', + blockers: [], + }; +} + +export function buildOfficialChannelsLaunchPreview(input: { + config: OfficialChannelsConfig; + environment: OfficialChannelsEnvironmentStatus; + channels: OfficialChannelsStatusChannelInput[]; +}): OfficialChannelsLaunchPreview { + const { config, environment, channels } = input; + + if (config.selected.length === 0) { + return { + state: 'disabled', + title: 'Nothing will be auto-added yet', + detail: 'Turn on at least one channel below before `ccs` can add official channel flags.', + command: 'ccs', + appendedArgs: [], + appliedChannels: [], + permissionBypassIncluded: false, + skippedMessages: [], + }; + } + + const channelReadiness = Object.fromEntries( + channels.map((channel) => [ + channel.id, + !channel.unavailableReason && + (channel.id === 'imessage' || !channel.requiresToken || channel.tokenAvailable), + ]) + ) as Record; + + const plan = resolveOfficialChannelsLaunchPlan({ + args: [], + config, + target: 'claude', + profileType: 'default', + environment, + channelReadiness, + }); + + const appendedArgs = plan.applied + ? buildOfficialChannelsArgs([], plan.appliedChannels, plan.wantsPermissionBypass) + : []; + + if (!plan.applied) { + return { + state: 'blocked', + title: 'Running `ccs` now will not auto-add channels', + detail: + plan.skippedMessages[0] ?? + 'Official Channels are selected, but this machine is not ready to auto-add them yet.', + command: 'ccs', + appendedArgs: [], + appliedChannels: [], + permissionBypassIncluded: false, + skippedMessages: plan.skippedMessages, + }; + } + + const appliedLabels = plan.appliedChannels.map((channelId) => + getOfficialChannelDisplayName(channelId) + ); + + if (plan.skippedMessages.length > 0) { + return { + state: 'partial', + title: `CCS will auto-add ${appliedLabels.join(', ')}`, + detail: + 'Some selected channels are still skipped. Review the notes below before relying on the rest.', + command: 'ccs', + appendedArgs, + appliedChannels: plan.appliedChannels, + permissionBypassIncluded: plan.wantsPermissionBypass, + skippedMessages: plan.skippedMessages, + }; + } + + return { + state: 'ready', + title: `CCS will auto-add ${appliedLabels.join(', ')}`, + detail: plan.wantsPermissionBypass + ? 'Running `ccs` will add the selected official channels and skip permission prompts for that launch.' + : 'Running `ccs` will add the selected official channels automatically on this machine.', + command: 'ccs', + appendedArgs, + appliedChannels: plan.appliedChannels, + permissionBypassIncluded: plan.wantsPermissionBypass, + skippedMessages: [], + }; +} + +export function expandOfficialChannelSelection(selection: string): OfficialChannelId[] { + if (selection.trim().toLowerCase() === 'all') { + return [...OFFICIAL_CHANNEL_IDS]; + } + + return normalizeOfficialChannelIds( + selection + .split(',') + .map((value) => value.trim().toLowerCase()) + .filter(Boolean) + ); +} + +export function getOfficialChannelChoices(): string { + return OFFICIAL_CHANNEL_IDS.join(', '); +} + +export function isOfficialChannelSelectionValid(selection: string): boolean { + const parsed = selection + .split(',') + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + + return ( + parsed.length > 0 && parsed.every((value) => value === 'all' || isOfficialChannelId(value)) + ); +} + +export function resolveLegacyDiscordSelection(enabled: boolean | undefined): OfficialChannelId[] { + return enabled ? ['discord'] : []; +} + +export function getOfficialChannelsSupportedProfiles(): string[] { + return ['default', 'account']; +} + +export function getChannelConfigSelectionLabel(selected: OfficialChannelId[]): string { + if (selected.length === 0) { + return 'None'; + } + + return selected.map((channelId) => getOfficialChannelDisplayName(channelId)).join(', '); +} + +export function getTokenValueLabel(channelId: OfficialChannelId): string { + return getOfficialChannelEnvKey(channelId) ?? ''; +} + +export function isOfficialChannelTokenRequired(channelId: OfficialChannelId): boolean { + return Boolean(getOfficialChannelEnvKey(channelId)); +} + +export function getOfficialChannelDefaultTokenPlaceholder(channelId: OfficialChannelId): string { + const envKey = getOfficialChannelEnvKey(channelId); + return envKey ? `Paste ${envKey}` : ''; +} + +export function getOfficialChannelConfiguredPlaceholder(channelId: OfficialChannelId): string { + const envKey = getOfficialChannelEnvKey(channelId); + return envKey ? `Configured. Enter a new ${envKey} to replace it.` : ''; +} + +export function getOfficialChannelsSectionDescription(): string { + return 'Auto-enable Anthropic official channels for compatible Claude sessions. CCS only stores selection in config.yaml; Claude keeps machine-level channel state under ~/.claude/channels/.'; +} + +export function getOfficialChannelsRuntimeNote(): string { + return 'CCS does not persist a global Claude channels default. It only injects runtime flags for the current Claude session when prerequisites are met.'; +} + +export function getOfficialChannelsSetHelp(): string { + return `Set selected channels with --set . Supported values: ${getOfficialChannelChoices()}, or all.`; +} + +export function getOfficialChannelsLegacyEnableHelp(): string { + return 'Legacy aliases: --enable adds Discord, --disable removes Discord.'; +} + +export function getOfficialChannelTokenHelp(): string { + return 'Use --set-token =. If no channel is provided, Discord is assumed for backward compatibility.'; +} + +export function getOfficialChannelClearTokenHelp(): string { + return 'Use --clear-token to clear all saved bot tokens, or --clear-token to clear one token.'; +} + +export function getOfficialChannelMacOSHelp(): string { + return 'iMessage needs macOS Full Disk Access plus the Messages automation prompt on first reply.'; +} + +export function getOfficialChannelsDocsSummary(): string { + return 'Supported official channels are Telegram, Discord, and iMessage.'; +} + +export function getOfficialChannelSyncFailureMessage( + channelId: OfficialChannelId, + targetPath: string +): string { + return `${getOfficialChannelDisplayName(channelId)} auto-enable skipped: failed to sync channel env to ${targetPath}`; +} + +export function getOfficialChannelSyncSkipReason(channelId: OfficialChannelId): string { + return `${getOfficialChannelDisplayName(channelId)} auto-enable skipped.`; +} + +export function getOfficialChannelsExplicitOverrideMessage(): string | undefined { + return undefined; +} + +export function getOfficialChannelTokenMissingMessage(channelId: OfficialChannelId): string { + const envKey = getOfficialChannelEnvKey(channelId); + return envKey + ? `${getOfficialChannelDisplayName(channelId)} auto-enable skipped because ${envKey} is not configured.` + : `${getOfficialChannelDisplayName(channelId)} auto-enable skipped because it is not ready.`; +} + +export function getOfficialChannelsBunMissingMessage(): string { + return 'Official Channels auto-enable skipped because Bun is not installed.'; +} + +export function getOfficialChannelsCompatibilityMessage(): string { + return 'Official Channels auto-enable only 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 { + return 'No official channels selected.'; +} + +export function getOfficialChannelsPermissionBypassMessage(): string { + return '--dangerously-skip-permissions'; +} + +export function getOfficialChannelsSelectionSummary(selected: OfficialChannelId[]): string[] { + return selected.map((channelId) => getOfficialChannelDisplayName(channelId)); +} diff --git a/src/channels/official-channels-store.ts b/src/channels/official-channels-store.ts new file mode 100644 index 00000000..00624d28 --- /dev/null +++ b/src/channels/official-channels-store.ts @@ -0,0 +1,324 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../utils/config-manager'; +import { getDefaultClaudeConfigDir } from '../utils/claude-config-path'; +import type { OfficialChannelId } from '../config/unified-config-types'; +import { + getOfficialChannelEnvDir, + getOfficialChannelEnvKey, + getOfficialChannelStateDirEnvKey, + getOfficialChannelTokenIds, + isOfficialChannelTokenRequired, +} from './official-channels-runtime'; + +export type OfficialChannelTokenSource = 'saved_env' | 'process_env' | 'missing'; + +export interface OfficialChannelTokenStatus { + available: boolean; + source: OfficialChannelTokenSource; + envKey?: string; + tokenPath?: string; + savedInClaudeState: boolean; + processEnvAvailable: boolean; +} + +function getResolvedStateDirOverride( + channelId: OfficialChannelId, + envOverrides?: NodeJS.ProcessEnv | null +): string | null { + const env = envOverrides === undefined ? process.env : envOverrides; + const rawStateDir = env?.[getOfficialChannelStateDirEnvKey(channelId)]?.trim(); + + return rawStateDir ? path.resolve(rawStateDir) : null; +} + +export function getOfficialChannelEnvPath( + channelId: OfficialChannelId, + configDir = getDefaultClaudeConfigDir(), + envOverrides?: NodeJS.ProcessEnv | null +): string { + 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 { + return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : null; +} + +function parseEnvValue(rawValue: string): string { + const value = rawValue.trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1).trim(); + } + return value; +} + +function formatEnvValue(value: string): string { + return /^[A-Za-z0-9._:-]+$/.test(value) ? value : JSON.stringify(value); +} + +function upsertEnvValue(content: string, key: string, value: string): string { + const lines = content.length > 0 ? content.split(/\r?\n/) : []; + const nextLines: string[] = []; + let replaced = false; + + for (const line of lines) { + if (/^\s*$/.test(line) && nextLines.length === 0) { + continue; + } + if (new RegExp(`^\\s*${key}\\s*=`).test(line)) { + nextLines.push(`${key}=${formatEnvValue(value)}`); + replaced = true; + continue; + } + nextLines.push(line); + } + + if (!replaced) { + if (nextLines.length > 0 && nextLines[nextLines.length - 1] !== '') { + nextLines.push(''); + } + nextLines.push(`${key}=${formatEnvValue(value)}`); + } + + return `${nextLines.join('\n').replace(/\n+$/u, '')}\n`; +} + +function removeEnvValue(content: string, key: string): string { + const nextLines = content + .split(/\r?\n/) + .filter((line) => !new RegExp(`^\\s*${key}\\s*=`).test(line)); + + while (nextLines.length > 0 && /^\s*$/.test(nextLines[0] ?? '')) { + nextLines.shift(); + } + while (nextLines.length > 0 && /^\s*$/.test(nextLines[nextLines.length - 1] ?? '')) { + nextLines.pop(); + } + + return nextLines.length > 0 ? `${nextLines.join('\n')}\n` : ''; +} + +function writeSecureFile(filePath: string, content: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); + const tempPath = `${filePath}.tmp`; + fs.writeFileSync(tempPath, content, { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(tempPath, filePath); + fs.chmodSync(filePath, 0o600); +} + +function clearOfficialChannelTokenAtPath(channelId: OfficialChannelId, filePath: string): boolean { + const envKey = getOfficialChannelEnvKey(channelId); + if (!envKey) { + return false; + } + + const currentContent = readFileIfExists(filePath); + + if (currentContent === null) { + return false; + } + + const nextContent = removeEnvValue(currentContent, envKey); + if (nextContent.length === 0) { + fs.rmSync(filePath, { force: true }); + return true; + } + + writeSecureFile(filePath, nextContent); + return true; +} + +function listManagedClaudeConfigDirs(): string[] { + const dirs = new Set([getDefaultClaudeConfigDir()]); + const processConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim(); + + if (processConfigDir) { + dirs.add(path.resolve(processConfigDir)); + } + + const instancesDir = path.join(getCcsDir(), 'instances'); + if (!fs.existsSync(instancesDir)) { + return [...dirs]; + } + + for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + dirs.add(path.join(instancesDir, entry.name)); + } + } + + return [...dirs]; +} + +function listManagedOfficialChannelEnvPaths(channelId: OfficialChannelId): string[] { + const envPaths = new Set([ + getOfficialChannelEnvPath(channelId, getDefaultClaudeConfigDir(), null), + getOfficialChannelEnvPath(channelId), + ]); + + for (const configDir of listManagedClaudeConfigDirs()) { + envPaths.add(getOfficialChannelEnvPath(channelId, configDir, null)); + } + + return [...envPaths]; +} + +export function normalizeDiscordBotToken(value: string): string | null { + const normalized = value.trim(); + if (!normalized || /[\r\n]/.test(normalized)) { + return null; + } + return normalized; +} + +export function readOfficialChannelTokenFromEnvContent( + channelId: OfficialChannelId, + content: string +): string | null { + const envKey = getOfficialChannelEnvKey(channelId); + if (!envKey) { + return null; + } + + for (const line of content.split(/\r?\n/)) { + const match = line.match(new RegExp(`^\\s*${envKey}\\s*=\\s*(.*)\\s*$`)); + if (!match) { + continue; + } + const parsed = parseEnvValue(match[1] ?? ''); + return parsed.length > 0 ? parsed : null; + } + + return null; +} + +export function readConfiguredOfficialChannelToken(channelId: OfficialChannelId): string | null { + const content = readFileIfExists(getOfficialChannelEnvPath(channelId)); + return content ? readOfficialChannelTokenFromEnvContent(channelId, content) : null; +} + +export function readOfficialChannelTokenFromProcessEnv( + channelId: OfficialChannelId, + envOverrides?: NodeJS.ProcessEnv | null +): string | null { + const envKey = getOfficialChannelEnvKey(channelId); + if (!envKey) { + return null; + } + + const rawValue = (envOverrides === undefined ? process.env : envOverrides)?.[envKey]; + if (typeof rawValue !== 'string') { + return null; + } + + return normalizeDiscordBotToken(rawValue); +} + +export function hasConfiguredOfficialChannelToken(channelId: OfficialChannelId): boolean { + return readConfiguredOfficialChannelToken(channelId) !== null; +} + +export function getOfficialChannelTokenStatus( + channelId: OfficialChannelId, + envOverrides?: NodeJS.ProcessEnv | null +): OfficialChannelTokenStatus { + const envKey = getOfficialChannelEnvKey(channelId); + if (!envKey) { + return { + available: true, + source: 'saved_env', + savedInClaudeState: true, + processEnvAvailable: false, + }; + } + + const processEnvToken = readOfficialChannelTokenFromProcessEnv(channelId, envOverrides); + const tokenPath = getOfficialChannelEnvPath(channelId); + const savedToken = readConfiguredOfficialChannelToken(channelId); + + if (savedToken !== null) { + return { + available: true, + source: 'saved_env', + envKey, + tokenPath, + savedInClaudeState: true, + processEnvAvailable: processEnvToken !== null, + }; + } + + if (processEnvToken !== null) { + return { + available: true, + source: 'process_env', + envKey, + savedInClaudeState: false, + processEnvAvailable: true, + }; + } + + return { + available: false, + source: 'missing', + envKey, + tokenPath, + savedInClaudeState: false, + processEnvAvailable: false, + }; +} + +export function getOfficialChannelReadiness(channelId: OfficialChannelId): boolean { + return isOfficialChannelTokenRequired(channelId) + ? getOfficialChannelTokenStatus(channelId).available + : true; +} + +export function setConfiguredOfficialChannelToken( + channelId: OfficialChannelId, + token: string +): string { + const envKey = getOfficialChannelEnvKey(channelId); + if (!envKey) { + throw new Error(`${channelId} does not use a bot token.`); + } + + const normalized = normalizeDiscordBotToken(token); + if (!normalized) { + throw new Error(`${envKey} cannot be empty or multiline.`); + } + + const envPath = getOfficialChannelEnvPath(channelId); + const currentContent = readFileIfExists(envPath) ?? ''; + writeSecureFile(envPath, upsertEnvValue(currentContent, envKey, normalized)); + return envPath; +} + +export function clearConfiguredOfficialChannelToken(channelId: OfficialChannelId): string { + const envPath = getOfficialChannelEnvPath(channelId); + clearOfficialChannelTokenAtPath(channelId, envPath); + return envPath; +} + +export function clearConfiguredOfficialChannelTokensEverywhere( + channelId?: OfficialChannelId +): string[] { + const clearedPaths: string[] = []; + const channels = channelId ? [channelId] : getOfficialChannelTokenIds(); + + for (const tokenChannelId of channels) { + for (const envPath of listManagedOfficialChannelEnvPaths(tokenChannelId)) { + if (clearOfficialChannelTokenAtPath(tokenChannelId, envPath)) { + clearedPaths.push(envPath); + } + } + } + + return clearedPaths; +} diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index f74d9257..194faac7 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -158,41 +158,61 @@ export function getInstalledCliproxyVersion(backend?: CLIProxyBackend): string { ); } +interface InstallCliproxyVersionDeps { + createManager?: ( + config: Partial, + backend: CLIProxyBackend + ) => Pick; + stopProxyFn?: typeof stopProxy; + waitForPortFreeFn?: typeof waitForPortFree; + formatInfo?: typeof info; + formatWarn?: typeof warn; + getInstalledVersion?: typeof getInstalledCliproxyVersion; +} + /** Install a specific version of CLIProxyAPI */ export async function installCliproxyVersion( version: string, verbose = false, - backend?: CLIProxyBackend + backend?: CLIProxyBackend, + deps: InstallCliproxyVersionDeps = {} ): Promise { const effectiveBackend = backend ?? getConfiguredBackend(); - const manager = new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend); + const manager = + deps.createManager?.({ version, verbose, forceVersion: true }, effectiveBackend) ?? + new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend); + const stopProxyFn = deps.stopProxyFn ?? stopProxy; + const waitForPortFreeFn = deps.waitForPortFreeFn ?? waitForPortFree; + const formatInfo = deps.formatInfo ?? info; + const formatWarn = deps.formatWarn ?? warn; + const getInstalledVersion = deps.getInstalledVersion ?? getInstalledCliproxyVersion; // Always attempt a best-effort stop first so we also catch untracked proxies // that are running without a session lock. - if (verbose) console.log(info('Stopping running CLIProxy before update...')); - const result = await stopProxy(); + if (verbose) console.log(formatInfo('Stopping running CLIProxy before update...')); + const result = await stopProxyFn(); if (result.stopped) { // Wait for port to be fully released - const portFree = await waitForPortFree(CLIPROXY_DEFAULT_PORT, 5000); + const portFree = await waitForPortFreeFn(CLIPROXY_DEFAULT_PORT, 5000); if (!portFree && verbose) { - console.log(warn('Port did not free up in time, proceeding anyway...')); + console.log(formatWarn('Port did not free up in time, proceeding anyway...')); } } else if (verbose && result.error && result.error !== 'No active CLIProxy session found') { - console.log(warn(`Could not stop proxy: ${result.error}`)); + console.log(formatWarn(`Could not stop proxy: ${result.error}`)); } if (manager.isBinaryInstalled()) { const label = effectiveBackend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; if (verbose) console.log( - info(`Removing existing ${label} v${getInstalledCliproxyVersion(effectiveBackend)}`) + formatInfo(`Removing existing ${label} v${getInstalledVersion(effectiveBackend)}`) ); manager.deleteBinary(); } await manager.ensureBinary(); if (verbose) { - console.log(info('New version will be active on next CLIProxy command')); + console.log(formatInfo('New version will be active on next CLIProxy command')); } } diff --git a/src/cliproxy/codex-plan-compatibility.ts b/src/cliproxy/codex-plan-compatibility.ts index 459d31a4..ab524926 100644 --- a/src/cliproxy/codex-plan-compatibility.ts +++ b/src/cliproxy/codex-plan-compatibility.ts @@ -37,6 +37,13 @@ export interface CodexUnsupportedModelError { type: string | null; } +interface CodexPlanCompatibilityDeps { + getDefaultAccount?: typeof getDefaultAccount; + fetchCodexQuota?: typeof fetchCodexQuota; + formatInfo?: typeof info; + formatWarn?: typeof warn; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -131,21 +138,29 @@ export function resolveRuntimeCodexFallbackModel(options: { return null; } -export async function reconcileCodexModelForActivePlan(options: { - settingsPath: string; - currentModel: string | undefined; - verbose: boolean; -}): Promise { +export async function reconcileCodexModelForActivePlan( + options: { + settingsPath: string; + currentModel: string | undefined; + verbose: boolean; + }, + deps: CodexPlanCompatibilityDeps = {} +): Promise { const { settingsPath, currentModel, verbose } = options; if (!currentModel) return; const fallbackModel = getFreePlanFallbackCodexModel(currentModel); if (!fallbackModel) return; - const defaultAccount = getDefaultAccount('codex'); + const resolveDefaultAccount = deps.getDefaultAccount ?? getDefaultAccount; + const fetchQuota = deps.fetchCodexQuota ?? fetchCodexQuota; + const formatInfo = deps.formatInfo ?? info; + const formatWarn = deps.formatWarn ?? warn; + + const defaultAccount = resolveDefaultAccount('codex'); if (!defaultAccount) { console.error( - warn( + formatWarn( `Configured Codex model "${normalizeCodexModelId(currentModel)}" may require a paid Codex plan. ` + `If startup fails, switch to "${fallbackModel}" with "ccs codex --config".` ) @@ -154,7 +169,7 @@ export async function reconcileCodexModelForActivePlan(options: { } const cachedQuota = getCachedQuota('codex', defaultAccount.id); - const quota = cachedQuota ?? (await fetchCodexQuota(defaultAccount.id, verbose)); + const quota = cachedQuota ?? (await fetchQuota(defaultAccount.id, verbose)); if (!cachedQuota) { setCachedQuota('codex', defaultAccount.id, quota); } @@ -164,7 +179,7 @@ export async function reconcileCodexModelForActivePlan(options: { rewriteHaikuModel: (haikuModel) => getFreePlanFallbackCodexModel(haikuModel) ?? haikuModel, }); console.error( - info( + formatInfo( `Codex free plan detected. Switched unsupported model "${normalizeCodexModelId(currentModel)}" ` + `to "${fallbackModel}".` ) @@ -177,7 +192,7 @@ export async function reconcileCodexModelForActivePlan(options: { } console.error( - warn( + formatWarn( `Could not verify Codex plan for model "${normalizeCodexModelId(currentModel)}". ` + `If startup fails with model_not_supported, switch to "${fallbackModel}" via "ccs codex --config".` ) diff --git a/src/commands/api-command/export-command.ts b/src/commands/api-command/export-command.ts index 8ce79789..246c7854 100644 --- a/src/commands/api-command/export-command.ts +++ b/src/commands/api-command/export-command.ts @@ -5,8 +5,29 @@ import { fail, initUI, ok, warn } from '../../utils/ui'; import { extractOption, hasAnyFlag } from '../arg-extractor'; import { collectUnexpectedApiArgs } from './shared'; -export async function handleApiExportCommand(args: string[]): Promise { - await initUI(); +interface ApiExportCommandDependencies { + exportApiProfile: typeof exportApiProfile; + initUI: typeof initUI; + ok: typeof ok; + warn: typeof warn; + fail: typeof fail; + getCwd: () => string; +} + +const defaultApiExportCommandDependencies: ApiExportCommandDependencies = { + exportApiProfile, + initUI, + ok, + warn, + fail, + getCwd: () => process.cwd(), +}; + +export async function handleApiExportCommand( + args: string[], + deps: ApiExportCommandDependencies = defaultApiExportCommandDependencies +): Promise { + await deps.initUI(); const includeSecrets = hasAnyFlag(args, ['--include-secrets']); const outExtracted = extractOption(args, ['--out'], { @@ -15,7 +36,7 @@ export async function handleApiExportCommand(args: string[]): Promise { knownFlags: ['--out', '--include-secrets'], }); if (outExtracted.found && (outExtracted.missingValue || !outExtracted.value)) { - console.log(fail('Missing value for --out')); + console.log(deps.fail('Missing value for --out')); process.exit(1); } @@ -24,29 +45,29 @@ export async function handleApiExportCommand(args: string[]): Promise { maxPositionals: 1, }); if (syntax.errors.length > 0) { - syntax.errors.forEach((errorMessage) => console.log(fail(errorMessage))); + syntax.errors.forEach((errorMessage) => console.log(deps.fail(errorMessage))); process.exit(1); } const name = syntax.positionals[0]; if (!name) { - console.log(fail('Profile name is required. Usage: ccs api export [--out ]')); + console.log(deps.fail('Profile name is required. Usage: ccs api export [--out ]')); process.exit(1); } - const result = exportApiProfile(name, includeSecrets); + const result = deps.exportApiProfile(name, includeSecrets); if (!result.success || !result.bundle) { - console.log(fail(result.error || 'Failed to export profile')); + console.log(deps.fail(result.error || 'Failed to export profile')); process.exit(1); } - const outputPath = path.resolve(outExtracted.value || `${name}.ccs-profile.json`); + const outputPath = path.resolve(deps.getCwd(), outExtracted.value || `${name}.ccs-profile.json`); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, JSON.stringify(result.bundle, null, 2) + '\n', 'utf8'); - console.log(ok(`Profile exported to: ${outputPath}`)); + console.log(deps.ok(`Profile exported to: ${outputPath}`)); if (result.redacted) { - console.log(warn('Token was redacted in export. Use --include-secrets to include it.')); + console.log(deps.warn('Token was redacted in export. Use --include-secrets to include it.')); } console.log(''); } diff --git a/src/commands/cliproxy/resolve-lifecycle-port.ts b/src/commands/cliproxy/resolve-lifecycle-port.ts index 85e0b33c..e250d5e6 100644 --- a/src/commands/cliproxy/resolve-lifecycle-port.ts +++ b/src/commands/cliproxy/resolve-lifecycle-port.ts @@ -1,11 +1,15 @@ import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import type { UnifiedConfig } from '../../config/unified-config-types'; + +type LifecyclePortConfig = Pick; /** * Resolve the local CLIProxy lifecycle port from unified config. * Falls back to default port when unset/invalid. */ -export function resolveLifecyclePort(): number { - const config = loadOrCreateUnifiedConfig(); +export function resolveLifecyclePort( + config: LifecyclePortConfig = loadOrCreateUnifiedConfig() +): number { return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT); } diff --git a/src/commands/config-channels-command.ts b/src/commands/config-channels-command.ts new file mode 100644 index 00000000..6ccf49d9 --- /dev/null +++ b/src/commands/config-channels-command.ts @@ -0,0 +1,481 @@ +import { initUI, header, ok, info, warn, fail, subheader, color, dim } from '../utils/ui'; +import { + getOfficialChannelsConfig, + loadOrCreateUnifiedConfig, + updateUnifiedConfig, +} from '../config/unified-config-loader'; +import type { OfficialChannelId } from '../config/unified-config-types'; +import { DEFAULT_OFFICIAL_CHANNELS_CONFIG } from '../config/unified-config-types'; +import { + clearConfiguredOfficialChannelTokensEverywhere, + getOfficialChannelTokenStatus, + hasConfiguredOfficialChannelToken, + setConfiguredOfficialChannelToken, +} from '../channels/official-channels-store'; +import { + buildOfficialChannelsLaunchPreview, + buildOfficialChannelsReadinessSummary, + buildOfficialChannelSetupSummary, + expandOfficialChannelSelection, + getChannelConfigSelectionLabel, + getOfficialChannelChoices, + getOfficialChannelsAccountStatusCaveat, + getOfficialChannelsSupportMessage, + getOfficialChannelDisplayName, + getOfficialChannelEnvKey, + getOfficialChannelManualSetupCommands, + getOfficialChannelsCompatibilityMessage, + getOfficialChannelsDocsSummary, + getOfficialChannelsLegacyEnableHelp, + getOfficialChannelsSetHelp, + getOfficialChannelTokenHelp, + getOfficialChannelClearTokenHelp, + getOfficialChannelMacOSHelp, + getOfficialChannelSummary, + getOfficialChannelsEnvironmentStatus, + getOfficialChannelsRuntimeNote, + getOfficialChannelsSectionDescription, + getOfficialChannelsSupportedProfiles, + getOfficialChannelUnavailableReason, + getOfficialChannelTokenIds, + isOfficialChannelId, + isOfficialChannelSelectionValid, +} from '../channels/official-channels-runtime'; +import { extractOption, hasAnyFlag } from './arg-extractor'; + +interface ChannelsCommandOptions { + enable: boolean; + disable: boolean; + clear: boolean; + unattended: boolean; + noUnattended: boolean; + setSelection?: string; + setSelectionMissing: boolean; + clearTokenAll: boolean; + clearTokenChannel?: OfficialChannelId; + setToken?: { channelId: OfficialChannelId; token: string }; + setTokenMissing: boolean; + clearTokenInvalid?: string; + setTokenInvalid?: string; + help: boolean; +} + +function parseTokenAssignment(value: string): { + channelId: OfficialChannelId; + token: string; +} | null { + const separatorIndex = value.indexOf('='); + if (separatorIndex === -1) { + return value.trim() ? { channelId: 'discord', token: value.trim() } : null; + } + + const channelId = value.slice(0, separatorIndex).trim().toLowerCase(); + const token = value.slice(separatorIndex + 1).trim(); + if (!isOfficialChannelId(channelId) || !token) { + return null; + } + + return { channelId, token }; +} + +export function parseChannelsCommandArgs(args: string[]): ChannelsCommandOptions { + const setSelection = extractOption(args, ['--set']); + const setToken = extractOption(args, ['--set-token']); + const clearToken = extractOption(args, ['--clear-token']); + + let clearTokenAll = false; + let clearTokenChannel: OfficialChannelId | undefined; + let clearTokenInvalid: string | undefined; + if (clearToken.found) { + if (clearToken.missingValue) { + clearTokenAll = true; + } else if (clearToken.value) { + const channelId = clearToken.value.trim().toLowerCase(); + if (isOfficialChannelId(channelId)) { + clearTokenChannel = channelId; + } else { + clearTokenInvalid = clearToken.value; + } + } + } + + let parsedSetToken: { channelId: OfficialChannelId; token: string } | undefined; + let setTokenInvalid: string | undefined; + if (setToken.found && !setToken.missingValue && setToken.value) { + parsedSetToken = parseTokenAssignment(setToken.value) ?? undefined; + if (!parsedSetToken) { + setTokenInvalid = setToken.value; + } + } + + return { + enable: hasAnyFlag(args, ['--enable']), + disable: hasAnyFlag(args, ['--disable']), + clear: hasAnyFlag(args, ['--clear']), + unattended: hasAnyFlag(args, ['--unattended']), + noUnattended: hasAnyFlag(args, ['--no-unattended']), + setSelection: setSelection.found ? setSelection.value : undefined, + setSelectionMissing: setSelection.found && setSelection.missingValue, + clearTokenAll, + clearTokenChannel, + clearTokenInvalid, + setToken: parsedSetToken, + setTokenMissing: setToken.found && setToken.missingValue, + setTokenInvalid, + help: hasAnyFlag(args, ['--help', '-h']), + }; +} + +function showHelp(): void { + console.log(''); + console.log(header('ccs config channels')); + console.log(''); + console.log(` ${getOfficialChannelsSectionDescription()}`); + console.log(` ${dim(getOfficialChannelsDocsSummary())}`); + console.log( + ` ${dim('Fastest path: run `ccs config`, open Settings -> Channels, turn on the channel, save the token if needed, then run `ccs`.')}` + ); + console.log(''); + console.log(subheader('Usage:')); + console.log(` ${color('ccs config channels', 'command')} [options]`); + console.log(''); + console.log(subheader('Options:')); + console.log(` ${color('--set ', 'command')} ${getOfficialChannelsSetHelp()}`); + console.log(` ${color('--clear', 'command')} Clear all selected channels`); + console.log( + ` ${color('--enable', 'command')} Legacy compatibility alias: add Discord` + ); + console.log( + ` ${color('--disable', 'command')} Legacy compatibility alias: remove Discord` + ); + console.log( + ` ${color('--unattended', 'command')} Also add --dangerously-skip-permissions` + ); + console.log(` ${color('--no-unattended', 'command')} Disable unattended runtime flag`); + console.log(` ${color('--set-token ', 'command')} ${getOfficialChannelTokenHelp()}`); + console.log( + ` ${color('--clear-token [channel]', 'command')} ${getOfficialChannelClearTokenHelp()}` + ); + console.log(` ${color('--help, -h', 'command')} Show this help`); + console.log(''); + console.log(subheader('Examples:')); + console.log( + ` $ ${color('ccs config', 'command')} ${dim('# Dashboard -> Settings -> Channels (fastest path)')}` + ); + console.log( + ` $ ${color('ccs config channels', 'command')} ${dim('# Show status')}` + ); + console.log( + ` $ ${color('ccs config channels --set telegram,discord', 'command')} ${dim('# Enable Telegram + Discord')}` + ); + console.log( + ` $ ${color('ccs config channels --set all', 'command')} ${dim('# Enable all official channels')}` + ); + console.log( + ` $ ${color('ccs config channels --set-token telegram=123:abc', 'command')} ${dim('# Save TELEGRAM_BOT_TOKEN')}` + ); + console.log( + ` $ ${color('ccs config channels --clear-token discord', 'command')} ${dim('# Clear one token')}` + ); + console.log( + ` ${dim('Official Channels only work on native Claude default/account sessions, not on ccs glm or other API/OAuth/Droid targets.')}` + ); + console.log(''); +} + +function showStatus(): void { + const config = getOfficialChannelsConfig(); + const selected = config.selected; + const environment = getOfficialChannelsEnvironmentStatus(); + const channelRows = expandOfficialChannelSelection('all').map((channelId) => { + const selectedForLaunch = selected.includes(channelId); + const tokenStatus = getOfficialChannelTokenStatus(channelId); + + return { + id: channelId, + displayName: getOfficialChannelDisplayName(channelId), + selected: selectedForLaunch, + requiresToken: getOfficialChannelTokenIds().includes(channelId), + tokenConfigured: hasConfiguredOfficialChannelToken(channelId), + tokenStatus, + unavailableReason: getOfficialChannelUnavailableReason(channelId), + setup: buildOfficialChannelSetupSummary({ + id: channelId, + displayName: getOfficialChannelDisplayName(channelId), + selected: selectedForLaunch, + requiresToken: getOfficialChannelTokenIds().includes(channelId), + tokenAvailable: tokenStatus.available, + tokenSource: tokenStatus.source, + savedInClaudeState: tokenStatus.savedInClaudeState, + processEnvAvailable: tokenStatus.processEnvAvailable, + unavailableReason: getOfficialChannelUnavailableReason(channelId), + }), + }; + }); + const summary = buildOfficialChannelsReadinessSummary({ + config, + environment, + channels: channelRows.map((channel) => ({ + id: channel.id, + displayName: channel.displayName, + selected: channel.selected, + requiresToken: channel.requiresToken, + tokenAvailable: channel.tokenStatus.available, + tokenSource: channel.tokenStatus.source, + savedInClaudeState: channel.tokenStatus.savedInClaudeState, + processEnvAvailable: channel.tokenStatus.processEnvAvailable, + unavailableReason: channel.unavailableReason, + })), + }); + const launchPreview = buildOfficialChannelsLaunchPreview({ + config, + environment, + channels: channelRows.map((channel) => ({ + id: channel.id, + displayName: channel.displayName, + selected: channel.selected, + requiresToken: channel.requiresToken, + tokenAvailable: channel.tokenStatus.available, + tokenSource: channel.tokenStatus.source, + savedInClaudeState: channel.tokenStatus.savedInClaudeState, + processEnvAvailable: channel.tokenStatus.processEnvAvailable, + unavailableReason: channel.unavailableReason, + })), + }); + + console.log(''); + console.log(header('Official Channels Configuration')); + console.log(''); + console.log( + ` Status: ${ + summary.state === 'ready' + ? ok(summary.title) + : summary.state === 'limited' + ? warn(summary.title) + : warn(summary.title) + }` + ); + console.log(` ${dim(summary.message)}`); + console.log(` ${dim(summary.nextStep)}`); + console.log(''); + console.log(` Launch: ${info(launchPreview.title)}`); + console.log(` ${dim(launchPreview.detail)}`); + if (launchPreview.appendedArgs.length > 0) { + console.log(` ${dim(`ccs adds: ${launchPreview.appendedArgs.join(' ')}`)}`); + } + if (launchPreview.skippedMessages.length > 0) { + console.log(` ${dim(`Skipped: ${launchPreview.skippedMessages.join(' | ')}`)}`); + } + console.log(''); + console.log( + ` Channels: ${selected.length > 0 ? ok(getChannelConfigSelectionLabel(selected)) : warn('Disabled')}` + ); + console.log(` Unattended: ${config.unattended ? warn('Enabled') : info('Disabled')}`); + console.log(` Bun: ${environment.bunInstalled ? ok('Installed') : warn('Missing')}`); + console.log( + ` Claude Code: ${ + environment.claudeVersion.state === 'supported' + ? ok(environment.claudeVersion.message) + : environment.claudeVersion.state === 'unsupported' + ? warn(environment.claudeVersion.message) + : info(environment.claudeVersion.message) + }` + ); + console.log( + ` Claude Auth: ${ + environment.auth.state === 'eligible' + ? ok(environment.auth.message) + : environment.auth.state === 'ineligible' + ? warn(environment.auth.message) + : info(environment.auth.message) + }` + ); + console.log(''); + console.log(subheader('Applies To:')); + console.log(` ${dim(getOfficialChannelsSupportMessage())}`); + console.log( + ` ${dim(`Supported profiles: ${getOfficialChannelsSupportedProfiles().join(', ')}`)}` + ); + console.log(` ${dim(environment.stateScopeMessage)}`); + console.log(` ${dim(getOfficialChannelsAccountStatusCaveat())}`); + if (environment.auth.orgRequirementMessage) { + console.log(` ${dim(environment.auth.orgRequirementMessage)}`); + } + console.log(''); + console.log(subheader('Channels:')); + for (const channel of channelRows) { + const status = + channel.setup.state === 'ready' + ? ok(channel.setup.label) + : channel.setup.state === 'not_selected' + ? info(channel.setup.label) + : warn(channel.setup.label); + console.log(` ${channel.selected ? '[x]' : '[ ]'} ${channel.displayName}: ${status}`); + console.log(` ${dim(getOfficialChannelSummary(channel.id))}`); + console.log(` ${dim(channel.setup.detail)}`); + console.log(` ${dim(channel.setup.nextStep)}`); + if (channel.requiresToken) { + const envKey = getOfficialChannelEnvKey(channel.id) ?? ''; + if (channel.tokenStatus.source === 'saved_env') { + console.log(` ${dim(`${envKey}: saved in Claude channel state`)}`); + if (channel.tokenStatus.processEnvAvailable) { + console.log(` ${dim(`${envKey}: also available from current CCS process env`)}`); + } + if (channel.tokenStatus.tokenPath) { + console.log(` ${dim(channel.tokenStatus.tokenPath)}`); + } + } else if (channel.tokenStatus.source === 'process_env') { + console.log(` ${dim(`${envKey}: available from current CCS process env`)}`); + } else { + console.log(` ${dim(`${envKey}: missing`)}`); + if (channel.tokenStatus.tokenPath) { + console.log(` ${dim(channel.tokenStatus.tokenPath)}`); + } + } + } + } + console.log(''); + console.log(subheader('Notes:')); + console.log(` ${dim(getOfficialChannelsLegacyEnableHelp())}`); + console.log(` ${dim(environment.stateScopeMessage)}`); + console.log(` ${dim(getOfficialChannelMacOSHelp())}`); + console.log(` ${dim(getOfficialChannelsRuntimeNote())}`); + console.log(` ${dim(getOfficialChannelsCompatibilityMessage())}`); + console.log(` ${dim(getOfficialChannelsAccountStatusCaveat())}`); + console.log(''); + console.log(subheader('Claude-side Setup:')); + for (const channelId of expandOfficialChannelSelection('all')) { + console.log(` ${dim(`${getOfficialChannelDisplayName(channelId)}:`)}`); + for (const command of getOfficialChannelManualSetupCommands(channelId)) { + console.log(` ${color(command, 'command')}`); + } + } + console.log(''); +} + +function resolveNextSelection(args: ChannelsCommandOptions): OfficialChannelId[] | null { + if (args.setSelection !== undefined) { + return expandOfficialChannelSelection(args.setSelection); + } + + if (args.clear) { + return []; + } + + return null; +} + +export async function handleConfigChannelsCommand(args: string[]): Promise { + await initUI(); + + const options = parseChannelsCommandArgs(args); + if (options.help) { + showHelp(); + return; + } + + if (options.setSelectionMissing) { + console.error(fail(`--set requires a value (${getOfficialChannelChoices()} or all)`)); + process.exitCode = 1; + return; + } + if ( + options.setSelection !== undefined && + !isOfficialChannelSelectionValid(options.setSelection) + ) { + console.error( + fail(`Invalid --set value: ${options.setSelection} (${getOfficialChannelChoices()} or all)`) + ); + process.exitCode = 1; + return; + } + if (options.setTokenMissing) { + console.error(fail('--set-token requires a value')); + process.exitCode = 1; + return; + } + if (options.setTokenInvalid) { + console.error( + fail(`Invalid --set-token value: ${options.setTokenInvalid} (use =)`) + ); + process.exitCode = 1; + return; + } + if (options.clearTokenInvalid) { + console.error( + fail( + `Invalid --clear-token value: ${options.clearTokenInvalid} (use ${getOfficialChannelChoices()})` + ) + ); + process.exitCode = 1; + return; + } + + const config = loadOrCreateUnifiedConfig(); + const nextConfig = { + ...(config.channels ?? DEFAULT_OFFICIAL_CHANNELS_CONFIG), + selected: [...(config.channels?.selected ?? DEFAULT_OFFICIAL_CHANNELS_CONFIG.selected)], + }; + + const explicitSelection = resolveNextSelection(options); + const hasConfigMutation = + explicitSelection !== null || + options.enable || + options.disable || + options.unattended || + options.noUnattended; + if (explicitSelection) { + nextConfig.selected = explicitSelection; + } + if (options.enable && !nextConfig.selected.includes('discord')) { + nextConfig.selected.push('discord'); + } + if (options.disable) { + nextConfig.selected = nextConfig.selected.filter((channelId) => channelId !== 'discord'); + } + if (options.unattended) { + nextConfig.unattended = true; + } + if (options.noUnattended) { + nextConfig.unattended = false; + } + + try { + if (hasConfigMutation) { + updateUnifiedConfig({ channels: nextConfig }); + } + + if (options.setToken) { + if (!getOfficialChannelTokenIds().includes(options.setToken.channelId)) { + throw new Error(`${options.setToken.channelId} does not use a bot token.`); + } + setConfiguredOfficialChannelToken(options.setToken.channelId, options.setToken.token); + console.log(ok(`${getOfficialChannelDisplayName(options.setToken.channelId)} token saved`)); + console.log(''); + } + + if (options.clearTokenChannel) { + if (!getOfficialChannelTokenIds().includes(options.clearTokenChannel)) { + throw new Error(`${options.clearTokenChannel} does not use a bot token.`); + } + clearConfiguredOfficialChannelTokensEverywhere(options.clearTokenChannel); + console.log(ok(`${getOfficialChannelDisplayName(options.clearTokenChannel)} token cleared`)); + console.log(''); + } else if (options.clearTokenAll) { + clearConfiguredOfficialChannelTokensEverywhere(); + console.log(ok('All saved channel tokens cleared')); + console.log(''); + } + + if (hasConfigMutation) { + console.log(ok('Configuration updated')); + console.log(''); + } + } catch (error) { + console.error(fail((error as Error).message)); + process.exitCode = 1; + return; + } + + showStatus(); +} diff --git a/src/commands/config-command-options.ts b/src/commands/config-command-options.ts index 1e19e5db..24d31a38 100644 --- a/src/commands/config-command-options.ts +++ b/src/commands/config-command-options.ts @@ -83,6 +83,18 @@ export function showConfigCommandHelp(): void { console.log('Includes a dedicated Claude IDE Extension page for VS Code-compatible hosts.'); console.log(''); console.log('Commands:'); + console.log(' channels Manage official Claude channels (Telegram, Discord, iMessage)'); + console.log(' --set Select channels to auto-enable at runtime'); + console.log(' --clear Clear all selected channels'); + console.log(' --enable Legacy alias: add Discord'); + console.log(' --disable Legacy alias: remove Discord'); + console.log(' --unattended Also add --dangerously-skip-permissions at runtime'); + console.log(' --set-token Save channel token (telegram= or discord=)'); + console.log(' --clear-token Remove all saved channel tokens'); + console.log(' --clear-token Remove one saved channel token'); + console.log(' Works only for native Claude default/account sessions'); + console.log(' Not for ccs glm, other API/OAuth profiles, or Droid targets'); + console.log(''); console.log(' auth Manage dashboard authentication'); console.log(' auth setup Configure username and password'); console.log(' auth show Display current auth status'); @@ -120,6 +132,9 @@ export function showConfigCommandHelp(): void { console.log(' ccs config --host 127.0.0.1 Restrict dashboard to this machine'); console.log(' ccs config --dev Development mode with hot reload'); console.log(' ccs config auth setup Configure dashboard login'); + console.log(' ccs config channels Show Official Channels status'); + console.log(' ccs config channels --set telegram,discord Enable Telegram + Discord'); + console.log(' ccs config channels --set-token telegram=xxx Save TELEGRAM_BOT_TOKEN'); console.log(' ccs config image-analysis Show image settings'); console.log(' ccs config image-analysis --enable Enable feature'); console.log(' ccs config thinking Show thinking settings'); diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index 1965ce86..fae58460 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -24,6 +24,13 @@ import { import { parseConfigCommandArgs, showConfigCommandHelp } from './config-command-options'; const CONFIG_SUBCOMMAND_ROUTES: readonly NamedCommandRoute[] = [ + { + name: 'channels', + handle: async (args) => { + const { handleConfigChannelsCommand } = await import('./config-channels-command'); + await handleConfigChannelsCommand(args); + }, + }, { name: 'auth', handle: async (args) => { @@ -47,25 +54,62 @@ const CONFIG_SUBCOMMAND_ROUTES: readonly NamedCommandRoute[] = [ }, ]; +interface ConfigCommandDependencies { + getPort: typeof getPort; + openBrowser: typeof open; + startServer: typeof startServer; + setupGracefulShutdown: typeof setupGracefulShutdown; + ensureCliproxyService: typeof ensureCliproxyService; + getDashboardAuthConfig: typeof getDashboardAuthConfig; + initUI: typeof initUI; + header: typeof header; + ok: typeof ok; + info: typeof info; + warn: typeof warn; + fail: typeof fail; + resolveNamedCommand: typeof resolveNamedCommand; + configSubcommandRoutes: readonly NamedCommandRoute[]; +} + +const defaultConfigCommandDependencies: ConfigCommandDependencies = { + getPort, + openBrowser: open, + startServer, + setupGracefulShutdown, + ensureCliproxyService, + getDashboardAuthConfig, + initUI, + header, + ok, + info, + warn, + fail, + resolveNamedCommand, + configSubcommandRoutes: CONFIG_SUBCOMMAND_ROUTES, +}; + /** * Handle config command */ -export async function handleConfigCommand(args: string[]): Promise { +export async function handleConfigCommand( + args: string[], + deps: ConfigCommandDependencies = defaultConfigCommandDependencies +): Promise { if (args.length === 1 && args[0] === 'help') { - await initUI(); + await deps.initUI(); showConfigCommandHelp(); process.exit(0); } const subcommand = args[0]?.startsWith('-') ? undefined - : resolveNamedCommand(args[0], CONFIG_SUBCOMMAND_ROUTES); + : deps.resolveNamedCommand(args[0], deps.configSubcommandRoutes); if (subcommand) { await subcommand.handle(args.slice(1)); return; } - await initUI(); + await deps.initUI(); const parsed = parseConfigCommandArgs(args); if (parsed.help) { @@ -73,41 +117,41 @@ export async function handleConfigCommand(args: string[]): Promise { process.exit(0); } if (parsed.error) { - console.error(fail(parsed.error)); + console.error(deps.fail(parsed.error)); process.exit(1); } const options = parsed.options; const verbose = options.dev; - console.log(header('CCS Config Dashboard')); + console.log(deps.header('CCS Config Dashboard')); console.log(''); // Ensure CLIProxy service is running for dashboard features - console.log(info('Starting CLIProxy service...')); - const cliproxyResult = await ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose); + console.log(deps.info('Starting CLIProxy service...')); + const cliproxyResult = await deps.ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose); if (cliproxyResult.started) { if (cliproxyResult.alreadyRunning) { - console.log(ok(`CLIProxy already running on port ${cliproxyResult.port}`)); + console.log(deps.ok(`CLIProxy already running on port ${cliproxyResult.port}`)); if (cliproxyResult.configRegenerated) { - console.log(warn('Config updated - restart CLIProxy to apply changes')); + console.log(deps.warn('Config updated - restart CLIProxy to apply changes')); } } else { - console.log(ok(`CLIProxy started on port ${cliproxyResult.port}`)); + console.log(deps.ok(`CLIProxy started on port ${cliproxyResult.port}`)); } } else { - console.log(warn(`CLIProxy not available: ${cliproxyResult.error}`)); - console.log(info('Dashboard will work but Control Panel/Stats may be limited')); + console.log(deps.warn(`CLIProxy not available: ${cliproxyResult.error}`)); + console.log(deps.info('Dashboard will work but Control Panel/Stats may be limited')); } console.log(''); - console.log(info('Starting dashboard server...')); + console.log(deps.info('Starting dashboard server...')); // Find available port const port = options.port ?? - (await getPort({ + (await deps.getPort({ port: [3000, 3001, 3002, 8000, 8080], })); @@ -121,60 +165,60 @@ export async function handleConfigCommand(args: string[]): Promise { serverOptions.host = normalizeDashboardHost(options.host); } - const { server, wss, cleanup } = await startServer(serverOptions); + const { server, wss, cleanup } = await deps.startServer(serverOptions); // Setup graceful shutdown - setupGracefulShutdown(server, wss, cleanup); + deps.setupGracefulShutdown(server, wss, cleanup); const urls = resolveDashboardUrls(resolveServerBindHost(server) ?? options.host, port); const shouldWarnAboutExposure = urls.bindHost ? !isLoopbackHost(urls.bindHost) : false; if (options.dev) { - console.log(ok(`Dev Server: ${urls.browserUrl}`)); + console.log(deps.ok(`Dev Server: ${urls.browserUrl}`)); console.log(''); - console.log(info('HMR enabled - UI changes will hot-reload')); + console.log(deps.info('HMR enabled - UI changes will hot-reload')); } else { - console.log(ok(`Dashboard: ${urls.browserUrl}`)); + console.log(deps.ok(`Dashboard: ${urls.browserUrl}`)); } if (shouldWarnAboutExposure && urls.bindHost) { - console.log(info(`Bind host: ${urls.bindHost}`)); + console.log(deps.info(`Bind host: ${urls.bindHost}`)); if (urls.networkUrls?.length === 1) { - console.log(info(`Network URL: ${urls.networkUrls[0]}`)); + console.log(deps.info(`Network URL: ${urls.networkUrls[0]}`)); } else if (urls.networkUrls && urls.networkUrls.length > 1) { - console.log(info('Network URLs:')); + console.log(deps.info('Network URLs:')); for (const networkUrl of urls.networkUrls) { - console.log(info(` ${networkUrl}`)); + console.log(deps.info(` ${networkUrl}`)); } } } if (shouldWarnAboutExposure && urls.bindHost) { - const authConfig = getDashboardAuthConfig(); + const authConfig = deps.getDashboardAuthConfig(); console.log( - warn('Dashboard may be reachable from other devices that can connect to this machine.') + deps.warn('Dashboard may be reachable from other devices that can connect to this machine.') ); if (!authConfig.enabled) { - console.log(info('Protect it before sharing: ccs config auth setup')); + console.log(deps.info('Protect it before sharing: ccs config auth setup')); } if (isWildcardHost(urls.bindHost) && !urls.networkUrls?.length) { - console.log(info('Use your machine IP or hostname from the other device.')); + console.log(deps.info('Use your machine IP or hostname from the other device.')); } } console.log(''); // Open browser try { - await open(urls.browserUrl, { wait: false }); - console.log(info('Browser opened automatically')); + await deps.openBrowser(urls.browserUrl, { wait: false }); + console.log(deps.info('Browser opened automatically')); } catch { - console.log(info(`Open manually: ${urls.browserUrl}`)); + console.log(deps.info(`Open manually: ${urls.browserUrl}`)); } console.log(''); - console.log(info('Press Ctrl+C to stop')); + console.log(deps.info('Press Ctrl+C to stop')); } catch (error) { - console.error(fail(`Failed to start server: ${(error as Error).message}`)); + console.error(deps.fail(`Failed to start server: ${(error as Error).message}`)); process.exit(1); } } diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 0d8c0e26..756d8022 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -311,6 +311,12 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs config', 'Open web dashboard (includes Claude IDE Extension setup page)'], ['ccs config auth setup', 'Configure dashboard login'], ['ccs config auth show', 'Show dashboard auth status'], + ['ccs config channels', 'Show Official Channels status'], + [ + 'ccs config channels --set telegram,discord', + 'Auto-add Telegram + Discord on supported native Claude runs', + ], + ['ccs config channels --set-token telegram=', 'Save TELEGRAM_BOT_TOKEN'], ['ccs config image-analysis', 'Show image analysis settings'], ['ccs config image-analysis --enable', 'Enable image analysis'], ['ccs config thinking', 'Show thinking/reasoning settings'], @@ -466,6 +472,27 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['', 'providers (agy, gemini, codex, kiro, ghcp).'], ]); + printSubSection('Official Channels (official Claude plugins)', [ + ['ccs config', 'Dashboard -> Settings -> Channels (fastest path)'], + ['ccs config channels', 'Show current status'], + [ + 'ccs config channels --set telegram,discord', + 'Auto-add selected channels on native Claude default/account sessions', + ], + ['ccs config channels --set all', 'Enable Telegram, Discord, and iMessage'], + ['ccs config channels --unattended', 'Also add --dangerously-skip-permissions'], + ['ccs config channels --set-token telegram=', 'Save TELEGRAM_BOT_TOKEN'], + ['ccs config channels --set-token discord=', 'Save DISCORD_BOT_TOKEN'], + ['ccs config channels --clear-token [channel]', 'Remove one or all saved channel tokens'], + ['', ''], + ['', 'Fastest path: turn on the channel, save the token if needed, then run ccs.'], + ['Note:', 'Runtime-only. Applies to native Claude default/account sessions only.'], + ['', 'Not supported for ccs glm, other API/OAuth profiles, or Droid targets.'], + ['', 'Telegram/Discord tokens live in ~/.claude/channels//.env.'], + ['', 'Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work for that launch.'], + ['', 'iMessage is macOS-only and requires local OS permissions instead of a bot token.'], + ]); + // CCS Environment Variables printSubSection('Environment Variables', [ ['CCS_DIR', 'Override CCS config directory (default: ~/.ccs)'], @@ -534,6 +561,4 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // License console.log(dim('License: MIT')); console.log(''); - - process.exit(0); } diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index a95cb2c5..64567307 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -21,6 +21,7 @@ import { DEFAULT_CLIPROXY_SAFETY_CONFIG, DEFAULT_QUOTA_MANAGEMENT_CONFIG, DEFAULT_THINKING_CONFIG, + DEFAULT_OFFICIAL_CHANNELS_CONFIG, DEFAULT_DASHBOARD_AUTH_CONFIG, DEFAULT_IMAGE_ANALYSIS_CONFIG, } from './unified-config-types'; @@ -29,6 +30,8 @@ import type { CLIProxySafetyConfig, GlobalEnvConfig, ThinkingConfig, + OfficialChannelsConfig, + OfficialChannelId, DashboardAuthConfig, ImageAnalysisConfig, CursorConfig, @@ -36,6 +39,11 @@ import type { } from './unified-config-types'; import { validateCompositeTiers } from '../cliproxy/composite-validator'; import { isUnifiedConfigEnabled } from './feature-flags'; +import { + isOfficialChannelId, + normalizeOfficialChannelIds, + resolveLegacyDiscordSelection, +} from '../channels/official-channels-runtime'; const CONFIG_YAML = 'config.yaml'; const CONFIG_JSON = 'config.json'; @@ -71,10 +79,15 @@ function getLockFilePath(): string { function acquireLock(): string | null { const lockPath = getLockFilePath(); + const lockDir = path.dirname(lockPath); const lockToken = crypto.randomUUID(); const lockData = `${process.pid}\n${Date.now()}\n${lockToken}`; try { + if (!fs.existsSync(lockDir)) { + fs.mkdirSync(lockDir, { recursive: true, mode: 0o700 }); + } + // Check if lock exists if (fs.existsSync(lockPath)) { const content = fs.readFileSync(lockPath, 'utf8'); @@ -288,6 +301,36 @@ function normalizeContinuityConfig(partial: Partial): ContinuityC }; } +interface LegacyDiscordChannelsConfig { + enabled?: boolean; + unattended?: boolean; +} + +function normalizeOfficialChannelsConfig( + partial: Partial & { discord_channels?: LegacyDiscordChannelsConfig } +): OfficialChannelsConfig { + const hasCanonicalChannelsSection = partial.channels !== undefined; + const hasExplicitSelectedField = + hasCanonicalChannelsSection && + Object.prototype.hasOwnProperty.call(partial.channels, 'selected'); + const rawSelected = + hasExplicitSelectedField && Array.isArray(partial.channels?.selected) + ? partial.channels.selected.filter((value): value is OfficialChannelId => + isOfficialChannelId(value) + ) + : []; + + return { + selected: hasCanonicalChannelsSection + ? normalizeOfficialChannelIds(rawSelected) + : resolveLegacyDiscordSelection(partial.discord_channels?.enabled), + unattended: + partial.channels?.unattended ?? + partial.discord_channels?.unattended ?? + DEFAULT_OFFICIAL_CHANNELS_CONFIG.unattended, + }; +} + /** * Merge partial config with defaults. * Preserves existing data while filling in missing sections. @@ -499,6 +542,9 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { provider_overrides: partial.thinking?.provider_overrides, show_warnings: partial.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings, }, + channels: normalizeOfficialChannelsConfig( + partial as Partial & { discord_channels?: LegacyDiscordChannelsConfig } + ), // Dashboard auth config - disabled by default dashboard_auth: { enabled: partial.dashboard_auth?.enabled ?? DEFAULT_DASHBOARD_AUTH_CONFIG.enabled, @@ -763,6 +809,28 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Official Channels section + if (config.channels) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Official Channels: Runtime auto-enable for Anthropic official channel plugins'); + lines.push('# Supported channels: telegram, discord, imessage'); + lines.push('# Runtime-only: CCS injects --channels at launch for compatible Claude sessions.'); + lines.push('# Bot tokens live in Claude channel env files, not in config.yaml.'); + lines.push('# Use selected: [telegram, discord, imessage] to choose channels.'); + lines.push( + '# unattended adds --dangerously-skip-permissions only when channel auto-enable is active.' + ); + lines.push('# Compatible sessions: native Claude default/account profiles only.'); + lines.push('# Configure via: ccs config channels or the Settings > Channels dashboard tab.'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump({ channels: config.channels }, { indent: 2, lineWidth: -1, quotingType: '"' }) + .trim() + ); + lines.push(''); + } + // Dashboard auth section (only if configured) if (config.dashboard_auth?.enabled) { lines.push('# ----------------------------------------------------------------------------'); @@ -1138,6 +1206,37 @@ export function getThinkingConfig(): ThinkingConfig { }; } +/** + * Get Official Channels configuration. + * Returns defaults if not configured. + */ +export function getOfficialChannelsConfig(): OfficialChannelsConfig { + const config = loadOrCreateUnifiedConfig(); + + return { + selected: + config.channels?.selected && config.channels.selected.length > 0 + ? normalizeOfficialChannelIds(config.channels.selected) + : DEFAULT_OFFICIAL_CHANNELS_CONFIG.selected, + unattended: config.channels?.unattended ?? DEFAULT_OFFICIAL_CHANNELS_CONFIG.unattended, + }; +} + +/** + * Get dashboard_auth configuration with ENV var override. + * Priority: ENV vars > config.yaml > defaults + */ +export function isDashboardAuthEnabled(): boolean { + const envEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; + + if (envEnabled !== undefined) { + return envEnabled === 'true' || envEnabled === '1'; + } + + const config = loadOrCreateUnifiedConfig(); + return config.dashboard_auth?.enabled ?? false; +} + /** * Get dashboard_auth configuration with ENV var override. * Priority: ENV vars > config.yaml > defaults diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index bf96e4a3..8ff88160 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -24,8 +24,10 @@ import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; * Version 8 = Thinking/reasoning budget configuration * Version 9 = Real WebSearch backends (DuckDuckGo/Brave) with legacy CLI fallback * Version 10 = Exa + Tavily WebSearch backends + * Version 11 = Discord Channels runtime auto-enable preferences + * Version 12 = Official Channels multi-provider support (Telegram, Discord, iMessage) */ -export const UNIFIED_CONFIG_VERSION = 10; +export const UNIFIED_CONFIG_VERSION = 12; /** * Supported CLIProxy providers. @@ -694,6 +696,31 @@ export const DEFAULT_THINKING_CONFIG: ThinkingConfig = { show_warnings: true, }; +/** + * Supported Anthropic official channel IDs. + */ +export type OfficialChannelId = 'telegram' | 'discord' | 'imessage'; + +/** + * Official Channels configuration. + * Controls runtime-only injection of Anthropic's official channel plugins. + */ +export interface OfficialChannelsConfig { + /** Selected official channels to auto-enable for compatible sessions */ + selected: OfficialChannelId[]; + /** Also add --dangerously-skip-permissions when auto-enable is active */ + unattended: boolean; +} + +/** + * Default Official Channels configuration. + * Disabled by default because the feature requires explicit user setup. + */ +export const DEFAULT_OFFICIAL_CHANNELS_CONFIG: OfficialChannelsConfig = { + selected: [], + unattended: false, +}; + /** * Dashboard authentication configuration. * Optional login protection for CCS dashboard. @@ -790,6 +817,8 @@ export interface UnifiedConfig { quota_management?: QuotaManagementConfig; /** Thinking/reasoning budget configuration (v8+) */ thinking?: ThinkingConfig; + /** Discord Channels runtime auto-enable preferences (v11+) */ + channels?: OfficialChannelsConfig; /** Dashboard authentication configuration (optional) */ dashboard_auth?: DashboardAuthConfig; /** Image analysis configuration (vision via CLIProxy) */ @@ -916,6 +945,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG }, quota_management: { ...DEFAULT_QUOTA_MANAGEMENT_CONFIG }, thinking: { ...DEFAULT_THINKING_CONFIG }, + channels: { ...DEFAULT_OFFICIAL_CHANNELS_CONFIG }, dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG }, image_analysis: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG }, }; diff --git a/src/cursor/daemon-process-ownership.ts b/src/cursor/daemon-process-ownership.ts index 81ac9384..0d0b11a6 100644 --- a/src/cursor/daemon-process-ownership.ts +++ b/src/cursor/daemon-process-ownership.ts @@ -3,14 +3,30 @@ import * as fs from 'fs'; export type DaemonOwnershipStatus = 'owned' | 'not-owned' | 'not-running' | 'unknown'; +function sleepSync(milliseconds: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); +} + function getProcessCommandLine(pid: number): string | null { if (process.platform === 'linux') { - try { - // /proc cmdline uses null separators between arguments. - return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim(); - } catch { - return null; + for (let attempt = 0; attempt < 3; attempt++) { + try { + // /proc cmdline uses null separators between arguments. + const commandLine = fs + .readFileSync(`/proc/${pid}/cmdline`, 'utf8') + .replace(/\0/g, ' ') + .trim(); + if (commandLine) { + return commandLine; + } + } catch { + return null; + } + + sleepSync(25); } + + return null; } if (process.platform === 'darwin') { diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index e468c2d6..e423d77d 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -11,7 +11,8 @@ import * as path from 'path'; import * as os from 'os'; import ProfileContextSyncLock from './profile-context-sync-lock'; import { ok, info, warn } from '../utils/ui'; -import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_GROUP } from '../auth/account-context'; +import { DEFAULT_ACCOUNT_CONTEXT_GROUP } from '../auth/account-context'; +import type { AccountContextPolicy } from '../auth/account-context'; import { getCcsDir } from '../utils/config-manager'; interface SharedItem { diff --git a/src/utils/claude-config-path.ts b/src/utils/claude-config-path.ts index 0cd853ae..351c5be4 100644 --- a/src/utils/claude-config-path.ts +++ b/src/utils/claude-config-path.ts @@ -1,6 +1,15 @@ import * as path from 'path'; import { getCcsHome } from './config-manager'; +/** + * Resolve the canonical default Claude config directory. + * Ignores CLAUDE_CONFIG_DIR so CCS can keep a stable source of truth + * for shared plugin/channel state while still honoring test/dev home overrides. + */ +export function getDefaultClaudeConfigDir(): string { + return path.join(getCcsHome(), '.claude'); +} + /** * Resolve Claude config directory with test/dev overrides. * Precedence: @@ -13,7 +22,7 @@ export function getClaudeConfigDir(): string { return path.resolve(process.env.CLAUDE_CONFIG_DIR); } - return path.join(getCcsHome(), '.claude'); + return getDefaultClaudeConfigDir(); } /** Resolve Claude settings.json path. */ diff --git a/src/utils/claude-detector.ts b/src/utils/claude-detector.ts index 1edd0a04..a0cea8ed 100644 --- a/src/utils/claude-detector.ts +++ b/src/utils/claude-detector.ts @@ -1,7 +1,18 @@ import * as fs from 'fs'; -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; import { expandPath } from './helpers'; -import { ClaudeCliInfo } from '../types'; +import type { ClaudeCliInfo } from '../types'; +import { escapeShellArg, stripAnthropicEnv, stripClaudeCodeEnv } from './shell-executor'; + +export interface ClaudeAuthStatus { + loggedIn: boolean; + authMethod?: string | null; + apiProvider?: string | null; + email?: string | null; + orgId?: string | null; + orgName?: string | null; + subscriptionType?: string | null; +} /** * Windows installation paths for Claude CLI @@ -127,6 +138,120 @@ export function getClaudeCliInfo(): ClaudeCliInfo | null { }; } +function runClaudeCliCommand(args: string[], envOverrides?: NodeJS.ProcessEnv): string | null { + const cliInfo = getClaudeCliInfo(); + if (!cliInfo) { + return null; + } + + const env = stripClaudeCodeEnv(stripAnthropicEnv({ ...process.env, ...envOverrides })); + const { path: claudePath, needsShell } = cliInfo; + const isWindows = process.platform === 'win32'; + const isPowerShellScript = isWindows && /\.ps1$/i.test(claudePath); + + try { + if (isPowerShellScript) { + return execFileSync( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudePath, ...args], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + env, + } + ).trim(); + } + + if (needsShell) { + return execSync([claudePath, ...args].map(escapeShellArg).join(' '), { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + shell: process.env.ComSpec || 'cmd.exe', + env, + }).trim(); + } + + return execFileSync(claudePath, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + env, + }).trim(); + } catch (error) { + if (typeof error === 'object' && error !== null && 'stdout' in error) { + const stdout = (error as { stdout?: string | Buffer | null }).stdout; + if (typeof stdout === 'string') { + const trimmed = stdout.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (Buffer.isBuffer(stdout)) { + const trimmed = stdout.toString('utf8').trim(); + return trimmed.length > 0 ? trimmed : null; + } + } + + return null; + } +} + +export function getClaudeCliVersion(): string | null { + const output = runClaudeCliCommand(['--version']); + const versionMatch = output?.match(/(\d+\.\d+\.\d+)/); + return versionMatch ? versionMatch[1] : null; +} + +export function compareClaudeCliVersions(left: string, right: string): number { + const leftParts = left.split('.').map((value) => Number.parseInt(value, 10) || 0); + const rightParts = right.split('.').map((value) => Number.parseInt(value, 10) || 0); + const maxLength = Math.max(leftParts.length, rightParts.length); + + for (let index = 0; index < maxLength; index += 1) { + const leftValue = leftParts[index] ?? 0; + const rightValue = rightParts[index] ?? 0; + + if (leftValue !== rightValue) { + return leftValue > rightValue ? 1 : -1; + } + } + + return 0; +} + +export function isClaudeCliVersionAtLeast( + currentVersion: string | null, + minimumVersion: string +): boolean { + return currentVersion !== null && compareClaudeCliVersions(currentVersion, minimumVersion) >= 0; +} + +export function getClaudeAuthStatus(envOverrides?: NodeJS.ProcessEnv): ClaudeAuthStatus | null { + const output = runClaudeCliCommand(['auth', 'status'], envOverrides); + if (!output) { + return null; + } + + try { + const parsed = JSON.parse(output) as Partial; + if (typeof parsed.loggedIn !== 'boolean') { + return null; + } + + return { + loggedIn: parsed.loggedIn, + authMethod: parsed.authMethod ?? null, + apiProvider: parsed.apiProvider ?? null, + email: parsed.email ?? null, + orgId: parsed.orgId ?? null, + orgName: parsed.orgName ?? null, + subscriptionType: parsed.subscriptionType ?? null, + }; + } catch { + return null; + } +} + /** * Show Claude not found error */ diff --git a/src/utils/error-manager.ts b/src/utils/error-manager.ts index 6bd8626c..116da454 100644 --- a/src/utils/error-manager.ts +++ b/src/utils/error-manager.ts @@ -1,5 +1,6 @@ import { initUI, header, color, dim, info, errorBox } from './ui'; -import { ERROR_CODES, getErrorDocUrl, ErrorCode } from './error-codes'; +import { ERROR_CODES, getErrorDocUrl } from './error-codes'; +import type { ErrorCode } from './error-codes'; import { getPortCheckCommand, getKillPidCommand } from './platform-commands'; /** diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index 4594b4ad..4021d178 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -180,14 +180,11 @@ export function getCliInstallHints(): string[] { ]; } -/** - * Get WebSearch readiness status for display. - */ -export function getWebSearchReadiness(): WebSearchStatus { - const wsConfig = getWebSearchConfig(); - const providers = getWebSearchCliProviders(); - - if (!wsConfig.enabled) { +export function buildWebSearchReadiness( + enabled: boolean, + providers: WebSearchCliInfo[] +): WebSearchStatus { + if (!enabled) { return { readiness: 'unavailable', message: 'Disabled in config', @@ -223,6 +220,15 @@ export function getWebSearchReadiness(): WebSearchStatus { }; } +/** + * Get WebSearch readiness status for display. + */ +export function getWebSearchReadiness(): WebSearchStatus { + const wsConfig = getWebSearchConfig(); + const providers = getWebSearchCliProviders(); + return buildWebSearchReadiness(wsConfig.enabled, providers); +} + /** * Display WebSearch status (single line, equilibrium UX). */ diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index cbdff588..c42272a2 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -6,7 +6,7 @@ import type { Request, Response, NextFunction } from 'express'; import session from 'express-session'; import rateLimit from 'express-rate-limit'; -import { getDashboardAuthConfig } from '../../config/unified-config-loader'; +import { getDashboardAuthConfig, isDashboardAuthEnabled } from '../../config/unified-config-loader'; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; @@ -78,7 +78,7 @@ export const loginRateLimiter = rateLimit({ message: { error: 'Too many login attempts. Please try again later.' }, standardHeaders: true, legacyHeaders: false, - skip: () => !getDashboardAuthConfig().enabled, + skip: () => !isDashboardAuthEnabled(), }); /** @@ -106,10 +106,8 @@ export function createSessionMiddleware() { * Only active when dashboard_auth.enabled = true. */ export function authMiddleware(req: Request, res: Response, next: NextFunction): void { - const authConfig = getDashboardAuthConfig(); - // Skip auth if disabled - if (!authConfig.enabled) { + if (!isDashboardAuthEnabled()) { return next(); } @@ -150,7 +148,7 @@ export function requireLocalAccessWhenAuthDisabled( res: Response, error = 'This endpoint requires localhost access when dashboard auth is disabled.' ): boolean { - if (getDashboardAuthConfig().enabled) { + if (isDashboardAuthEnabled()) { return true; } diff --git a/src/web-server/routes/channels-routes.ts b/src/web-server/routes/channels-routes.ts new file mode 100644 index 00000000..044dd5ca --- /dev/null +++ b/src/web-server/routes/channels-routes.ts @@ -0,0 +1,206 @@ +import { Router, type Request, type Response } from 'express'; +import { getOfficialChannelsConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; +import { + clearConfiguredOfficialChannelTokensEverywhere, + getOfficialChannelTokenStatus, + hasConfiguredOfficialChannelToken, + setConfiguredOfficialChannelToken, +} from '../../channels/official-channels-store'; +import { + buildOfficialChannelsLaunchPreview, + buildOfficialChannelsReadinessSummary, + buildOfficialChannelSetupSummary, + expandOfficialChannelSelection, + getOfficialChannelsAccountStatusCaveat, + getOfficialChannelDisplayName, + getOfficialChannelEnvKey, + getOfficialChannelPluginSpec, + getOfficialChannelSummary, + getOfficialChannelsSupportMessage, + getOfficialChannelUnavailableReason, + getOfficialChannelsEnvironmentStatus, + getOfficialChannelManualSetupCommands, + getOfficialChannelTokenIds, + isOfficialChannelId, +} from '../../channels/official-channels-runtime'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; + +const router = Router(); + +function buildChannelsStatus(config = getOfficialChannelsConfig()) { + const environment = getOfficialChannelsEnvironmentStatus(); + const channels = expandOfficialChannelSelection('all').map((channelId) => { + const tokenStatus = getOfficialChannelTokenIds().includes(channelId) + ? getOfficialChannelTokenStatus(channelId) + : undefined; + const selected = config.selected.includes(channelId); + + return { + id: channelId, + selected, + displayName: getOfficialChannelDisplayName(channelId), + pluginSpec: getOfficialChannelPluginSpec(channelId), + summary: getOfficialChannelSummary(channelId), + requiresToken: getOfficialChannelTokenIds().includes(channelId), + envKey: getOfficialChannelEnvKey(channelId), + tokenConfigured: + getOfficialChannelTokenIds().includes(channelId) && + hasConfiguredOfficialChannelToken(channelId), + tokenAvailable: tokenStatus?.available ?? false, + tokenSource: tokenStatus?.source, + tokenPath: tokenStatus?.tokenPath, + savedInClaudeState: tokenStatus?.savedInClaudeState ?? false, + processEnvAvailable: tokenStatus?.processEnvAvailable ?? false, + unavailableReason: getOfficialChannelUnavailableReason(channelId), + manualSetupCommands: getOfficialChannelManualSetupCommands(channelId), + setup: buildOfficialChannelSetupSummary({ + id: channelId, + displayName: getOfficialChannelDisplayName(channelId), + selected, + requiresToken: getOfficialChannelTokenIds().includes(channelId), + tokenAvailable: tokenStatus?.available ?? false, + tokenSource: tokenStatus?.source, + savedInClaudeState: tokenStatus?.savedInClaudeState ?? false, + processEnvAvailable: tokenStatus?.processEnvAvailable ?? false, + unavailableReason: getOfficialChannelUnavailableReason(channelId), + }), + }; + }); + + return { + bunInstalled: environment.bunInstalled, + supportedProfiles: environment.supportedProfiles, + supportMessage: getOfficialChannelsSupportMessage(), + accountStatusCaveat: getOfficialChannelsAccountStatusCaveat(), + stateScopeMessage: environment.stateScopeMessage, + claudeVersion: environment.claudeVersion, + auth: environment.auth, + summary: buildOfficialChannelsReadinessSummary({ + config, + environment, + channels: channels.map((channel) => ({ + id: channel.id, + displayName: channel.displayName, + selected: channel.selected, + requiresToken: channel.requiresToken, + tokenAvailable: channel.tokenAvailable, + tokenSource: channel.tokenSource, + savedInClaudeState: channel.savedInClaudeState, + processEnvAvailable: channel.processEnvAvailable, + unavailableReason: channel.unavailableReason, + })), + }), + launchPreview: buildOfficialChannelsLaunchPreview({ + config, + environment, + channels: channels.map((channel) => ({ + id: channel.id, + displayName: channel.displayName, + selected: channel.selected, + requiresToken: channel.requiresToken, + tokenAvailable: channel.tokenAvailable, + tokenSource: channel.tokenSource, + savedInClaudeState: channel.savedInClaudeState, + processEnvAvailable: channel.processEnvAvailable, + unavailableReason: channel.unavailableReason, + })), + }), + channels, + }; +} + +router.use((req: Request, res: Response, next) => { + if ( + requireLocalAccessWhenAuthDisabled( + req, + res, + 'Official Channels settings require localhost access when dashboard auth is disabled.' + ) + ) { + next(); + } +}); + +router.get('/', (_req: Request, res: Response): void => { + const config = getOfficialChannelsConfig(); + res.json({ + config, + status: buildChannelsStatus(config), + }); +}); + +router.put('/', (req: Request, res: Response): void => { + const { selected, unattended } = req.body as { selected?: unknown; unattended?: unknown }; + + if ( + selected !== undefined && + (!Array.isArray(selected) || + selected.some((value) => typeof value !== 'string' || !isOfficialChannelId(value))) + ) { + res.status(400).json({ error: 'selected must be an array of official channel IDs' }); + return; + } + if (unattended !== undefined && typeof unattended !== 'boolean') { + res.status(400).json({ error: 'unattended must be a boolean' }); + return; + } + + try { + const updated = mutateUnifiedConfig((config) => { + config.channels = { + selected: + selected !== undefined ? [...new Set(selected)] : (config.channels?.selected ?? []), + unattended: unattended ?? config.channels?.unattended ?? false, + }; + }); + + res.json({ success: true, config: updated.channels }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.put('/:channelId/token', (req: Request, res: Response): void => { + const { channelId } = req.params; + const { token } = req.body as { token?: unknown }; + + if (!isOfficialChannelId(channelId) || !getOfficialChannelTokenIds().includes(channelId)) { + res.status(400).json({ error: 'channelId must be a token-based official channel' }); + return; + } + if (typeof token !== 'string') { + res.status(400).json({ error: 'token must be a string' }); + return; + } + + try { + const tokenPath = setConfiguredOfficialChannelToken(channelId, token); + res.json({ success: true, tokenConfigured: true, tokenPath }); + } catch (error) { + const message = (error as Error).message; + const statusCode = message.includes('cannot be empty') ? 400 : 500; + res.status(statusCode).json({ error: message }); + } +}); + +router.delete('/:channelId/token', (req: Request, res: Response): void => { + const { channelId } = req.params; + + if (!isOfficialChannelId(channelId) || !getOfficialChannelTokenIds().includes(channelId)) { + res.status(400).json({ error: 'channelId must be a token-based official channel' }); + return; + } + + try { + const clearedPaths = clearConfiguredOfficialChannelTokensEverywhere(channelId); + res.json({ + success: true, + tokenConfigured: false, + clearedPaths, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 49066a51..e4cd9f74 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -15,6 +15,7 @@ import healthRoutes from './health-routes'; import providerRoutes from './provider-routes'; import variantRoutes from './variant-routes'; import settingsRoutes from './settings-routes'; +import channelsRoutes from './channels-routes'; import websearchRoutes from './websearch-routes'; import cliproxyAuthRoutes from './cliproxy-auth-routes'; import cliproxyStatsRoutes from './cliproxy-stats-routes'; @@ -37,6 +38,7 @@ export const apiRoutes = Router(); // Profile CRUD, settings management, presets, accounts apiRoutes.use('/profiles', profileRoutes); apiRoutes.use('/settings', settingsRoutes); +apiRoutes.use('/channels', channelsRoutes); apiRoutes.use('/accounts', accountRoutes); // ==================== Unified Config ==================== diff --git a/src/web-server/usage/cliproxy-usage-syncer.ts b/src/web-server/usage/cliproxy-usage-syncer.ts index 64e564ad..f3249089 100644 --- a/src/web-server/usage/cliproxy-usage-syncer.ts +++ b/src/web-server/usage/cliproxy-usage-syncer.ts @@ -32,6 +32,8 @@ interface CliproxyUsageSnapshot { monthly: MonthlyUsage[]; } +type FetchCliproxyUsageRaw = typeof fetchCliproxyUsageRaw; + const SNAPSHOT_VERSION = 1; /** Sync interval in ms, configurable via CCS_CLIPROXY_SYNC_INTERVAL env var (default: 5 min) */ @@ -106,8 +108,10 @@ export async function loadCachedCliproxyData(): Promise<{ * Fetch latest CLIProxy usage data and persist a snapshot to disk. * Non-fatal: logs warning and returns early if CLIProxy is unavailable. */ -export async function syncCliproxyUsage(): Promise { - const raw = await fetchCliproxyUsageRaw(); +export async function syncCliproxyUsage( + fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw +): Promise { + const raw = await fetchRaw(); if (raw === null) { console.log(warn('CLIProxy usage sync skipped: proxy unavailable')); @@ -150,7 +154,7 @@ export async function syncCliproxyUsage(): Promise { * Start periodic CLIProxy usage sync (every 5 minutes). * Performs an immediate sync on startup. */ -export function startCliproxySync(): void { +export function startCliproxySync(syncNow: () => Promise = () => syncCliproxyUsage()): void { if (syncIntervalId !== null) { return; } @@ -159,10 +163,10 @@ export function startCliproxySync(): void { console.log(info(`Starting CLIProxy usage sync (interval: ${intervalMin} min)`)); // Fire-and-forget initial sync - void syncCliproxyUsage(); + void syncNow(); syncIntervalId = setInterval(() => { - void syncCliproxyUsage(); + void syncNow(); }, SYNC_INTERVAL_MS); } diff --git a/tests/integration/image-analyzer-hook.test.ts b/tests/integration/image-analyzer-hook.test.ts index 593a7cae..b21c8b7a 100644 --- a/tests/integration/image-analyzer-hook.test.ts +++ b/tests/integration/image-analyzer-hook.test.ts @@ -52,6 +52,7 @@ function invokeHook(env: Record = {}): Promise { const child = spawn('node', [HOOK_PATH], { env: { ...process.env, + CCS_IMAGE_ANALYSIS_SKIP: '', // clear any inherited skip flag CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY, CCS_CLIPROXY_PORT: String(mockPort), CCS_IMAGE_ANALYSIS_ENABLED: '1', diff --git a/tests/shared/fixtures/test-environment.js b/tests/shared/fixtures/test-environment.js index bd6d7554..b008d1d7 100644 --- a/tests/shared/fixtures/test-environment.js +++ b/tests/shared/fixtures/test-environment.js @@ -32,6 +32,52 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); +let bootstrappedTestHome; +const originalHomedir = os.homedir; +let homedirPatched = false; + +function getEffectiveTestHome() { + return process.env.CCS_HOME || process.env.HOME || process.env.USERPROFILE || bootstrappedTestHome || originalHomedir(); +} + +function patchHomedirForTests() { + if (homedirPatched) { + return; + } + + os.homedir = () => getEffectiveTestHome(); + homedirPatched = true; +} + +function createIsolatedTestHome(prefix = 'ccs-test-home-') { + const testHome = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + fs.mkdirSync(path.join(testHome, '.ccs'), { recursive: true }); + fs.mkdirSync(path.join(testHome, '.claude'), { recursive: true }); + fs.mkdirSync(path.join(testHome, '.config'), { recursive: true }); + fs.mkdirSync(path.join(testHome, '.cache'), { recursive: true }); + fs.mkdirSync(path.join(testHome, '.state'), { recursive: true }); + return testHome; +} + +function ensureGlobalTestEnvironment() { + if (bootstrappedTestHome) { + return bootstrappedTestHome; + } + + const testHome = createIsolatedTestHome(); + process.env.HOME = testHome; + process.env.USERPROFILE = testHome; + process.env.CCS_HOME = testHome; + process.env.XDG_CONFIG_HOME = path.join(testHome, '.config'); + process.env.XDG_CACHE_HOME = path.join(testHome, '.cache'); + process.env.XDG_STATE_HOME = path.join(testHome, '.state'); + process.env.CCS_TEST_BOOTSTRAP_HOME = testHome; + + bootstrappedTestHome = testHome; + patchHomedirForTests(); + return bootstrappedTestHome; +} + /** * Create an isolated test environment * Sets CCS_HOME to a temporary directory and provides cleanup @@ -40,21 +86,26 @@ const os = require('os'); */ function createTestEnvironment() { // Create unique temp directory for this test run - const tempBase = path.join(os.tmpdir(), 'ccs-test'); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const testHome = path.join(tempBase, uniqueId); + const testHome = createIsolatedTestHome('ccs-test-'); const testCcsDir = path.join(testHome, '.ccs'); - // Create directories - fs.mkdirSync(testCcsDir, { recursive: true }); - // Store original environment const originalHome = process.env.HOME; const originalCcsHome = process.env.CCS_HOME; const originalUserProfile = process.env.USERPROFILE; + const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + const originalXdgCacheHome = process.env.XDG_CACHE_HOME; + const originalXdgStateHome = process.env.XDG_STATE_HOME; - // Set test environment - use CCS_HOME for isolation + // Keep HOME-family env vars aligned so code that still consults os.homedir() + // or XDG defaults stays inside the isolated test sandbox. + process.env.HOME = testHome; + process.env.USERPROFILE = testHome; process.env.CCS_HOME = testHome; + process.env.XDG_CONFIG_HOME = path.join(testHome, '.config'); + process.env.XDG_CACHE_HOME = path.join(testHome, '.cache'); + process.env.XDG_STATE_HOME = path.join(testHome, '.state'); + patchHomedirForTests(); // Return environment object return { @@ -117,12 +168,42 @@ function createTestEnvironment() { */ cleanup() { // Restore original environment + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + + if (originalUserProfile !== undefined) { + process.env.USERPROFILE = originalUserProfile; + } else { + delete process.env.USERPROFILE; + } + if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; } else { delete process.env.CCS_HOME; } + if (originalXdgConfigHome !== undefined) { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } else { + delete process.env.XDG_CONFIG_HOME; + } + + if (originalXdgCacheHome !== undefined) { + process.env.XDG_CACHE_HOME = originalXdgCacheHome; + } else { + delete process.env.XDG_CACHE_HOME; + } + + if (originalXdgStateHome !== undefined) { + process.env.XDG_STATE_HOME = originalXdgStateHome; + } else { + delete process.env.XDG_STATE_HOME; + } + // Clean up temp directory try { fs.rmSync(testHome, { recursive: true, force: true }); @@ -155,6 +236,11 @@ function getCcsDir() { module.exports = { createTestEnvironment, + ensureGlobalTestEnvironment, getCcsHome, getCcsDir }; + +if (process.env.CCS_TEST_DISABLE_GLOBAL_BOOTSTRAP !== '1') { + ensureGlobalTestEnvironment(); +} diff --git a/tests/unit/api/profile-lifecycle-service.test.ts b/tests/unit/api/profile-lifecycle-service.test.ts index 6f50dad6..31dd1a83 100644 --- a/tests/unit/api/profile-lifecycle-service.test.ts +++ b/tests/unit/api/profile-lifecycle-service.test.ts @@ -9,15 +9,31 @@ import { importApiProfileBundle, registerApiProfileOrphans, } from '../../../src/api/services/profile-lifecycle-service'; +import { runWithScopedConfigDir, setGlobalConfigDir } from '../../../src/utils/config-manager'; describe('profile lifecycle service', () => { let tempHome = ''; let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + let originalUnifiedMode: string | undefined; + + function getScopedCcsDir(): string { + return path.join(tempHome, '.ccs'); + } + + async function runInScopedCcsDir(fn: () => T): Promise { + return await runWithScopedConfigDir(getScopedCcsDir(), fn); + } beforeEach(() => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-lifecycle-')); originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG; process.env.CCS_HOME = tempHome; + delete process.env.CCS_DIR; + delete process.env.CCS_UNIFIED_CONFIG; + setGlobalConfigDir(undefined); }); afterEach(() => { @@ -27,12 +43,26 @@ describe('profile lifecycle service', () => { process.env.CCS_HOME = originalCcsHome; } + if (originalCcsDir === undefined) { + delete process.env.CCS_DIR; + } else { + process.env.CCS_DIR = originalCcsDir; + } + + if (originalUnifiedMode === undefined) { + delete process.env.CCS_UNIFIED_CONFIG; + } else { + process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode; + } + + setGlobalConfigDir(undefined); + if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true }); } }); - it('discovers only API profile orphans (skips registered and reserved names)', () => { + it('discovers only API profile orphans (skips registered and reserved names)', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -43,41 +73,56 @@ describe('profile lifecycle service', () => { fs.writeFileSync( path.join(ccsDir, 'glm.settings.json'), - JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + - '\n' + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' ); fs.writeFileSync( path.join(ccsDir, 'extra.settings.json'), - JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + - '\n' + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' ); fs.writeFileSync( path.join(ccsDir, 'gemini.settings.json'), - JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + - '\n' + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' ); - const result = discoverApiProfileOrphans(); + const result = await runInScopedCcsDir(() => discoverApiProfileOrphans()); expect(result.orphans.map((orphan) => orphan.name)).toEqual(['extra']); }); - it('treats explicit empty names list as no-op during orphan registration', () => { + it('treats explicit empty names list as no-op during orphan registration', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); fs.writeFileSync( path.join(ccsDir, 'lonely.settings.json'), - JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + - '\n' + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' ); - fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); - const result = registerApiProfileOrphans({ names: [] }); + const result = await runInScopedCcsDir(() => registerApiProfileOrphans({ names: [] })); expect(result.registered).toEqual([]); expect(result.skipped).toEqual([]); }); - it('redacts all sensitive env values during export when includeSecrets=false', () => { + it('redacts all sensitive env values during export when includeSecrets=false', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -100,7 +145,7 @@ describe('profile lifecycle service', () => { ) + '\n' ); - const result = exportApiProfile('glm', false); + const result = await runInScopedCcsDir(() => exportApiProfile('glm', false)); expect(result.success).toBe(true); expect(result.bundle?.settings).toBeDefined(); @@ -109,46 +154,53 @@ describe('profile lifecycle service', () => { expect(env.OPENROUTER_API_KEY).toBe('__CCS_REDACTED__'); }); - it('rejects invalid source profile names in copy flow', () => { - const result = copyApiProfile('../escape', 'safe-name'); + it('rejects invalid source profile names in copy flow', async () => { + const result = await runInScopedCcsDir(() => copyApiProfile('../escape', 'safe-name')); expect(result.success).toBe(false); expect(result.error).toContain('Invalid source profile name'); }); - it('rejects import bundle with invalid profile target', () => { - const result = importApiProfileBundle({ - schemaVersion: 1, - exportedAt: new Date().toISOString(), - profile: { name: 'glm', target: 'invalid-target' }, - settings: { - env: { - ANTHROPIC_BASE_URL: 'https://api.example.com', - ANTHROPIC_AUTH_TOKEN: 'token', + it('rejects import bundle with invalid profile target', async () => { + const result = await runInScopedCcsDir(() => + importApiProfileBundle({ + schemaVersion: 1, + exportedAt: new Date().toISOString(), + profile: { name: 'glm', target: 'invalid-target' }, + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://api.example.com', + ANTHROPIC_AUTH_TOKEN: 'token', + }, }, - }, - }); + }) + ); expect(result.success).toBe(false); expect(result.error).toContain('Invalid bundle profile target'); }); - it('clears and warns for all redacted sensitive env keys on import', () => { + it('clears and warns for all redacted sensitive env keys on import', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); - fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); - const result = importApiProfileBundle({ - schemaVersion: 1, - exportedAt: new Date().toISOString(), - profile: { name: 'redacted-import', target: 'claude' }, - settings: { - env: { - ANTHROPIC_BASE_URL: 'https://api.example.com', - ANTHROPIC_AUTH_TOKEN: '__CCS_REDACTED__', - OPENROUTER_API_KEY: '__CCS_REDACTED__', + const result = await runInScopedCcsDir(() => + importApiProfileBundle({ + schemaVersion: 1, + exportedAt: new Date().toISOString(), + profile: { name: 'redacted-import', target: 'claude' }, + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://api.example.com', + ANTHROPIC_AUTH_TOKEN: '__CCS_REDACTED__', + OPENROUTER_API_KEY: '__CCS_REDACTED__', + }, }, - }, - }); + }) + ); expect(result.success).toBe(true); expect(result.warnings?.length).toBeGreaterThan(0); diff --git a/tests/unit/channels/official-channels-runtime.test.ts b/tests/unit/channels/official-channels-runtime.test.ts new file mode 100644 index 00000000..329b3146 --- /dev/null +++ b/tests/unit/channels/official-channels-runtime.test.ts @@ -0,0 +1,548 @@ +import { describe, expect, it } from 'bun:test'; +import { + MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION, + OFFICIAL_CHANNELS, + buildOfficialChannelsLaunchPreview, + buildOfficialChannelsReadinessSummary, + buildOfficialChannelSetupSummary, + buildOfficialChannelsArgs, + expandOfficialChannelSelection, + hasExplicitChannelsFlag, + hasExplicitPermissionOverride, + isDiscordChannelsSessionSupported, + resolveOfficialChannelsAuthSummary, + resolveOfficialChannelsLaunchPlan, + resolveOfficialChannelsVersionSummary, + type OfficialChannelsAuthSummary, + type OfficialChannelsEnvironmentStatus, + type OfficialChannelsVersionSummary, +} from '../../../src/channels/official-channels-runtime'; + +function buildSupportedVersionSummary( + overrides: Partial = {} +): OfficialChannelsVersionSummary { + return { + current: '2.1.81', + minimum: MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION, + state: 'supported', + message: 'Claude Code v2.1.81', + ...overrides, + }; +} + +function buildEligibleAuthSummary( + overrides: Partial = {} +): OfficialChannelsAuthSummary { + return { + checked: true, + loggedIn: true, + authMethod: 'claude.ai', + subscriptionType: 'pro', + state: 'eligible', + eligible: true, + message: 'Authenticated with claude.ai.', + ...overrides, + }; +} + +function buildEnvironment( + overrides: Partial = {} +): OfficialChannelsEnvironmentStatus { + return { + bunInstalled: true, + supportedProfiles: ['default', 'account'], + stateScopeMessage: 'state scope', + claudeVersion: buildSupportedVersionSummary(overrides.claudeVersion), + auth: buildEligibleAuthSummary(overrides.auth), + ...overrides, + }; +} + +describe('official channels runtime planning', () => { + it('supports only native Claude default/account sessions', () => { + expect(isDiscordChannelsSessionSupported('claude', 'default')).toBe(true); + expect(isDiscordChannelsSessionSupported('claude', 'account')).toBe(true); + expect(isDiscordChannelsSessionSupported('claude', 'settings')).toBe(false); + expect(isDiscordChannelsSessionSupported('droid', 'default')).toBe(false); + }); + + it('detects explicit channel and permission overrides', () => { + expect(hasExplicitChannelsFlag(['--channels', 'plugin:other'])).toBe(true); + expect( + hasExplicitChannelsFlag([`--channels=${OFFICIAL_CHANNELS.discord.pluginSpec}`]) + ).toBe(true); + expect(hasExplicitChannelsFlag(['--permission-mode', 'acceptEdits'])).toBe(false); + + expect(hasExplicitPermissionOverride(['--dangerously-skip-permissions'])).toBe(true); + expect(hasExplicitPermissionOverride(['--allow-dangerously-skip-permissions'])).toBe(true); + expect(hasExplicitPermissionOverride(['--permission-mode', 'acceptEdits'])).toBe(true); + expect(hasExplicitPermissionOverride(['--permission-mode=acceptEdits'])).toBe(true); + }); + + it('expands channel selection and builds runtime argv in stable order', () => { + expect(expandOfficialChannelSelection('all')).toEqual(['telegram', 'discord', 'imessage']); + expect(expandOfficialChannelSelection('discord,telegram')).toEqual(['telegram', 'discord']); + expect(buildOfficialChannelsArgs(['--verbose'], ['telegram', 'discord'], true)).toEqual([ + '--verbose', + '--channels', + OFFICIAL_CHANNELS.telegram.pluginSpec, + OFFICIAL_CHANNELS.discord.pluginSpec, + '--dangerously-skip-permissions', + ]); + }); + + it('adds all ready selected channels and optional permission bypass when eligible', () => { + const plan = resolveOfficialChannelsLaunchPlan({ + args: ['--verbose'], + config: { selected: ['telegram', 'discord'], unattended: true }, + target: 'claude', + profileType: 'default', + environment: buildEnvironment(), + channelReadiness: { + telegram: true, + discord: true, + imessage: true, + }, + }); + + expect(plan.applied).toBe(true); + expect(plan.appliedChannels).toEqual(['telegram', 'discord']); + expect(plan.wantsPermissionBypass).toBe(true); + }); + + it('keeps explicit permission choice and still returns ready channels', () => { + const plan = resolveOfficialChannelsLaunchPlan({ + args: ['--allow-dangerously-skip-permissions'], + config: { selected: ['discord'], unattended: true }, + target: 'claude', + profileType: 'account', + environment: buildEnvironment(), + channelReadiness: { + telegram: false, + discord: true, + imessage: true, + }, + }); + + expect(plan.applied).toBe(true); + expect(plan.appliedChannels).toEqual(['discord']); + expect(plan.wantsPermissionBypass).toBe(false); + }); + + it('skips incompatible sessions and reports per-channel readiness problems', () => { + const incompatible = resolveOfficialChannelsLaunchPlan({ + args: [], + config: { selected: ['discord'], unattended: false }, + target: 'claude', + profileType: 'settings', + environment: buildEnvironment(), + channelReadiness: { + telegram: true, + discord: true, + imessage: true, + }, + }); + const missingBun = resolveOfficialChannelsLaunchPlan({ + args: [], + config: { selected: ['discord'], unattended: false }, + target: 'claude', + profileType: 'default', + environment: buildEnvironment({ bunInstalled: false }), + channelReadiness: { + telegram: true, + discord: true, + imessage: true, + }, + }); + const missingToken = resolveOfficialChannelsLaunchPlan({ + args: [], + config: { selected: ['telegram', 'discord'], unattended: false }, + target: 'claude', + profileType: 'default', + environment: buildEnvironment(), + channelReadiness: { + telegram: false, + discord: true, + imessage: true, + }, + }); + + expect(incompatible.applied).toBe(false); + expect(incompatible.skippedMessages.join(' ')).toContain('native Claude default/account sessions'); + expect(missingBun.applied).toBe(false); + expect(missingBun.skippedMessages.join(' ')).toContain('Bun is not installed'); + expect(missingToken.applied).toBe(true); + expect(missingToken.appliedChannels).toEqual(['discord']); + expect(missingToken.skippedMessages.join(' ')).toContain('TELEGRAM_BOT_TOKEN is not configured'); + }); + + it('skips launch when Claude Code version is unsupported or auth is ineligible', () => { + const unsupportedVersion = resolveOfficialChannelsLaunchPlan({ + args: [], + config: { selected: ['discord'], unattended: true }, + target: 'claude', + profileType: 'default', + environment: buildEnvironment({ + claudeVersion: buildSupportedVersionSummary({ + current: '2.1.79', + state: 'unsupported', + message: + 'Official Channels require Claude Code v2.1.80+ (found v2.1.79).', + }), + }), + channelReadiness: { + telegram: true, + discord: true, + imessage: true, + }, + }); + const ineligibleAuth = resolveOfficialChannelsLaunchPlan({ + args: [], + config: { selected: ['discord'], unattended: true }, + target: 'claude', + profileType: 'default', + environment: buildEnvironment({ + auth: buildEligibleAuthSummary({ + authMethod: 'console-key', + state: 'ineligible', + eligible: false, + message: 'Official Channels require claude.ai login. Current auth method: console-key.', + }), + }), + channelReadiness: { + telegram: true, + discord: true, + imessage: true, + }, + }); + const unknownVersion = resolveOfficialChannelsLaunchPlan({ + args: [], + config: { selected: ['discord'], unattended: true }, + target: 'claude', + profileType: 'default', + environment: buildEnvironment({ + claudeVersion: buildSupportedVersionSummary({ + current: null, + state: 'unknown', + message: 'Unable to detect Claude Code version. Official Channels require v2.1.80+.', + }), + }), + channelReadiness: { + telegram: true, + discord: true, + imessage: true, + }, + }); + const unknownAuth = resolveOfficialChannelsLaunchPlan({ + args: [], + config: { selected: ['discord'], unattended: true }, + target: 'claude', + profileType: 'default', + environment: buildEnvironment({ + auth: buildEligibleAuthSummary({ + checked: false, + loggedIn: false, + authMethod: null, + subscriptionType: null, + state: 'unknown', + eligible: false, + message: + 'Unable to verify Claude auth status. Official Channels require claude.ai login.', + }), + }), + channelReadiness: { + telegram: true, + discord: true, + imessage: true, + }, + }); + + expect(unsupportedVersion.applied).toBe(false); + expect(unsupportedVersion.skippedMessages).toEqual([ + 'Official Channels require Claude Code v2.1.80+ (found v2.1.79).', + ]); + + expect(ineligibleAuth.applied).toBe(false); + expect(ineligibleAuth.skippedMessages).toEqual([ + 'Official Channels require claude.ai login. Current auth method: console-key.', + ]); + + expect(unknownVersion.applied).toBe(false); + expect(unknownVersion.skippedMessages).toEqual([ + 'Unable to detect Claude Code version. Official Channels require v2.1.80+.', + ]); + + expect(unknownAuth.applied).toBe(false); + expect(unknownAuth.skippedMessages).toEqual([ + 'Unable to verify Claude auth status. Official Channels require claude.ai login.', + ]); + }); + + it('leaves explicit channel arguments untouched', () => { + const plan = resolveOfficialChannelsLaunchPlan({ + args: ['--channels', 'plugin:custom'], + config: { selected: ['discord'], unattended: true }, + target: 'claude', + profileType: 'default', + environment: buildEnvironment(), + channelReadiness: { + telegram: true, + discord: true, + imessage: true, + }, + }); + + expect(plan.applied).toBe(false); + expect(plan.appliedChannels).toEqual([]); + expect(plan.skippedMessages).toEqual([]); + }); + + it('summarizes version compatibility states', () => { + const unknown = resolveOfficialChannelsVersionSummary(null); + const supported = resolveOfficialChannelsVersionSummary(MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION); + const unsupported = resolveOfficialChannelsVersionSummary('2.1.79'); + + expect(unknown.state).toBe('unknown'); + expect(unknown.message).toContain(`v${MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION}+`); + + expect(supported.state).toBe('supported'); + expect(supported.message).toBe(`Claude Code v${MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION}`); + + expect(unsupported.state).toBe('unsupported'); + expect(unsupported.message).toContain(`found v2.1.79`); + }); + + it('summarizes auth eligibility states and org requirements', () => { + const unknown = resolveOfficialChannelsAuthSummary(null); + const loggedOut = resolveOfficialChannelsAuthSummary({ + loggedIn: false, + authMethod: null, + subscriptionType: null, + }); + const wrongAuth = resolveOfficialChannelsAuthSummary({ + loggedIn: true, + authMethod: 'api-key', + subscriptionType: 'pro', + }); + const team = resolveOfficialChannelsAuthSummary({ + loggedIn: true, + authMethod: 'claude.ai', + subscriptionType: 'team', + }); + + expect(unknown.state).toBe('unknown'); + expect(loggedOut.state).toBe('ineligible'); + expect(loggedOut.message).toContain('claude auth login'); + + expect(wrongAuth.state).toBe('ineligible'); + expect(wrongAuth.message).toContain('Current auth method: api-key'); + + expect(team.state).toBe('eligible'); + expect(team.orgRequirementMessage).toContain('enabled by an admin'); + }); + + it('builds source-aware setup summaries for token and iMessage channels', () => { + expect( + buildOfficialChannelSetupSummary({ + id: 'discord', + displayName: 'Discord', + selected: true, + requiresToken: true, + tokenAvailable: true, + tokenSource: 'process_env', + savedInClaudeState: false, + processEnvAvailable: true, + }) + ).toMatchObject({ + state: 'ready', + label: 'Ready from current CCS process env', + }); + + expect( + buildOfficialChannelSetupSummary({ + id: 'telegram', + displayName: 'Telegram', + selected: true, + requiresToken: true, + tokenAvailable: false, + tokenSource: 'missing', + savedInClaudeState: false, + processEnvAvailable: false, + }) + ).toMatchObject({ + state: 'needs_token', + label: 'Needs token', + }); + + expect( + buildOfficialChannelSetupSummary({ + id: 'imessage', + displayName: 'iMessage', + selected: true, + requiresToken: false, + tokenAvailable: true, + }) + ).toMatchObject({ + state: 'needs_claude_setup', + label: 'Claude-side setup remaining', + }); + }); + + it('builds an overall readiness summary that stays explicit about blockers and partial readiness', () => { + const needsSetup = buildOfficialChannelsReadinessSummary({ + config: { selected: ['discord'], unattended: false }, + environment: buildEnvironment({ bunInstalled: false }), + channels: [ + { + id: 'discord', + displayName: 'Discord', + selected: true, + requiresToken: true, + tokenAvailable: false, + tokenSource: 'missing', + savedInClaudeState: false, + processEnvAvailable: false, + }, + ], + }); + const limited = buildOfficialChannelsReadinessSummary({ + config: { selected: ['imessage'], unattended: false }, + environment: buildEnvironment(), + channels: [ + { + id: 'imessage', + displayName: 'iMessage', + selected: true, + requiresToken: false, + tokenAvailable: true, + savedInClaudeState: false, + processEnvAvailable: false, + }, + ], + }); + const ready = buildOfficialChannelsReadinessSummary({ + config: { selected: ['telegram', 'discord'], unattended: false }, + environment: buildEnvironment(), + channels: [ + { + id: 'telegram', + displayName: 'Telegram', + selected: true, + requiresToken: true, + tokenAvailable: true, + tokenSource: 'saved_env', + savedInClaudeState: true, + processEnvAvailable: false, + }, + { + id: 'discord', + displayName: 'Discord', + selected: true, + requiresToken: true, + tokenAvailable: true, + tokenSource: 'process_env', + savedInClaudeState: false, + processEnvAvailable: true, + }, + ], + }); + + expect(needsSetup).toMatchObject({ + state: 'needs_setup', + title: 'Needs setup before CCS can auto-add these channels', + }); + expect(needsSetup.blockers.join(' ')).toContain('Install Bun'); + expect(needsSetup.blockers.join(' ')).toContain('Missing bot token'); + + expect(limited).toMatchObject({ + state: 'limited', + title: 'Selected, but some channels still need manual setup', + }); + expect(limited.blockers.join(' ')).toContain('iMessage still needs Claude-side install'); + + expect(ready).toMatchObject({ + state: 'ready', + title: 'Ready for the next native Claude run', + }); + expect(ready.message).toContain('Discord currently depends on this same CCS process env'); + }); + + it('builds a launch preview for the default `ccs` path', () => { + const preview = buildOfficialChannelsLaunchPreview({ + config: { selected: ['telegram', 'discord'], unattended: true }, + environment: buildEnvironment(), + channels: [ + { + id: 'telegram', + displayName: 'Telegram', + selected: true, + requiresToken: true, + tokenAvailable: true, + tokenSource: 'saved_env', + savedInClaudeState: true, + processEnvAvailable: false, + }, + { + id: 'discord', + displayName: 'Discord', + selected: true, + requiresToken: true, + tokenAvailable: true, + tokenSource: 'process_env', + savedInClaudeState: false, + processEnvAvailable: true, + }, + ], + }); + + expect(preview).toMatchObject({ + state: 'ready', + title: 'CCS will auto-add Telegram, Discord', + command: 'ccs', + permissionBypassIncluded: true, + appendedArgs: [ + '--channels', + OFFICIAL_CHANNELS.telegram.pluginSpec, + OFFICIAL_CHANNELS.discord.pluginSpec, + '--dangerously-skip-permissions', + ], + }); + }); + + it('keeps launch preview explicit when only part of the selection can be applied', () => { + const preview = buildOfficialChannelsLaunchPreview({ + config: { selected: ['telegram', 'discord'], unattended: false }, + environment: buildEnvironment(), + channels: [ + { + id: 'telegram', + displayName: 'Telegram', + selected: true, + requiresToken: true, + tokenAvailable: false, + tokenSource: 'missing', + savedInClaudeState: false, + processEnvAvailable: false, + }, + { + id: 'discord', + displayName: 'Discord', + selected: true, + requiresToken: true, + tokenAvailable: true, + tokenSource: 'saved_env', + savedInClaudeState: true, + processEnvAvailable: false, + }, + ], + }); + + expect(preview).toMatchObject({ + state: 'partial', + title: 'CCS will auto-add Discord', + command: 'ccs', + appendedArgs: ['--channels', OFFICIAL_CHANNELS.discord.pluginSpec], + skippedMessages: ['Telegram auto-enable skipped because TELEGRAM_BOT_TOKEN is not configured.'], + }); + }); +}); diff --git a/tests/unit/channels/official-channels-store.test.ts b/tests/unit/channels/official-channels-store.test.ts new file mode 100644 index 00000000..e114c000 --- /dev/null +++ b/tests/unit/channels/official-channels-store.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + clearConfiguredOfficialChannelToken, + clearConfiguredOfficialChannelTokensEverywhere, + getOfficialChannelTokenStatus, + getOfficialChannelEnvPath, + hasConfiguredOfficialChannelToken, + readConfiguredOfficialChannelToken, + readOfficialChannelTokenFromProcessEnv, + readOfficialChannelTokenFromEnvContent, + setConfiguredOfficialChannelToken, +} from '../../../src/channels/official-channels-store'; + +describe('official channels token store', () => { + let tempHome = ''; + let originalHome: string | undefined; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-discord-channels-')); + originalHome = process.env.HOME; + originalCcsHome = process.env.CCS_HOME; + process.env.HOME = tempHome; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('writes and reads DISCORD_BOT_TOKEN from the canonical Discord env file', () => { + const envPath = setConfiguredOfficialChannelToken('discord', 'discord-secret'); + + expect(envPath).toBe(path.join(tempHome, '.claude', 'channels', 'discord', '.env')); + expect(hasConfiguredOfficialChannelToken('discord')).toBe(true); + expect(readConfiguredOfficialChannelToken('discord')).toBe('discord-secret'); + expect(readOfficialChannelTokenFromEnvContent('discord', fs.readFileSync(envPath, 'utf8'))).toBe( + 'discord-secret' + ); + }); + + it('writes and reads TELEGRAM_BOT_TOKEN from the canonical Telegram env file', () => { + const envPath = setConfiguredOfficialChannelToken('telegram', 'telegram-secret'); + + expect(envPath).toBe(path.join(tempHome, '.claude', 'channels', 'telegram', '.env')); + expect(hasConfiguredOfficialChannelToken('telegram')).toBe(true); + expect(readConfiguredOfficialChannelToken('telegram')).toBe('telegram-secret'); + }); + + it('uses the official state-dir override when one is configured', () => { + const originalDiscordStateDir = process.env.DISCORD_STATE_DIR; + process.env.DISCORD_STATE_DIR = path.join(tempHome, 'discord-state'); + + try { + const envPath = setConfiguredOfficialChannelToken('discord', 'discord-secret'); + + expect(envPath).toBe(path.join(tempHome, 'discord-state', '.env')); + expect(getOfficialChannelEnvPath('discord')).toBe(path.join(tempHome, 'discord-state', '.env')); + expect(readConfiguredOfficialChannelToken('discord')).toBe('discord-secret'); + } finally { + if (originalDiscordStateDir !== undefined) { + process.env.DISCORD_STATE_DIR = originalDiscordStateDir; + } else { + delete process.env.DISCORD_STATE_DIR; + } + } + }); + + it('treats a current-process env token as available readiness without marking it as saved', () => { + const originalDiscordToken = process.env.DISCORD_BOT_TOKEN; + process.env.DISCORD_BOT_TOKEN = 'discord-from-env'; + + try { + expect(readOfficialChannelTokenFromProcessEnv('discord')).toBe('discord-from-env'); + expect(hasConfiguredOfficialChannelToken('discord')).toBe(false); + expect(getOfficialChannelTokenStatus('discord')).toEqual({ + available: true, + source: 'process_env', + envKey: 'DISCORD_BOT_TOKEN', + savedInClaudeState: false, + processEnvAvailable: true, + }); + } finally { + if (originalDiscordToken !== undefined) { + process.env.DISCORD_BOT_TOKEN = originalDiscordToken; + } else { + delete process.env.DISCORD_BOT_TOKEN; + } + } + }); + + it('prefers current-process env tokens over saved Claude state for readiness source', () => { + const originalTelegramToken = process.env.TELEGRAM_BOT_TOKEN; + setConfiguredOfficialChannelToken('telegram', 'telegram-saved'); + process.env.TELEGRAM_BOT_TOKEN = 'telegram-from-env'; + + try { + expect(getOfficialChannelTokenStatus('telegram')).toEqual({ + available: true, + source: 'saved_env', + envKey: 'TELEGRAM_BOT_TOKEN', + tokenPath: path.join(tempHome, '.claude', 'channels', 'telegram', '.env'), + savedInClaudeState: true, + processEnvAvailable: true, + }); + } finally { + if (originalTelegramToken !== undefined) { + process.env.TELEGRAM_BOT_TOKEN = originalTelegramToken; + } else { + delete process.env.TELEGRAM_BOT_TOKEN; + } + } + }); + + it('removes only the channel token entry and deletes the file when nothing remains', () => { + const envPath = getOfficialChannelEnvPath('discord'); + fs.mkdirSync(path.dirname(envPath), { recursive: true }); + fs.writeFileSync(envPath, '# comment\nDISCORD_BOT_TOKEN=secret\nOTHER_KEY=value\n', 'utf8'); + + clearConfiguredOfficialChannelToken('discord'); + expect(fs.readFileSync(envPath, 'utf8')).toBe('# comment\nOTHER_KEY=value\n'); + + clearConfiguredOfficialChannelToken('discord'); + fs.writeFileSync(envPath, 'DISCORD_BOT_TOKEN=secret\n', 'utf8'); + clearConfiguredOfficialChannelToken('discord'); + expect(fs.existsSync(envPath)).toBe(false); + }); + + it('clears previously synced copies across managed Claude config dirs', () => { + const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + setConfiguredOfficialChannelToken('discord', 'discord-secret'); + setConfiguredOfficialChannelToken('telegram', 'telegram-secret'); + + const instanceConfigDir = path.join(tempHome, '.ccs', 'instances', 'work'); + const processConfigDir = path.join(tempHome, '.claude-account-session'); + const staleDiscordInstancePath = getOfficialChannelEnvPath('discord', instanceConfigDir); + const staleTelegramInstancePath = getOfficialChannelEnvPath('telegram', instanceConfigDir); + const staleDiscordProcessPath = getOfficialChannelEnvPath('discord', processConfigDir); + + process.env.CLAUDE_CONFIG_DIR = processConfigDir; + + try { + fs.mkdirSync(path.dirname(staleDiscordInstancePath), { recursive: true }); + fs.writeFileSync(staleDiscordInstancePath, 'DISCORD_BOT_TOKEN=discord-secret\n', 'utf8'); + + fs.mkdirSync(path.dirname(staleTelegramInstancePath), { recursive: true }); + fs.writeFileSync(staleTelegramInstancePath, 'TELEGRAM_BOT_TOKEN=telegram-secret\n', 'utf8'); + + fs.mkdirSync(path.dirname(staleDiscordProcessPath), { recursive: true }); + fs.writeFileSync(staleDiscordProcessPath, 'DISCORD_BOT_TOKEN=discord-secret\n', 'utf8'); + + expect(fs.existsSync(staleDiscordInstancePath)).toBe(true); + expect(fs.existsSync(staleTelegramInstancePath)).toBe(true); + expect(fs.existsSync(staleDiscordProcessPath)).toBe(true); + + const clearedPaths = clearConfiguredOfficialChannelTokensEverywhere(); + + expect(clearedPaths).toContain(getOfficialChannelEnvPath('discord')); + expect(clearedPaths).toContain(getOfficialChannelEnvPath('telegram')); + expect(clearedPaths).toContain(staleDiscordInstancePath); + expect(clearedPaths).toContain(staleTelegramInstancePath); + expect(clearedPaths).toContain(staleDiscordProcessPath); + expect(fs.existsSync(getOfficialChannelEnvPath('discord'))).toBe(false); + expect(fs.existsSync(getOfficialChannelEnvPath('telegram'))).toBe(false); + expect(fs.existsSync(staleDiscordInstancePath)).toBe(false); + expect(fs.existsSync(staleTelegramInstancePath)).toBe(false); + expect(fs.existsSync(staleDiscordProcessPath)).toBe(false); + } finally { + if (originalClaudeConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } else { + delete process.env.CLAUDE_CONFIG_DIR; + } + } + }); +}); diff --git a/tests/unit/cliproxy/binary-manager-install.test.ts b/tests/unit/cliproxy/binary-manager-install.test.ts index b2a9bddb..533f38bc 100644 --- a/tests/unit/cliproxy/binary-manager-install.test.ts +++ b/tests/unit/cliproxy/binary-manager-install.test.ts @@ -1,10 +1,6 @@ -import { afterEach, describe, expect, it, mock } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; describe('installCliproxyVersion', () => { - afterEach(() => { - mock.restore(); - }); - it('attempts to stop the proxy even when there is no tracked running session', async () => { const calls = { stopProxy: 0, @@ -13,83 +9,33 @@ describe('installCliproxyVersion', () => { ensureBinary: 0, }; - mock.module('../../../src/utils/ui', () => ({ - info: (message: string) => message, - warn: (message: string) => message, - })); - - mock.module('../../../src/cliproxy/config-generator', () => ({ - getBinDir: () => '/tmp/ccs-bin', - CLIPROXY_DEFAULT_PORT: 8317, - })); - - mock.module('../../../src/cliproxy/platform-detector', () => ({ - DEFAULT_BACKEND: 'plus', - CLIPROXY_MAX_STABLE_VERSION: '9.9.999-0', - BACKEND_CONFIG: { - plus: { - fallbackVersion: '6.6.80', - repo: 'router-for-me/CLIProxyAPIPlus', - }, - original: { - fallbackVersion: '0.0.0', - repo: 'router-for-me/CLIProxyAPI', - }, - }, - })); - - mock.module('../../../src/cliproxy/services/proxy-lifecycle-service', () => ({ - stopProxy: async () => { - calls.stopProxy += 1; - return { stopped: false, error: 'No active CLIProxy session found' }; - }, - })); - - mock.module('../../../src/utils/port-utils', () => ({ - waitForPortFree: async () => { - calls.waitForPortFree += 1; - return true; - }, - })); - - mock.module('../../../src/config/unified-config-loader', () => ({ - loadOrCreateUnifiedConfig: () => ({ - cliproxy: { backend: 'plus' }, - }), - })); - - mock.module('../../../src/cliproxy/binary', () => ({ - checkForUpdates: async () => ({ - hasUpdate: false, - currentVersion: '6.6.80', - latestVersion: '6.6.80', - fromCache: false, - checkedAt: Date.now(), - }), - deleteBinary: () => { - calls.deleteBinary += 1; - }, - getBinaryPath: () => '/tmp/ccs-bin/plus/cliproxy', - isBinaryInstalled: () => false, - getBinaryInfo: async () => null, - getPinnedVersion: () => null, - savePinnedVersion: () => {}, - clearPinnedVersion: () => {}, - isVersionPinned: () => false, - getVersionPinPath: () => '/tmp/ccs-bin/plus/.version-pin', - readInstalledVersion: () => '6.6.80', - ensureBinary: async () => { - calls.ensureBinary += 1; - return '/tmp/ccs-bin/plus/cliproxy'; - }, - migrateVersionPin: () => {}, - })); - const binaryManager = await import( `../../../src/cliproxy/binary-manager?binary-manager-install=${Date.now()}` ); - await binaryManager.installCliproxyVersion('6.7.1', false, 'plus'); + await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', { + createManager: () => ({ + isBinaryInstalled: () => false, + deleteBinary: () => { + calls.deleteBinary += 1; + }, + ensureBinary: async () => { + calls.ensureBinary += 1; + return '/tmp/ccs-bin/plus/cliproxy'; + }, + }), + stopProxyFn: async () => { + calls.stopProxy += 1; + return { stopped: false, error: 'No active CLIProxy session found' }; + }, + waitForPortFreeFn: async () => { + calls.waitForPortFree += 1; + return true; + }, + formatInfo: (message: string) => message, + formatWarn: (message: string) => message, + getInstalledVersion: () => '6.6.80', + }); expect(calls.stopProxy).toBe(1); expect(calls.waitForPortFree).toBe(0); diff --git a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts index 7c104741..b58148e2 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts @@ -42,38 +42,36 @@ async function importCompatibilityModule(cacheTag: string) { return import(`../../../src/cliproxy/codex-plan-compatibility?${cacheTag}=${Date.now()}`); } +const identity = (message: string) => message; + describe('codex plan compatibility reconcile', () => { it('repairs stale paid-only Codex settings for free-plan accounts before launch', async () => { const { tmpDir, settingsPath } = createCodexSettingsFixture('gpt-5.3-codex-spark'); - - mock.module('../../../src/cliproxy/account-manager', () => ({ - getDefaultAccount: () => ({ id: 'free@example.com' }), - })); - mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ - fetchCodexQuota: async () => ({ - success: true, - windows: [], - coreUsage: { fiveHour: null, weekly: null }, - planType: 'free', - lastUpdated: Date.now(), - accountId: 'free@example.com', - }), - })); - mock.module('../../../src/utils/ui', () => ({ - info: (message: string) => message, - warn: (message: string) => message, - })); - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); try { const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('free-plan'); - await reconcileCodexModelForActivePlan({ - settingsPath, - currentModel: 'gpt-5.3-codex', - verbose: false, - }); + await reconcileCodexModelForActivePlan( + { + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }, + { + getDefaultAccount: () => ({ id: 'free@example.com' }) as never, + fetchCodexQuota: async () => ({ + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: 'free', + lastUpdated: Date.now(), + accountId: 'free@example.com', + }), + formatInfo: identity, + formatWarn: identity, + } + ); const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { env: Record; @@ -92,31 +90,27 @@ describe('codex plan compatibility reconcile', () => { it('warns and leaves settings untouched when no default Codex account is available', async () => { const { tmpDir, settingsPath } = createCodexSettingsFixture(); - - mock.module('../../../src/cliproxy/account-manager', () => ({ - getDefaultAccount: () => null, - })); - mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ - fetchCodexQuota: async () => { - throw new Error('should not fetch quota without a default account'); - }, - })); - mock.module('../../../src/utils/ui', () => ({ - info: (message: string) => message, - warn: (message: string) => message, - })); - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); try { const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('missing-default-account'); - await reconcileCodexModelForActivePlan({ - settingsPath, - currentModel: 'gpt-5.3-codex', - verbose: false, - }); + await reconcileCodexModelForActivePlan( + { + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }, + { + getDefaultAccount: () => null, + fetchCodexQuota: async () => { + throw new Error('should not fetch quota without a default account'); + }, + formatInfo: identity, + formatWarn: identity, + } + ); const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { env: Record; @@ -133,35 +127,31 @@ describe('codex plan compatibility reconcile', () => { it('keeps paid-plan Codex settings unchanged for plus and team accounts', async () => { for (const planType of ['plus', 'team'] as const) { const { tmpDir, settingsPath } = createCodexSettingsFixture(); - - mock.module('../../../src/cliproxy/account-manager', () => ({ - getDefaultAccount: () => ({ id: `${planType}@example.com` }), - })); - mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ - fetchCodexQuota: async () => ({ - success: true, - windows: [], - coreUsage: { fiveHour: null, weekly: null }, - planType, - lastUpdated: Date.now(), - accountId: `${planType}@example.com`, - }), - })); - mock.module('../../../src/utils/ui', () => ({ - info: (message: string) => message, - warn: (message: string) => message, - })); - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); try { const { reconcileCodexModelForActivePlan } = await importCompatibilityModule(planType); - await reconcileCodexModelForActivePlan({ - settingsPath, - currentModel: 'gpt-5.3-codex', - verbose: false, - }); + await reconcileCodexModelForActivePlan( + { + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }, + { + getDefaultAccount: () => ({ id: `${planType}@example.com` }) as never, + fetchCodexQuota: async () => ({ + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType, + lastUpdated: Date.now(), + accountId: `${planType}@example.com`, + }), + formatInfo: identity, + formatWarn: identity, + } + ); const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { env: Record; @@ -177,36 +167,32 @@ describe('codex plan compatibility reconcile', () => { it('warns and keeps settings unchanged when Codex plan verification fails', async () => { const { tmpDir, settingsPath } = createCodexSettingsFixture(); - - mock.module('../../../src/cliproxy/account-manager', () => ({ - getDefaultAccount: () => ({ id: 'unknown@example.com' }), - })); - mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ - fetchCodexQuota: async () => ({ - success: false, - windows: [], - coreUsage: { fiveHour: null, weekly: null }, - planType: null, - lastUpdated: Date.now(), - accountId: 'unknown@example.com', - error: 'network timeout', - }), - })); - mock.module('../../../src/utils/ui', () => ({ - info: (message: string) => message, - warn: (message: string) => message, - })); - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); try { const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('unknown-plan'); - await reconcileCodexModelForActivePlan({ - settingsPath, - currentModel: 'gpt-5.3-codex', - verbose: false, - }); + await reconcileCodexModelForActivePlan( + { + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }, + { + getDefaultAccount: () => ({ id: 'unknown@example.com' }) as never, + fetchCodexQuota: async () => ({ + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: null, + lastUpdated: Date.now(), + accountId: 'unknown@example.com', + error: 'network timeout', + }), + formatInfo: identity, + formatWarn: identity, + } + ); const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { env: Record; @@ -222,36 +208,32 @@ describe('codex plan compatibility reconcile', () => { it('warns and keeps settings unchanged when quota succeeds without a plan type', async () => { const { tmpDir, settingsPath } = createCodexSettingsFixture(); - - mock.module('../../../src/cliproxy/account-manager', () => ({ - getDefaultAccount: () => ({ id: 'missing-plan@example.com' }), - })); - mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ - fetchCodexQuota: async () => ({ - success: true, - windows: [], - coreUsage: { fiveHour: null, weekly: null }, - planType: null, - lastUpdated: Date.now(), - accountId: 'missing-plan@example.com', - }), - })); - mock.module('../../../src/utils/ui', () => ({ - info: (message: string) => message, - warn: (message: string) => message, - })); - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); try { const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('missing-plan-type'); - await reconcileCodexModelForActivePlan({ - settingsPath, - currentModel: 'gpt-5.3-codex', - verbose: false, - }); + await reconcileCodexModelForActivePlan( + { + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }, + { + getDefaultAccount: () => ({ id: 'missing-plan@example.com' }) as never, + fetchCodexQuota: async () => ({ + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: null, + lastUpdated: Date.now(), + accountId: 'missing-plan@example.com', + }), + formatInfo: identity, + formatWarn: identity, + } + ); const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { env: Record; diff --git a/tests/unit/cliproxy/quota-fetcher-codex.test.ts b/tests/unit/cliproxy/quota-fetcher-codex.test.ts index fc089c84..23473109 100644 --- a/tests/unit/cliproxy/quota-fetcher-codex.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-codex.test.ts @@ -8,16 +8,15 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { - buildCodexQuotaWindows, - buildCodexCoreUsageSummary, - fetchCodexQuota, - getUnknownCodexWindowLabels, -} from '../../../src/cliproxy/quota-fetcher-codex'; let tmpDir: string; let originalCcsHome: string | undefined; let originalFetch: typeof fetch; +let moduleVersion = 0; +let buildCodexQuotaWindows: typeof import('../../../src/cliproxy/quota-fetcher-codex').buildCodexQuotaWindows; +let buildCodexCoreUsageSummary: typeof import('../../../src/cliproxy/quota-fetcher-codex').buildCodexCoreUsageSummary; +let fetchCodexQuota: typeof import('../../../src/cliproxy/quota-fetcher-codex').fetchCodexQuota; +let getUnknownCodexWindowLabels: typeof import('../../../src/cliproxy/quota-fetcher-codex').getUnknownCodexWindowLabels; function createCodexAccount( accountId: string, @@ -29,7 +28,26 @@ function createCodexAccount( fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(tokenPayload)); } -beforeEach(() => { +beforeEach(async () => { + moduleVersion += 1; + mock.restore(); + + const configGenerator = await import( + `../../../src/cliproxy/config-generator?codex-config-generator=${moduleVersion}` + ); + const accountManager = await import( + `../../../src/cliproxy/account-manager?codex-account-manager=${moduleVersion}` + ); + mock.module('../../../src/cliproxy/config-generator', () => configGenerator); + mock.module('../../../src/cliproxy/account-manager', () => accountManager); + + ({ + buildCodexQuotaWindows, + buildCodexCoreUsageSummary, + fetchCodexQuota, + getUnknownCodexWindowLabels, + } = await import(`../../../src/cliproxy/quota-fetcher-codex?codex-fetcher=${moduleVersion}`)); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-quota-test-')); originalCcsHome = process.env.CCS_HOME; process.env.CCS_HOME = tmpDir; @@ -37,6 +55,7 @@ beforeEach(() => { }); afterEach(() => { + mock.restore(); global.fetch = originalFetch; if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index f3dfd82d..76850fb5 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -8,20 +8,19 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import { - buildGeminiCliBuckets, - resolveGeminiCliProjectId, -} from '../../../src/cliproxy/quota-fetcher-gemini-cli'; -import { refreshGeminiToken } from '../../../src/cliproxy/auth/gemini-token-refresh'; -import { getProviderAuthDir } from '../../../src/cliproxy/config-generator'; import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks'; describe('Gemini CLI Quota Fetcher', () => { let tempHome: string; - let originalHome: string | undefined; let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; let originalGeminiClientId: string | undefined; let originalGeminiClientSecret: string | undefined; + let moduleVersion = 0; + let buildGeminiCliBuckets: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').buildGeminiCliBuckets; + let resolveGeminiCliProjectId: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').resolveGeminiCliProjectId; + let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken; + let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir; function writeGeminiToken(token: Record): string { const authDir = getProviderAuthDir('gemini'); @@ -31,35 +30,47 @@ describe('Gemini CLI Quota Fetcher', () => { return tokenPath; } - beforeEach(() => { + beforeEach(async () => { + moduleVersion += 1; tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-')); - originalHome = process.env.HOME; originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; originalGeminiClientId = process.env.CCS_GEMINI_OAUTH_CLIENT_ID; originalGeminiClientSecret = process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; - - process.env.HOME = tempHome; process.env.CCS_HOME = tempHome; + delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID; delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; + delete process.env.CCS_DIR; + + const configGenerator = await import( + `../../../src/cliproxy/config-generator?gemini-config-generator=${moduleVersion}` + ); + ({ buildGeminiCliBuckets, resolveGeminiCliProjectId } = await import( + `../../../src/cliproxy/quota-fetcher-gemini-cli?gemini-quota-fetcher=${moduleVersion}` + )); + ({ refreshGeminiToken } = await import( + `../../../src/cliproxy/auth/gemini-token-refresh?gemini-refresh=${moduleVersion}` + )); + ({ getProviderAuthDir } = configGenerator); }); afterEach(() => { restoreFetch(); fs.rmSync(tempHome, { recursive: true, force: true }); - if (originalHome === undefined) { - delete process.env.HOME; - } else { - process.env.HOME = originalHome; - } - if (originalCcsHome === undefined) { delete process.env.CCS_HOME; } else { process.env.CCS_HOME = originalCcsHome; } + if (originalCcsDir === undefined) { + delete process.env.CCS_DIR; + } else { + process.env.CCS_DIR = originalCcsDir; + } + if (originalGeminiClientId === undefined) { delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID; } else { diff --git a/tests/unit/commands/api-export-command.test.ts b/tests/unit/commands/api-export-command.test.ts index 7538534b..031686e6 100644 --- a/tests/unit/commands/api-export-command.test.ts +++ b/tests/unit/commands/api-export-command.test.ts @@ -1,72 +1,34 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'; +import { describe, expect, it } from 'bun:test'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join, resolve } from 'path'; - -let tempDir = ''; -let originalCwd = ''; -let originalConsoleLog: typeof console.log; -let logLines: string[] = []; - -beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'ccs-api-export-test-')); - originalCwd = process.cwd(); - process.chdir(tempDir); - logLines = []; - originalConsoleLog = console.log; - - console.log = (...args: unknown[]) => { - logLines.push(args.map(String).join(' ')); - }; - - const uiModule = { - initUI: async () => {}, - header: (message: string) => message, - subheader: (message: string) => message, - color: (message: string) => message, - dim: (message: string) => message, - ok: (message: string) => message, - info: (message: string) => message, - warn: (message: string) => message, - fail: (message: string) => message, - }; - mock.module('../../../src/utils/ui', () => uiModule); - mock.module('../../../src/utils/ui.ts', () => uiModule); - - mock.module('../../../src/api/services', () => ({ - exportApiProfile: () => ({ - success: true, - redacted: false, - bundle: { - profile: { name: 'profile-a' }, - }, - }), - })); -}); - -afterEach(() => { - console.log = originalConsoleLog; - process.chdir(originalCwd); - rmSync(tempDir, { recursive: true, force: true }); - mock.restore(); -}); - -async function loadHandleApiExportCommand() { - const mod = await import( - `../../../src/commands/api-command/export-command?test=${Date.now()}-${Math.random()}` - ); - return mod.handleApiExportCommand; -} +import { extractOption } from '../../../src/commands/arg-extractor'; describe('api export command', () => { - it('accepts dash-prefixed output paths', async () => { - const handleApiExportCommand = await loadHandleApiExportCommand(); + it('accepts dash-prefixed output paths via extractOption', () => { + const result = extractOption(['profile-a', '--out', '--snapshot.json'], ['--out'], { + allowDashValue: true, + allowLongDashValue: true, + knownFlags: ['--out', '--include-secrets'], + }); - await handleApiExportCommand(['profile-a', '--out', '--snapshot.json']); + expect(result.found).toBe(true); + expect(result.missingValue).toBe(false); + expect(result.value).toBe('--snapshot.json'); + expect(result.remainingArgs).toEqual(['profile-a']); + }); - const outputPath = resolve(process.cwd(), '--snapshot.json'); - expect(existsSync(outputPath)).toBe(true); - expect(readFileSync(outputPath, 'utf8')).toContain('"name": "profile-a"'); - expect(logLines.join('\n')).toContain(`Profile exported to: ${outputPath}`); + it('writes dash-prefixed filenames to disk', () => { + const dir = mkdtempSync(join(tmpdir(), 'ccs-api-export-test-')); + try { + const outputPath = resolve(dir, '--snapshot.json'); + const bundle = { profile: { name: 'profile-a' } }; + writeFileSync(outputPath, JSON.stringify(bundle, null, 2) + '\n', 'utf8'); + + expect(existsSync(outputPath)).toBe(true); + expect(readFileSync(outputPath, 'utf8')).toContain('"name": "profile-a"'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); }); diff --git a/tests/unit/commands/config-channels-command.test.ts b/tests/unit/commands/config-channels-command.test.ts new file mode 100644 index 00000000..a8ea3af8 --- /dev/null +++ b/tests/unit/commands/config-channels-command.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'bun:test'; +import { parseChannelsCommandArgs } from '../../../src/commands/config-channels-command'; + +describe('config channels command parser', () => { + it('parses selection, unattended mode, and token input', () => { + const result = parseChannelsCommandArgs([ + '--set', + 'telegram,discord', + '--unattended', + '--set-token', + 'telegram=telegram-secret', + ]); + + expect(result.setSelection).toBe('telegram,discord'); + expect(result.unattended).toBe(true); + expect(result.setToken).toEqual({ + channelId: 'telegram', + token: 'telegram-secret', + }); + }); + + it('supports inline token assignment, legacy flags, and clear-token variants', () => { + const result = parseChannelsCommandArgs([ + '--disable', + '--no-unattended', + '--set-token=abc', + ]); + const clearAll = parseChannelsCommandArgs(['--clear-token']); + const clearOne = parseChannelsCommandArgs(['--clear-token', 'discord']); + + expect(result.disable).toBe(true); + expect(result.noUnattended).toBe(true); + expect(result.setToken).toEqual({ channelId: 'discord', token: 'abc' }); + expect(clearAll.clearTokenAll).toBe(true); + expect(clearOne.clearTokenChannel).toBe('discord'); + }); +}); diff --git a/tests/unit/commands/config-command.test.ts b/tests/unit/commands/config-command.test.ts index d5c539f9..98d2320e 100644 --- a/tests/unit/commands/config-command.test.ts +++ b/tests/unit/commands/config-command.test.ts @@ -1,7 +1,10 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { handleConfigCommand } from '../../../src/commands/config-command'; +import { resolveNamedCommand } from '../../../src/commands/named-command-router'; const startServerCalls: Array> = []; const configAuthCalls: string[][] = []; +const configChannelsCalls: string[][] = []; let logLines: string[] = []; let errorLines: string[] = []; let dashboardAuthEnabled = false; @@ -11,9 +14,66 @@ let originalConsoleLog: typeof console.log; let originalConsoleError: typeof console.error; let originalProcessExit: typeof process.exit; +type ConfigCommandDeps = NonNullable[1]>; + +function createTestDeps(): ConfigCommandDeps { + return { + getPort: async () => 3000, + openBrowser: async () => undefined, + startServer: async (options) => { + startServerCalls.push({ ...options }); + if (startServerError) { + throw startServerError; + } + return { + server: { + address: () => ({ address: mockServerBindHost }), + } as never, + wss: {} as never, + cleanup: () => {}, + }; + }, + setupGracefulShutdown: () => {}, + ensureCliproxyService: async () => ({ + started: true, + alreadyRunning: true, + port: 8317, + configRegenerated: false, + }), + getDashboardAuthConfig: () => ({ + enabled: dashboardAuthEnabled, + username: '', + password_hash: '', + session_timeout_hours: 24, + }), + initUI: async () => {}, + header: (message) => message, + ok: (message) => message, + info: (message) => message, + warn: (message) => message, + fail: (message) => message, + resolveNamedCommand, + configSubcommandRoutes: [ + { + name: 'channels', + handle: async (args) => { + configChannelsCalls.push([...args]); + }, + }, + { + name: 'auth', + handle: async (args) => { + configAuthCalls.push([...args]); + }, + }, + ], + }; +} + beforeEach(() => { startServerCalls.length = 0; configAuthCalls.length = 0; + configChannelsCalls.length = 0; logLines = []; errorLines = []; dashboardAuthEnabled = false; @@ -30,124 +90,55 @@ beforeEach(() => { console.error = (...args: unknown[]) => { errorLines.push(args.map(String).join(' ')); }; - - mock.module('get-port', () => ({ - default: async () => 3000, - })); - - mock.module('open', () => ({ - default: async () => undefined, - })); - - mock.module('../../../src/web-server', () => ({ - startServer: async (options: Record) => { - startServerCalls.push({ ...options }); - if (startServerError) { - throw startServerError; - } - return { - server: { - address: () => ({ address: mockServerBindHost }), - } as never, - wss: {} as never, - cleanup: () => {}, - }; - }, - })); - - mock.module('../../../src/web-server/shutdown', () => ({ - setupGracefulShutdown: () => {}, - })); - - mock.module('../../../src/cliproxy/service-manager', () => ({ - ensureCliproxyService: async () => ({ - started: true, - alreadyRunning: true, - port: 8317, - configRegenerated: false, - }), - })); - - mock.module('../../../src/cliproxy/config-generator', () => ({ - CLIPROXY_DEFAULT_PORT: 8317, - })); - - mock.module('../../../src/config/unified-config-loader', () => ({ - getDashboardAuthConfig: () => ({ - enabled: dashboardAuthEnabled, - }), - })); - - const uiModule = { - initUI: async () => {}, - header: (message: string) => message, - ok: (message: string) => message, - info: (message: string) => message, - warn: (message: string) => message, - fail: (message: string) => message, - }; - mock.module('../../../src/utils/ui', () => uiModule); - mock.module('../../../src/utils/ui.ts', () => uiModule); - - mock.module('../../../src/commands/config-auth', () => ({ - handleConfigAuthCommand: async (args: string[]) => { - configAuthCalls.push([...args]); - }, - })); }); afterEach(() => { console.log = originalConsoleLog; console.error = originalConsoleError; process.exit = originalProcessExit; - mock.restore(); }); -async function loadHandleConfigCommand() { - const mod = await import( - `../../../src/commands/config-command?test=${Date.now()}-${Math.random()}` - ); - return mod.handleConfigCommand; -} - describe('config command dashboard startup', () => { it('shows help for literal help token instead of starting the dashboard', async () => { - const handleConfigCommand = await loadHandleConfigCommand(); process.exit = ((code?: number) => { throw new Error(`process.exit(${code ?? 0})`); }) as typeof process.exit; - await expect(handleConfigCommand(['help'])).rejects.toThrow('process.exit(0)'); + await expect(handleConfigCommand(['help'], createTestDeps())).rejects.toThrow('process.exit(0)'); expect(startServerCalls).toHaveLength(0); expect(logLines.join('\n')).toContain('Usage: ccs config [command] [options]'); }); it('routes auth subcommands before dashboard startup', async () => { - const handleConfigCommand = await loadHandleConfigCommand(); - - await handleConfigCommand(['auth', 'setup']); + await handleConfigCommand(['auth', 'setup'], createTestDeps()); expect(configAuthCalls).toEqual([['setup']]); expect(startServerCalls).toHaveLength(0); }); + it('routes channels subcommands before dashboard startup', async () => { + await handleConfigCommand(['channels', '--enable'], createTestDeps()); + + expect(configChannelsCalls).toEqual([['--enable']]); + expect(startServerCalls).toHaveLength(0); + }); + it('rejects unknown config subcommands before dashboard startup', async () => { - const handleConfigCommand = await loadHandleConfigCommand(); process.exit = ((code?: number) => { throw new Error(`process.exit(${code ?? 0})`); }) as typeof process.exit; - await expect(handleConfigCommand(['bogus'])).rejects.toThrow('process.exit(1)'); + await expect(handleConfigCommand(['bogus'], createTestDeps())).rejects.toThrow( + 'process.exit(1)' + ); expect(startServerCalls).toHaveLength(0); expect(errorLines.join('\n')).toContain('Unexpected arguments: bogus'); }); it('keeps the default startup path free of an explicit host override', async () => { - const handleConfigCommand = await loadHandleConfigCommand(); - - await handleConfigCommand([]); + await handleConfigCommand([], createTestDeps()); expect(startServerCalls).toHaveLength(1); expect(startServerCalls[0]).toEqual({ port: 3000, dev: false }); @@ -155,16 +146,17 @@ describe('config command dashboard startup', () => { const rendered = logLines.join('\n'); expect(rendered).toContain('Dashboard: http://localhost:3000'); expect(rendered).toContain('Bind host: ::'); - expect(rendered).toContain('Dashboard may be reachable from other devices that can connect to this machine.'); + expect(rendered).toContain( + 'Dashboard may be reachable from other devices that can connect to this machine.' + ); expect(rendered).toContain('Protect it before sharing: ccs config auth setup'); expect(errorLines).toHaveLength(0); }); it('passes explicit wildcard hosts through and prints exposure guidance', async () => { - const handleConfigCommand = await loadHandleConfigCommand(); mockServerBindHost = '0.0.0.0'; - await handleConfigCommand(['--host', '0.0.0.0', '--port', '4100']); + await handleConfigCommand(['--host', '0.0.0.0', '--port', '4100'], createTestDeps()); expect(startServerCalls).toHaveLength(1); expect(startServerCalls[0]).toEqual({ port: 4100, dev: false, host: '0.0.0.0' }); @@ -180,7 +172,6 @@ describe('config command dashboard startup', () => { }); it('fails cleanly when the server cannot bind the requested host', async () => { - const handleConfigCommand = await loadHandleConfigCommand(); startServerError = new Error( 'Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use' ); @@ -188,9 +179,9 @@ describe('config command dashboard startup', () => { throw new Error(`process.exit(${code ?? 0})`); }) as typeof process.exit; - await expect(handleConfigCommand(['--host', '192.0.2.123', '--port', '4100'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + handleConfigCommand(['--host', '192.0.2.123', '--port', '4100'], createTestDeps()) + ).rejects.toThrow('process.exit(1)'); expect(errorLines.join('\n')).toContain( 'Failed to start server: Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use' diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 2ed57bd1..e7afd2ce 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -84,4 +84,23 @@ describe('help command parity', () => { expect(rendered.includes('ccs config --host 0.0.0.0')).toBe(true); expect(rendered.includes('Force all-interface binding for remote devices')).toBe(true); }); + + test('root help documents official channels native-only scope and process-env tokens', async () => { + const lines: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map((arg) => String(arg)).join(' ')); + }; + + await handleHelpCommand(); + + const rendered = stripAnsi(lines.join('\n')); + expect(rendered.includes('Dashboard -> Settings -> Channels (fastest path)')).toBe(true); + expect( + rendered.includes('Fastest path: turn on the channel, save the token if needed, then run ccs.') + ).toBe(true); + expect(rendered.includes('Not supported for ccs glm')).toBe(true); + expect(rendered.includes('Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work')).toBe( + true + ); + }); }); diff --git a/tests/unit/commands/proxy-lifecycle-subcommand.test.ts b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts index 32da192c..f0607add 100644 --- a/tests/unit/commands/proxy-lifecycle-subcommand.test.ts +++ b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts @@ -1,62 +1,33 @@ -import { afterEach, describe, expect, it, mock } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager'; - -type MockUnifiedConfig = { - cliproxy_server?: { - local?: { - port?: number; - }; - }; -}; - -function mockUnifiedConfig(config: MockUnifiedConfig): void { - mock.module('../../../src/config/unified-config-loader', () => ({ - loadOrCreateUnifiedConfig: () => config, - })); -} - -async function loadResolveLifecyclePort() { - const mod = await import( - `../../../src/commands/cliproxy/resolve-lifecycle-port?proxy-lifecycle-port=${Date.now()}-${Math.random()}` - ); - return mod.resolveLifecyclePort; -} +import { resolveLifecyclePort } from '../../../src/commands/cliproxy/resolve-lifecycle-port'; describe('resolveLifecyclePort', () => { - afterEach(() => { - mock.restore(); - }); - - it('uses configured cliproxy_server.local.port', async () => { - mockUnifiedConfig({ - cliproxy_server: { - local: { - port: 9456, + it('uses configured cliproxy_server.local.port', () => { + expect( + resolveLifecyclePort({ + cliproxy_server: { + local: { + port: 9456, + }, }, - }, - }); - - const resolveLifecyclePort = await loadResolveLifecyclePort(); - expect(resolveLifecyclePort()).toBe(9456); + }) + ).toBe(9456); }); - it('falls back to default port when configured local port is invalid', async () => { - mockUnifiedConfig({ - cliproxy_server: { - local: { - port: 70000, + it('falls back to default port when configured local port is invalid', () => { + expect( + resolveLifecyclePort({ + cliproxy_server: { + local: { + port: 70000, + }, }, - }, - }); - - const resolveLifecyclePort = await loadResolveLifecyclePort(); - expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + }) + ).toBe(CLIPROXY_DEFAULT_PORT); }); - it('falls back to default port when config file is missing', async () => { - mockUnifiedConfig({}); - - const resolveLifecyclePort = await loadResolveLifecyclePort(); - expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + it('falls back to default port when config file is missing', () => { + expect(resolveLifecyclePort({})).toBe(CLIPROXY_DEFAULT_PORT); }); }); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 3b5fc576..5a2f15bb 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -49,6 +49,27 @@ afterEach(() => { // Use getCcsDir() for consistent path resolution with production code const getTestCursorDir = () => path.join(getCcsDir(), 'cursor'); +async function waitForProcessReady(pid: number): Promise { + for (let attempt = 0; attempt < 10; attempt++) { + try { + process.kill(pid, 0); + + if (process.platform !== 'linux') { + return; + } + + const commandLine = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, '').trim(); + if (commandLine.length > 0) { + return; + } + } catch { + // Process is still starting up. + } + + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + describe('getPidFromFile', () => { it('returns null when no PID file exists', () => { expect(getPidFromFile()).toBeNull(); @@ -230,6 +251,7 @@ describe('stopDaemon', () => { throw new Error('Failed to spawn unrelated process'); } + await waitForProcessReady(unrelatedPid); writePidToFile(unrelatedPid); try { diff --git a/tests/unit/jsonl-parser.test.ts b/tests/unit/jsonl-parser.test.ts index ce8b1e6f..f4f9e402 100644 --- a/tests/unit/jsonl-parser.test.ts +++ b/tests/unit/jsonl-parser.test.ts @@ -16,6 +16,7 @@ import { getDefaultProjectsDir, type RawUsageEntry, } from '../../src/web-server/jsonl-parser'; +import { getDefaultClaudeConfigDir } from '../../src/utils/claude-config-path'; // ============================================================================ // TEST FIXTURES @@ -545,6 +546,6 @@ describe('getDefaultProjectsDir', () => { test('falls back to ~/.claude/projects', () => { delete process.env.CLAUDE_CONFIG_DIR; const dir = getDefaultProjectsDir(); - expect(dir).toBe(path.join(os.homedir(), '.claude', 'projects')); + expect(dir).toBe(path.join(getDefaultClaudeConfigDir(), 'projects')); }); }); diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index 6572a0df..13c5661c 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -106,6 +106,12 @@ describe('unified-config-types', () => { expect(config.preferences.auto_update).toBe(true); }); + it('should default Official Channels to disabled and attended mode', () => { + const config = createEmptyUnifiedConfig(); + expect(config.channels?.selected).toEqual([]); + expect(config.channels?.unattended).toBe(false); + }); + it('should have CLIProxy providers list', () => { const config = createEmptyUnifiedConfig(); expect(config.cliproxy.providers).toContain('gemini'); @@ -202,13 +208,7 @@ describe('continuity-inheritance-config', () => { fs.writeFileSync( path.join(ccsDir, 'config.yaml'), - [ - 'version: 8', - 'continuity_inherit_from_account:', - ' glm: pro', - ' empty: ""', - '', - ].join('\n') + ['version: 8', 'continuity_inherit_from_account:', ' glm: pro', ' empty: ""', ''].join('\n') ); process.env.CCS_HOME = tempHome; @@ -266,3 +266,74 @@ describe('continuity-inheritance-config', () => { } }); }); + +describe('official-channels-config', () => { + it('keeps explicit channels.selected empty even when legacy discord_channels.enabled is true', () => { + const originalCcsHome = process.env.CCS_HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-official-channels-home-')); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'channels:', + ' selected: []', + ' unattended: false', + 'discord_channels:', + ' enabled: true', + ' unattended: true', + '', + ].join('\n') + ); + + process.env.CCS_HOME = tempHome; + try { + const config = loadOrCreateUnifiedConfig(); + expect(config.channels?.selected).toEqual([]); + expect(config.channels?.unattended).toBe(false); + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('treats the canonical channels section as authoritative even without selected', () => { + const originalCcsHome = process.env.CCS_HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-official-channels-canonical-')); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'channels:', + ' unattended: false', + 'discord_channels:', + ' enabled: true', + ' unattended: true', + '', + ].join('\n') + ); + + process.env.CCS_HOME = tempHome; + try { + const config = loadOrCreateUnifiedConfig(); + expect(config.channels?.selected).toEqual([]); + expect(config.channels?.unattended).toBe(false); + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/utils/websearch/status.test.ts b/tests/unit/utils/websearch/status.test.ts index 4c79f83b..3bdd3c23 100644 --- a/tests/unit/utils/websearch/status.test.ts +++ b/tests/unit/utils/websearch/status.test.ts @@ -1,126 +1,83 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { getWebSearchReadiness } from '../../../../src/utils/websearch/status'; +import { describe, expect, it } from 'bun:test'; +import { buildWebSearchReadiness } from '../../../../src/utils/websearch/status'; +import type { WebSearchCliInfo } from '../../../../src/utils/websearch/types'; -function writeWebSearchConfig(tempRoot: string, lines: string[]): void { - fs.writeFileSync(path.join(tempRoot, '.ccs', 'config.yaml'), lines.join('\n'), 'utf8'); +function provider(overrides: Partial & Pick): WebSearchCliInfo { + return { + id: overrides.id, + kind: overrides.kind ?? 'backend', + name: overrides.name, + enabled: overrides.enabled ?? false, + available: overrides.available ?? false, + version: overrides.version ?? null, + requiresApiKey: overrides.requiresApiKey ?? false, + description: overrides.description ?? '', + detail: overrides.detail ?? '', + ...overrides, + }; } describe('websearch readiness', () => { - const originalCcsHome = process.env.CCS_HOME; - const originalBraveKey = process.env.BRAVE_API_KEY; - const originalExaKey = process.env.EXA_API_KEY; - const originalTavilyKey = process.env.TAVILY_API_KEY; - let tempRoot = ''; - - beforeEach(() => { - tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-status-')); - process.env.CCS_HOME = tempRoot; - delete process.env.BRAVE_API_KEY; - delete process.env.EXA_API_KEY; - delete process.env.TAVILY_API_KEY; - fs.mkdirSync(path.join(tempRoot, '.ccs'), { recursive: true }); - }); - - afterEach(() => { - if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; - else delete process.env.CCS_HOME; - - if (originalBraveKey !== undefined) process.env.BRAVE_API_KEY = originalBraveKey; - else delete process.env.BRAVE_API_KEY; - - if (originalExaKey !== undefined) process.env.EXA_API_KEY = originalExaKey; - else delete process.env.EXA_API_KEY; - - if (originalTavilyKey !== undefined) process.env.TAVILY_API_KEY = originalTavilyKey; - else delete process.env.TAVILY_API_KEY; - - fs.rmSync(tempRoot, { recursive: true, force: true }); - }); - it('is ready by default because DuckDuckGo is enabled', () => { - const readiness = getWebSearchReadiness(); + const readiness = buildWebSearchReadiness(true, [ + provider({ + id: 'duckduckgo', + name: 'DuckDuckGo', + enabled: true, + available: true, + detail: 'Built-in (5 results)', + }), + ]); expect(readiness.readiness).toBe('ready'); expect(readiness.message).toContain('DuckDuckGo'); }); it('reports setup required when only Tavily is enabled without an API key', () => { - writeWebSearchConfig(tempRoot, [ - 'version: 10', - 'websearch:', - ' enabled: true', - ' providers:', - ' exa:', - ' enabled: false', - ' max_results: 5', - ' tavily:', - ' enabled: true', - ' max_results: 5', - ' duckduckgo:', - ' enabled: false', - ' max_results: 5', - ' brave:', - ' enabled: false', - ' max_results: 5', - ' gemini:', - ' enabled: false', - ' model: "gemini-2.5-flash"', - ' timeout: 55', - ' opencode:', - ' enabled: false', - ' model: "opencode/grok-code"', - ' timeout: 90', - ' grok:', - ' enabled: false', - ' timeout: 55', - '', + const readiness = buildWebSearchReadiness(true, [ + provider({ + id: 'tavily', + name: 'Tavily', + enabled: true, + available: false, + requiresApiKey: true, + apiKeyEnvVar: 'TAVILY_API_KEY', + detail: 'Set TAVILY_API_KEY', + }), + provider({ + id: 'duckduckgo', + name: 'DuckDuckGo', + enabled: false, + available: false, + detail: 'Built-in (5 results)', + }), ]); - const readiness = getWebSearchReadiness(); - expect(readiness.readiness).toBe('needs_setup'); expect(readiness.message).toContain('Tavily'); expect(readiness.message).toContain('TAVILY_API_KEY'); }); it('prefers API-backed readiness when Exa is enabled and configured', () => { - process.env.EXA_API_KEY = 'exa-test-key'; - writeWebSearchConfig(tempRoot, [ - 'version: 10', - 'websearch:', - ' enabled: true', - ' providers:', - ' exa:', - ' enabled: true', - ' max_results: 5', - ' tavily:', - ' enabled: false', - ' max_results: 5', - ' duckduckgo:', - ' enabled: false', - ' max_results: 5', - ' brave:', - ' enabled: false', - ' max_results: 5', - ' gemini:', - ' enabled: false', - ' model: "gemini-2.5-flash"', - ' timeout: 55', - ' opencode:', - ' enabled: false', - ' model: "opencode/grok-code"', - ' timeout: 90', - ' grok:', - ' enabled: false', - ' timeout: 55', - '', + const readiness = buildWebSearchReadiness(true, [ + provider({ + id: 'exa', + name: 'Exa', + enabled: true, + available: true, + requiresApiKey: true, + apiKeyEnvVar: 'EXA_API_KEY', + detail: 'API key detected (5 results)', + }), + provider({ + id: 'duckduckgo', + name: 'DuckDuckGo', + enabled: false, + available: false, + detail: 'Built-in (5 results)', + }), ]); - const readiness = getWebSearchReadiness(); - expect(readiness.readiness).toBe('ready'); expect(readiness.message).toContain('Exa'); }); diff --git a/tests/unit/web-server/auth-middleware.test.ts b/tests/unit/web-server/auth-middleware.test.ts index e441b310..5e3a30e3 100644 --- a/tests/unit/web-server/auth-middleware.test.ts +++ b/tests/unit/web-server/auth-middleware.test.ts @@ -3,39 +3,35 @@ * Tests for dashboard authentication middleware and routes. */ -import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import bcrypt from 'bcrypt'; - -// Mock the config loader -const mockAuthConfig = { - enabled: false, - username: '', - password_hash: '', - session_timeout_hours: 24, -}; - -mock.module('../../src/config/unified-config-loader', () => ({ - getDashboardAuthConfig: () => ({ ...mockAuthConfig }), -})); +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getDashboardAuthConfig } from '../../../src/config/unified-config-loader'; +import { runWithScopedConfigDir } from '../../../src/utils/config-manager'; describe('Dashboard Auth', () => { + let tempDir = ''; + beforeEach(() => { - // Reset to default disabled state - mockAuthConfig.enabled = false; - mockAuthConfig.username = ''; - mockAuthConfig.password_hash = ''; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-dashboard-auth-')); + }); + + afterEach(() => { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } }); describe('getDashboardAuthConfig', () => { it('returns disabled by default', async () => { - const { getDashboardAuthConfig } = await import('../../src/config/unified-config-loader'); - const config = getDashboardAuthConfig(); + const config = await runWithScopedConfigDir(tempDir, () => getDashboardAuthConfig()); expect(config.enabled).toBe(false); }); it('returns 24 hour default session timeout', async () => { - const { getDashboardAuthConfig } = await import('../../src/config/unified-config-loader'); - const config = getDashboardAuthConfig(); + const config = await runWithScopedConfigDir(tempDir, () => getDashboardAuthConfig()); expect(config.session_timeout_hours).toBe(24); }); }); @@ -126,29 +122,29 @@ describe('Dashboard Auth', () => { describe('auth flow logic', () => { it('bypasses auth when disabled', () => { - mockAuthConfig.enabled = false; - const shouldSkip = !mockAuthConfig.enabled; + const shouldSkip = true; expect(shouldSkip).toBe(true); }); it('requires auth when enabled', () => { - mockAuthConfig.enabled = true; - mockAuthConfig.username = 'admin'; - mockAuthConfig.password_hash = '$2b$10$test'; - - const shouldSkip = !mockAuthConfig.enabled; + const authConfig = { + enabled: true, + username: 'admin', + password_hash: '$2b$10$test', + }; + const shouldSkip = !authConfig.enabled; expect(shouldSkip).toBe(false); }); it('validates username match', () => { - mockAuthConfig.username = 'admin'; - const usernameMatch = 'admin' === mockAuthConfig.username; + const authConfig = { username: 'admin' }; + const usernameMatch = 'admin' === authConfig.username; expect(usernameMatch).toBe(true); }); it('rejects wrong username', () => { - mockAuthConfig.username = 'admin'; - const usernameMatch = 'wrong' === mockAuthConfig.username; + const authConfig = { username: 'admin' }; + const usernameMatch = 'wrong' === authConfig.username; expect(usernameMatch).toBe(false); }); }); diff --git a/tests/unit/web-server/channels-routes.test.ts b/tests/unit/web-server/channels-routes.test.ts new file mode 100644 index 00000000..f414f742 --- /dev/null +++ b/tests/unit/web-server/channels-routes.test.ts @@ -0,0 +1,197 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } 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 { getOfficialChannelsConfig } from '../../../src/config/unified-config-loader'; + +async function putJson(baseUrl: string, routePath: string, body: unknown): Promise { + return fetch(`${baseUrl}${routePath}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('web-server channels-routes', () => { + let channelsRoutes: typeof import('../../../src/web-server/routes/channels-routes').default; + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalCcsUnified: string | undefined; + let moduleVersion = 0; + + beforeAll(async () => { + moduleVersion += 1; + const actualRuntime = await import( + `../../../src/channels/official-channels-runtime?channels-runtime-actual=${moduleVersion}` + ); + mock.module('../../../src/channels/official-channels-runtime', () => ({ + ...actualRuntime, + getOfficialChannelsEnvironmentStatus: () => ({ + bunInstalled: true, + supportedProfiles: ['default', 'account'], + stateScopeMessage: + "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.", + claudeVersion: { + current: '2.1.83', + minimum: '2.1.80', + state: 'supported', + message: 'Claude Code v2.1.83', + }, + auth: { + checked: true, + loggedIn: true, + authMethod: 'claude.ai', + subscriptionType: 'max', + state: 'eligible', + eligible: true, + message: 'Authenticated with claude.ai.', + }, + }), + })); + ({ default: channelsRoutes } = await import( + `../../../src/web-server/routes/channels-routes?channels-routes=${moduleVersion}` + )); + + const app = express(); + app.use(express.json()); + app.use('/api/channels', channelsRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const handleError = (error: Error) => reject(error); + server.once('error', handleError); + server.once('listening', () => { + server.off('error', handleError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + mock.restore(); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-channels-routes-')); + originalCcsHome = process.env.CCS_HOME; + originalCcsUnified = process.env.CCS_UNIFIED_CONFIG; + + process.env.CCS_HOME = tempHome; + process.env.CCS_UNIFIED_CONFIG = '1'; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalCcsUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalCcsUnified; + else delete process.env.CCS_UNIFIED_CONFIG; + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('persists an empty selected array when clearing all official channels', async () => { + let response = await putJson(baseUrl, '/api/channels', { + selected: ['discord', 'telegram'], + unattended: true, + }); + expect(response.status).toBe(200); + + response = await putJson(baseUrl, '/api/channels', { + selected: [], + }); + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + config?: { + selected?: string[]; + unattended?: boolean; + }; + }; + + expect(payload.config?.selected).toEqual([]); + expect(payload.config?.unattended).toBe(true); + expect(getOfficialChannelsConfig()).toEqual({ + selected: [], + unattended: true, + }); + }); + + it('reports current-process env tokens as available readiness in GET status', async () => { + const originalDiscordToken = process.env.DISCORD_BOT_TOKEN; + process.env.DISCORD_BOT_TOKEN = 'discord-from-env'; + + try { + await putJson(baseUrl, '/api/channels', { + selected: ['discord'], + }); + + const response = await fetch(`${baseUrl}/api/channels`); + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + status?: { + summary?: { + title?: string; + }; + launchPreview?: { + state?: string; + title?: string; + appendedArgs?: string[]; + }; + supportMessage?: string; + accountStatusCaveat?: string; + channels?: Array<{ + id?: string; + tokenConfigured?: boolean; + tokenAvailable?: boolean; + tokenSource?: string; + setup?: { + label?: string; + }; + }>; + }; + }; + + expect(payload.status?.summary?.title).toBe('Ready for the next native Claude run'); + expect(payload.status?.launchPreview).toEqual( + expect.objectContaining({ + state: 'ready', + title: 'CCS will auto-add Discord', + appendedArgs: ['--channels', 'plugin:discord@claude-plugins-official'], + }) + ); + expect(payload.status?.supportMessage).toContain('ccs glm'); + expect(payload.status?.accountStatusCaveat).toContain('current CCS process'); + expect(payload.status?.channels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'discord', + tokenConfigured: false, + tokenAvailable: true, + tokenSource: 'process_env', + setup: expect.objectContaining({ + label: 'Ready from current CCS process env', + }), + }), + ]) + ); + } finally { + if (originalDiscordToken !== undefined) process.env.DISCORD_BOT_TOKEN = originalDiscordToken; + else delete process.env.DISCORD_BOT_TOKEN; + } + }); +}); diff --git a/tests/unit/web-server/cliproxy-usage-syncer.test.ts b/tests/unit/web-server/cliproxy-usage-syncer.test.ts index d8f3fa34..699bcd25 100644 --- a/tests/unit/web-server/cliproxy-usage-syncer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-syncer.test.ts @@ -1,26 +1,24 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher'; import { runWithScopedConfigDir } from '../../../src/utils/config-manager'; +import { + loadCachedCliproxyData, + startCliproxySync, + stopCliproxySync, + syncCliproxyUsage, +} from '../../../src/web-server/usage/cliproxy-usage-syncer'; let ccsDir = ''; let rawResponse: CliproxyUsageApiResponse | null = null; let fetchCalls = 0; -mock.module('../../../src/cliproxy/stats-fetcher', () => ({ - fetchCliproxyUsageRaw: async () => { - fetchCalls++; - return rawResponse; - }, -})); - -let syncer: typeof import('../../../src/web-server/usage/cliproxy-usage-syncer'); - -beforeAll(async () => { - syncer = await import('../../../src/web-server/usage/cliproxy-usage-syncer'); -}); +function fetchRawResponse(): Promise { + fetchCalls++; + return Promise.resolve(rawResponse); +} beforeEach(() => { ccsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-syncer-')); @@ -52,29 +50,25 @@ beforeEach(() => { }, }, }; - syncer.stopCliproxySync(); + stopCliproxySync(); }); afterEach(() => { - syncer.stopCliproxySync(); + stopCliproxySync(); fs.rmSync(ccsDir, { recursive: true, force: true }); }); -afterAll(() => { - mock.restore(); -}); - describe('cliproxy usage syncer', () => { it('writes and loads snapshot data', async () => { await runWithScopedConfigDir(ccsDir, async () => { - await syncer.syncCliproxyUsage(); + await syncCliproxyUsage(fetchRawResponse); }); const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json'); expect(fs.existsSync(snapshotPath)).toBe(true); const cached = await runWithScopedConfigDir(ccsDir, async () => { - return await syncer.loadCachedCliproxyData(); + return await loadCachedCliproxyData(); }); expect(cached.daily).toHaveLength(1); expect(cached.daily[0].source).toBe('cliproxy'); @@ -87,14 +81,15 @@ describe('cliproxy usage syncer', () => { const intervalSpy = spyOn(globalThis, 'setInterval'); await runWithScopedConfigDir(ccsDir, async () => { - syncer.startCliproxySync(); - syncer.startCliproxySync(); + const syncNow = () => syncCliproxyUsage(fetchRawResponse); + startCliproxySync(syncNow); + startCliproxySync(syncNow); }); expect(intervalSpy).toHaveBeenCalledTimes(1); expect(fetchCalls).toBeGreaterThan(0); - syncer.stopCliproxySync(); + stopCliproxySync(); intervalSpy.mockRestore(); }); }); diff --git a/ui/src/pages/settings/components/tab-navigation.tsx b/ui/src/pages/settings/components/tab-navigation.tsx index d0d919c6..0c3fb57d 100644 --- a/ui/src/pages/settings/components/tab-navigation.tsx +++ b/ui/src/pages/settings/components/tab-navigation.tsx @@ -4,7 +4,7 @@ */ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Globe, Settings2, Server, KeyRound, Brain, Archive } from 'lucide-react'; +import { Globe, Settings2, Server, KeyRound, Brain, Archive, MessageSquare } from 'lucide-react'; import type { SettingsTab } from '../types'; import { useTranslation } from 'react-i18next'; @@ -17,6 +17,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) { const { t } = useTranslation(); const tabs = [ { value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe }, + { value: 'channels' as const, label: 'Channels', icon: MessageSquare }, { value: 'globalenv' as const, label: t('settingsTabs.env'), icon: Settings2 }, { value: 'thinking' as const, label: t('settingsTabs.think'), icon: Brain }, { value: 'proxy' as const, label: t('settingsTabs.proxy'), icon: Server }, @@ -26,7 +27,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) { return ( onTabChange(v as SettingsTab)}> - + {tabs.map(({ value, label, icon: Icon }) => ( diff --git a/ui/src/pages/settings/hooks/use-official-channels-config.ts b/ui/src/pages/settings/hooks/use-official-channels-config.ts new file mode 100644 index 00000000..877edf9c --- /dev/null +++ b/ui/src/pages/settings/hooks/use-official-channels-config.ts @@ -0,0 +1,165 @@ +import { useCallback, useState } from 'react'; +import type { OfficialChannelId, OfficialChannelsConfig, OfficialChannelsStatus } from '../types'; + +const DEFAULT_CONFIG: OfficialChannelsConfig = { + selected: [], + unattended: false, +}; + +async function readErrorMessage(response: Response, fallback: string): Promise { + try { + const data = (await response.json()) as { error?: unknown }; + return typeof data.error === 'string' && data.error.trim().length > 0 ? data.error : fallback; + } catch { + return fallback; + } +} + +export function useOfficialChannelsConfig() { + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const flashSuccess = useCallback((message: string) => { + setSuccess(message); + window.setTimeout(() => setSuccess(null), 1500); + }, []); + + const fetchConfig = useCallback(async (): Promise => { + try { + setLoading(true); + setError(null); + const res = await fetch('/api/channels'); + if (!res.ok) { + throw new Error(await readErrorMessage(res, 'Failed to load Official Channels settings')); + } + + const data = (await res.json()) as { + config?: OfficialChannelsConfig; + status?: OfficialChannelsStatus; + }; + + setConfig(data.config ?? DEFAULT_CONFIG); + setStatus(data.status ?? null); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + return false; + } finally { + setLoading(false); + } + }, []); + + const updateConfig = useCallback( + async ( + updates: Partial, + successMessage = 'Settings saved' + ): Promise => { + try { + setSaving(true); + setError(null); + + const res = await fetch('/api/channels', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }); + + if (!res.ok) { + throw new Error(await readErrorMessage(res, 'Failed to save Official Channels settings')); + } + + const data = (await res.json()) as { config?: OfficialChannelsConfig }; + setConfig((current) => data.config ?? { ...current, ...updates }); + flashSuccess(successMessage); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + return false; + } finally { + setSaving(false); + } + }, + [flashSuccess] + ); + + const saveToken = useCallback( + async (channelId: OfficialChannelId, token: string): Promise => { + try { + setSaving(true); + setError(null); + + const res = await fetch(`/api/channels/${channelId}/token`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + + if (!res.ok) { + throw new Error(await readErrorMessage(res, `Failed to save ${channelId} token`)); + } + + const refreshed = await fetchConfig(); + if (!refreshed) { + return false; + } + + flashSuccess(`${channelId} token saved`); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + return false; + } finally { + setSaving(false); + } + }, + [fetchConfig, flashSuccess] + ); + + const clearToken = useCallback( + async (channelId: OfficialChannelId): Promise => { + try { + setSaving(true); + setError(null); + + const res = await fetch(`/api/channels/${channelId}/token`, { + method: 'DELETE', + }); + + if (!res.ok) { + throw new Error(await readErrorMessage(res, `Failed to clear ${channelId} token`)); + } + + const refreshed = await fetchConfig(); + if (!refreshed) { + return false; + } + + flashSuccess(`${channelId} token cleared`); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + return false; + } finally { + setSaving(false); + } + }, + [fetchConfig, flashSuccess] + ); + + return { + config, + status, + loading, + saving, + error, + success, + fetchConfig, + updateConfig, + saveToken, + clearToken, + }; +} diff --git a/ui/src/pages/settings/hooks/use-settings-tab.ts b/ui/src/pages/settings/hooks/use-settings-tab.ts index 8efd3123..15a05a5f 100644 --- a/ui/src/pages/settings/hooks/use-settings-tab.ts +++ b/ui/src/pages/settings/hooks/use-settings-tab.ts @@ -11,17 +11,19 @@ export function useSettingsTab() { // Normalize to lowercase for case-insensitive matching (fixes ?tab=Backups vs ?tab=backups) const tabParam = searchParams.get('tab')?.toLowerCase(); const activeTab: SettingsTab = - tabParam === 'globalenv' - ? 'globalenv' - : tabParam === 'proxy' - ? 'proxy' - : tabParam === 'auth' - ? 'auth' - : tabParam === 'thinking' - ? 'thinking' - : tabParam === 'backups' - ? 'backups' - : 'websearch'; + tabParam === 'channels' + ? 'channels' + : tabParam === 'globalenv' + ? 'globalenv' + : tabParam === 'proxy' + ? 'proxy' + : tabParam === 'auth' + ? 'auth' + : tabParam === 'thinking' + ? 'thinking' + : tabParam === 'backups' + ? 'backups' + : 'websearch'; const setActiveTab = useCallback( (tab: SettingsTab) => { diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx index bb317ff9..b01539f2 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -48,6 +48,7 @@ function lazyWithRetry>(importFn: () => Promise // Lazy-loaded sections with retry capability const WebSearchSection = lazyWithRetry(() => import('./sections/websearch')); +const ChannelsSection = lazyWithRetry(() => import('./sections/channels')); const GlobalEnvSection = lazyWithRetry(() => import('./sections/globalenv-section')); const ThinkingSection = lazyWithRetry(() => import('./sections/thinking')); const ProxySection = lazyWithRetry(() => import('./sections/proxy')); @@ -130,6 +131,7 @@ function SettingsPageInner() { }> {activeTab === 'websearch' && } + {activeTab === 'channels' && } {activeTab === 'globalenv' && } {activeTab === 'thinking' && } {activeTab === 'proxy' && } @@ -153,6 +155,7 @@ function SettingsPageInner() { }> {activeTab === 'websearch' && } + {activeTab === 'channels' && } {activeTab === 'globalenv' && } {activeTab === 'thinking' && } {activeTab === 'proxy' && } diff --git a/ui/src/pages/settings/sections/channels.tsx b/ui/src/pages/settings/sections/channels.tsx new file mode 100644 index 00000000..36d1a001 --- /dev/null +++ b/ui/src/pages/settings/sections/channels.tsx @@ -0,0 +1,476 @@ +import { useEffect, useState } from 'react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Switch } from '@/components/ui/switch'; +import { + AlertCircle, + CheckCircle2, + MessageSquare, + RefreshCw, + Save, + ShieldAlert, + Trash2, +} from 'lucide-react'; +import { useOfficialChannelsConfig } from '../hooks/use-official-channels-config'; +import { useRawConfig } from '../hooks'; +import type { OfficialChannelId } from '../types'; + +type TokenDrafts = Record; + +const EMPTY_DRAFTS: TokenDrafts = { + telegram: '', + discord: '', + imessage: '', +}; + +function getSummaryClasses(state: 'ready' | 'needs_setup' | 'limited'): string { + if (state === 'ready') { + return 'border-green-200 bg-green-50 text-green-900 dark:border-green-900/60 dark:bg-green-950/40 dark:text-green-100'; + } + if (state === 'limited') { + return 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100'; + } + + return 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-100'; +} + +function getSetupBadgeVariant(state: string): 'default' | 'secondary' | 'destructive' | 'outline' { + if (state === 'ready') { + return 'default'; + } + if (state === 'not_selected') { + return 'secondary'; + } + if (state === 'unavailable') { + return 'destructive'; + } + + return 'outline'; +} + +function getLaunchPreviewBadgeVariant( + state: 'disabled' | 'blocked' | 'partial' | 'ready' +): 'default' | 'secondary' | 'destructive' | 'outline' { + if (state === 'ready') { + return 'default'; + } + if (state === 'partial') { + return 'outline'; + } + if (state === 'blocked') { + return 'destructive'; + } + + return 'secondary'; +} + +function getSelectedChannelLabel( + selected: OfficialChannelId[], + channels: Array<{ id: OfficialChannelId; displayName: string }> | undefined +): string { + if (selected.length === 0) { + return 'None selected'; + } + + return selected + .map( + (channelId) => channels?.find((channel) => channel.id === channelId)?.displayName ?? channelId + ) + .join(', '); +} + +export default function ChannelsSection() { + const { + config, + status, + loading, + saving, + error, + success, + fetchConfig, + updateConfig, + saveToken, + clearToken, + } = useOfficialChannelsConfig(); + const { fetchRawConfig } = useRawConfig(); + const [tokenDrafts, setTokenDrafts] = useState(EMPTY_DRAFTS); + const selectedChannelLabel = getSelectedChannelLabel(config.selected, status?.channels); + + useEffect(() => { + void fetchConfig(); + void fetchRawConfig(); + }, [fetchConfig, fetchRawConfig]); + + const refreshAll = async () => { + await Promise.all([fetchConfig(), fetchRawConfig()]); + }; + + const toggleChannel = async (channelId: OfficialChannelId, checked: boolean): Promise => { + const nextSelected = checked + ? [...new Set([...config.selected, channelId])] + : config.selected.filter((value) => value !== channelId); + + const updated = await updateConfig( + { selected: nextSelected }, + checked ? `${channelId} selected for auto-enable` : `${channelId} removed from auto-enable` + ); + if (updated) { + await Promise.all([fetchConfig(), fetchRawConfig()]); + } + }; + + const updateTokenDraft = (channelId: OfficialChannelId, value: string) => { + setTokenDrafts((current) => ({ ...current, [channelId]: value })); + }; + + const handleSaveToken = async (channelId: OfficialChannelId): Promise => { + const saved = await saveToken(channelId, tokenDrafts[channelId]); + if (saved) { + setTokenDrafts((current) => ({ ...current, [channelId]: '' })); + await fetchRawConfig(); + } + }; + + const handleClearToken = async (channelId: OfficialChannelId): Promise => { + const cleared = await clearToken(channelId); + if (cleared) { + setTokenDrafts((current) => ({ ...current, [channelId]: '' })); + await fetchRawConfig(); + } + }; + + if (loading) { + return ( +
+
+ + Loading +
+
+ ); + } + + return ( + <> +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + {success} +
+ )} +
+ + +
+
+ +
+

Official Channels

+

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

+

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

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

{status.summary.message}

+

{status.summary.nextStep}

+
+
+

Machine checks

+
+
+ Bun + {status.bunInstalled ? 'Installed' : 'Missing'} +
+
+ Claude Code + + {status.claudeVersion.current + ? `v${status.claudeVersion.current}` + : 'Unknown'} + +
+
+ Claude auth + {status.auth.authMethod ?? 'Unknown'} +
+
+
+
+ {status.summary.blockers.length > 0 && ( +
+ {status.summary.blockers.map((blocker) => ( +

{blocker}

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

Fastest path

+
+

1. Turn on the channels you want below.

+

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

+

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

+

{status.supportMessage}

+
+
+ + Advanced notes and scope + +
+

{status.accountStatusCaveat}

+

{status.stateScopeMessage}

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

+ If you run ccs now +

+

{status.launchPreview.detail}

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

{message}

+ ))} +
+ )} +
+ )} + + {status?.claudeVersion.message && status.claudeVersion.state !== 'supported' && ( + + + {status.claudeVersion.message} + + )} + + {status?.auth.message && status.auth.state !== 'eligible' && ( + + + {status.auth.message} + + )} + + {status?.auth.orgRequirementMessage && ( + + + {status.auth.orgRequirementMessage} + + )} + +
+ {status?.channels.map((channel) => { + const enabled = config.selected.includes(channel.id); + const tokenDraft = tokenDrafts[channel.id]; + + return ( +
+
+
+ +

{channel.summary}

+

+ {channel.pluginSpec} +

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

{channel.setup.detail}

+

{channel.setup.nextStep}

+
+ + {channel.requiresToken && ( +
+

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

+ {channel.tokenConfigured && ( +

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

+ )} + updateTokenDraft(channel.id, event.target.value)} + placeholder={ + channel.tokenConfigured + ? `Configured. Enter a new ${channel.envKey} to replace it.` + : !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} + /> + {channel.tokenPath && channel.tokenSource !== 'process_env' && ( +
+ {channel.tokenPath} +
+ )} +
+ + +
+
+ )} + +
+ + Claude-side setup commands + +
+ {(channel.manualSetupCommands ?? []).map((command) => ( +
+ {command} +
+ ))} +
+
+
+ ); + })} +
+ + + + CCS injects --channels only for the current Claude session. Telegram, + Discord, and iMessage stop receiving messages when that Claude session exits. + + + +
+
+
+ +
+ +

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

+
+
+ + void (async () => { + const updated = await updateConfig( + { unattended: checked }, + checked ? 'Unattended mode enabled' : 'Unattended mode disabled' + ); + if (updated) { + await fetchRawConfig(); + } + })() + } + /> +
+
+ +
+ +
+
+
+ + ); +} diff --git a/ui/src/pages/settings/sections/websearch/index.tsx b/ui/src/pages/settings/sections/websearch/index.tsx index f8a92803..2cc1eb7d 100644 --- a/ui/src/pages/settings/sections/websearch/index.tsx +++ b/ui/src/pages/settings/sections/websearch/index.tsx @@ -350,24 +350,6 @@ export default function WebSearchSection() { fetchRawConfig(); }, [fetchConfig, fetchStatus, fetchRawConfig]); - useEffect(() => { - if (!config?.providers) { - return; - } - - const nextDrafts: Record = {}; - for (const provider of [...BACKEND_PROVIDERS, ...LEGACY_PROVIDERS]) { - for (const field of provider.fields ?? []) { - nextDrafts[`${provider.id}.${field.key}`] = getConfiguredValue( - config.providers, - provider.id, - field - ); - } - } - setFieldDrafts(nextDrafts); - }, [config]); - const providerStatus = useMemo( () => new Map((status?.providers ?? []).map((provider) => [provider.id, provider])), [status?.providers] @@ -410,7 +392,10 @@ export default function WebSearchSection() { const currentProviders = (config.providers ?? {}) as WebSearchProvidersConfig; const currentProviderConfig = currentProviders[providerId] ?? {}; const currentValue = currentProviderConfig[field.key] ?? field.defaultValue; - const normalized = normalizeFieldValue(field, fieldDrafts[fieldId] ?? String(currentValue)); + const normalized = normalizeFieldValue( + field, + fieldDrafts[fieldId] ?? getConfiguredValue(currentProviders, providerId, field) + ); setFieldDrafts((current) => ({ ...current, [fieldId]: String(normalized) })); @@ -443,7 +428,13 @@ export default function WebSearchSection() { return { id: fieldId, label: field.label, - value: fieldDrafts[fieldId] ?? String(field.defaultValue), + value: + fieldDrafts[fieldId] ?? + getConfiguredValue( + (config?.providers ?? {}) as WebSearchProvidersConfig, + provider.id, + field + ), placeholder: field.placeholder, type: field.type, helpText: field.helpText, diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index 606d3c00..a1f10a9f 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -60,9 +60,95 @@ export interface GlobalEnvConfig { env: Record; } +// === Official Channels Types === + +export type OfficialChannelId = 'telegram' | 'discord' | 'imessage'; + +export interface OfficialChannelsConfig { + selected: OfficialChannelId[]; + unattended: boolean; +} + +export interface OfficialChannelStatus { + id: OfficialChannelId; + selected?: boolean; + displayName: string; + pluginSpec: string; + summary: string; + requiresToken: boolean; + envKey?: string; + tokenConfigured: boolean; + tokenAvailable?: boolean; + tokenSource?: 'saved_env' | 'process_env' | 'missing'; + tokenPath?: string; + savedInClaudeState?: boolean; + processEnvAvailable?: boolean; + unavailableReason?: string; + setup: { + state: 'not_selected' | 'ready' | 'needs_token' | 'needs_claude_setup' | 'unavailable'; + label: string; + detail: string; + nextStep: 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 { + bunInstalled: boolean; + 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[]; +} + // === Tab Types === -export type SettingsTab = 'websearch' | 'globalenv' | 'proxy' | 'auth' | 'thinking' | 'backups'; +export type SettingsTab = + | 'websearch' + | 'channels' + | 'globalenv' + | 'proxy' + | 'auth' + | 'thinking' + | 'backups'; // === Thinking Types === diff --git a/ui/tests/unit/ui/pages/settings/sections/channels-section.test.tsx b/ui/tests/unit/ui/pages/settings/sections/channels-section.test.tsx new file mode 100644 index 00000000..8c7eeb25 --- /dev/null +++ b/ui/tests/unit/ui/pages/settings/sections/channels-section.test.tsx @@ -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(); + + 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 '], + }, + ], + }, + }); + } + + 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(, { 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 '], + }, + ], + }, + }); + } + + 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(, { 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(, { 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 '], + }, + ], + }, + }); + } + + if (url.endsWith('/api/config/raw')) { + return textResponse('channels:\n selected:\n - discord\n'); + } + + throw new Error(`Unexpected fetch: ${url}`); + }); + + render(, { 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(); + }); +});