mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-05 08:22:29 +00:00
Merge pull request #785 from kaitranntt/kai/feat/783-discord-channels
feat: support official Claude channels
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/<profile>/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/<channel>/.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
|
||||
|
||||
@@ -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/<channel>/.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/<channel>/.env`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+58
-3
@@ -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<boolean> {
|
||||
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<void> {
|
||||
// Register target adapters
|
||||
registerTarget(new ClaudeAdapter());
|
||||
@@ -838,7 +887,8 @@ async function main(): Promise<void> {
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
@@ -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<OfficialChannelId, OfficialChannelDefinition> = {
|
||||
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 <token>',
|
||||
'/telegram:access pair <code>',
|
||||
'/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 <token>',
|
||||
'/discord:access pair <code>',
|
||||
'/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<OfficialChannelId, boolean>;
|
||||
}
|
||||
|
||||
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<OfficialChannelId>();
|
||||
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<OfficialChannelId, boolean>;
|
||||
|
||||
const plan = resolveOfficialChannelsLaunchPlan({
|
||||
args: [],
|
||||
config,
|
||||
target: 'claude',
|
||||
profileType: 'default',
|
||||
environment,
|
||||
channelReadiness,
|
||||
});
|
||||
|
||||
const appendedArgs = plan.applied
|
||||
? buildOfficialChannelsArgs([], plan.appliedChannels, plan.wantsPermissionBypass)
|
||||
: [];
|
||||
|
||||
if (!plan.applied) {
|
||||
return {
|
||||
state: 'blocked',
|
||||
title: 'Running `ccs` now will not auto-add channels',
|
||||
detail:
|
||||
plan.skippedMessages[0] ??
|
||||
'Official Channels are selected, but this machine is not ready to auto-add them yet.',
|
||||
command: 'ccs',
|
||||
appendedArgs: [],
|
||||
appliedChannels: [],
|
||||
permissionBypassIncluded: false,
|
||||
skippedMessages: plan.skippedMessages,
|
||||
};
|
||||
}
|
||||
|
||||
const appliedLabels = plan.appliedChannels.map((channelId) =>
|
||||
getOfficialChannelDisplayName(channelId)
|
||||
);
|
||||
|
||||
if (plan.skippedMessages.length > 0) {
|
||||
return {
|
||||
state: 'partial',
|
||||
title: `CCS will auto-add ${appliedLabels.join(', ')}`,
|
||||
detail:
|
||||
'Some selected channels are still skipped. Review the notes below before relying on the rest.',
|
||||
command: 'ccs',
|
||||
appendedArgs,
|
||||
appliedChannels: plan.appliedChannels,
|
||||
permissionBypassIncluded: plan.wantsPermissionBypass,
|
||||
skippedMessages: plan.skippedMessages,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'ready',
|
||||
title: `CCS will auto-add ${appliedLabels.join(', ')}`,
|
||||
detail: plan.wantsPermissionBypass
|
||||
? 'Running `ccs` will add the selected official channels and skip permission prompts for that launch.'
|
||||
: 'Running `ccs` will add the selected official channels automatically on this machine.',
|
||||
command: 'ccs',
|
||||
appendedArgs,
|
||||
appliedChannels: plan.appliedChannels,
|
||||
permissionBypassIncluded: plan.wantsPermissionBypass,
|
||||
skippedMessages: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function expandOfficialChannelSelection(selection: string): OfficialChannelId[] {
|
||||
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 <csv>. 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 <channel>=<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 <channel> 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));
|
||||
}
|
||||
@@ -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<string>([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<string>([
|
||||
getOfficialChannelEnvPath(channelId, getDefaultClaudeConfigDir(), null),
|
||||
getOfficialChannelEnvPath(channelId),
|
||||
]);
|
||||
|
||||
for (const configDir of listManagedClaudeConfigDirs()) {
|
||||
envPaths.add(getOfficialChannelEnvPath(channelId, configDir, null));
|
||||
}
|
||||
|
||||
return [...envPaths];
|
||||
}
|
||||
|
||||
export function normalizeDiscordBotToken(value: string): string | null {
|
||||
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;
|
||||
}
|
||||
@@ -158,41 +158,61 @@ export function getInstalledCliproxyVersion(backend?: CLIProxyBackend): string {
|
||||
);
|
||||
}
|
||||
|
||||
interface InstallCliproxyVersionDeps {
|
||||
createManager?: (
|
||||
config: Partial<BinaryManagerConfig>,
|
||||
backend: CLIProxyBackend
|
||||
) => Pick<BinaryManager, 'isBinaryInstalled' | 'deleteBinary' | 'ensureBinary'>;
|
||||
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<void> {
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<void> {
|
||||
export async function reconcileCodexModelForActivePlan(
|
||||
options: {
|
||||
settingsPath: string;
|
||||
currentModel: string | undefined;
|
||||
verbose: boolean;
|
||||
},
|
||||
deps: CodexPlanCompatibilityDeps = {}
|
||||
): Promise<void> {
|
||||
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<CodexQuotaResult>('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".`
|
||||
)
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 <name> [--out <file>]'));
|
||||
console.log(deps.fail('Profile name is required. Usage: ccs api export <name> [--out <file>]'));
|
||||
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('');
|
||||
}
|
||||
|
||||
@@ -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<UnifiedConfig, 'cliproxy_server'>;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
@@ -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 <csv|all>', '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 <spec>', '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<void> {
|
||||
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 <channel>=<token>)`)
|
||||
);
|
||||
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();
|
||||
}
|
||||
@@ -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 <csv|all> 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 <s> Save channel token (telegram=<t> or discord=<t>)');
|
||||
console.log(' --clear-token Remove all saved channel tokens');
|
||||
console.log(' --clear-token <c> Remove one saved channel token');
|
||||
console.log(' Works only for native Claude default/account sessions');
|
||||
console.log(' Not for ccs glm, other API/OAuth profiles, or Droid targets');
|
||||
console.log('');
|
||||
console.log(' 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');
|
||||
|
||||
@@ -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<void> {
|
||||
export async function handleConfigCommand(
|
||||
args: string[],
|
||||
deps: ConfigCommandDependencies = defaultConfigCommandDependencies
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=<token>', '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=<token>', 'Save TELEGRAM_BOT_TOKEN'],
|
||||
['ccs config channels --set-token discord=<token>', 'Save DISCORD_BOT_TOKEN'],
|
||||
['ccs config channels --clear-token [channel]', 'Remove one or all saved channel tokens'],
|
||||
['', ''],
|
||||
['', 'Fastest path: turn on the channel, save the token if needed, then run ccs.'],
|
||||
['Note:', 'Runtime-only. Applies to native Claude default/account sessions only.'],
|
||||
['', 'Not supported for ccs glm, other API/OAuth profiles, or Droid targets.'],
|
||||
['', 'Telegram/Discord tokens live in ~/.claude/channels/<channel>/.env.'],
|
||||
['', '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);
|
||||
}
|
||||
|
||||
@@ -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<UnifiedConfig>): ContinuityC
|
||||
};
|
||||
}
|
||||
|
||||
interface LegacyDiscordChannelsConfig {
|
||||
enabled?: boolean;
|
||||
unattended?: boolean;
|
||||
}
|
||||
|
||||
function normalizeOfficialChannelsConfig(
|
||||
partial: Partial<UnifiedConfig> & { 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>): UnifiedConfig {
|
||||
provider_overrides: partial.thinking?.provider_overrides,
|
||||
show_warnings: partial.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings,
|
||||
},
|
||||
channels: normalizeOfficialChannelsConfig(
|
||||
partial as Partial<UnifiedConfig> & { 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
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<ClaudeAuthStatus>;
|
||||
if (typeof parsed.loggedIn !== 'boolean') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
loggedIn: parsed.loggedIn,
|
||||
authMethod: parsed.authMethod ?? null,
|
||||
apiProvider: parsed.apiProvider ?? null,
|
||||
email: parsed.email ?? null,
|
||||
orgId: parsed.orgId ?? null,
|
||||
orgName: parsed.orgName ?? null,
|
||||
subscriptionType: parsed.subscriptionType ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Claude not found error
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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 ====================
|
||||
|
||||
@@ -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<void> {
|
||||
const raw = await fetchCliproxyUsageRaw();
|
||||
export async function syncCliproxyUsage(
|
||||
fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
* Start periodic CLIProxy usage sync (every 5 minutes).
|
||||
* Performs an immediate sync on startup.
|
||||
*/
|
||||
export function startCliproxySync(): void {
|
||||
export function startCliproxySync(syncNow: () => Promise<void> = () => 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
|
||||
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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<T>(fn: () => T): Promise<T> {
|
||||
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);
|
||||
|
||||
@@ -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> = {}
|
||||
): OfficialChannelsVersionSummary {
|
||||
return {
|
||||
current: '2.1.81',
|
||||
minimum: MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION,
|
||||
state: 'supported',
|
||||
message: 'Claude Code v2.1.81',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEligibleAuthSummary(
|
||||
overrides: Partial<OfficialChannelsAuthSummary> = {}
|
||||
): OfficialChannelsAuthSummary {
|
||||
return {
|
||||
checked: true,
|
||||
loggedIn: true,
|
||||
authMethod: 'claude.ai',
|
||||
subscriptionType: 'pro',
|
||||
state: 'eligible',
|
||||
eligible: true,
|
||||
message: 'Authenticated with claude.ai.',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEnvironment(
|
||||
overrides: Partial<OfficialChannelsEnvironmentStatus> = {}
|
||||
): OfficialChannelsEnvironmentStatus {
|
||||
return {
|
||||
bunInstalled: true,
|
||||
supportedProfiles: ['default', 'account'],
|
||||
stateScopeMessage: 'state scope',
|
||||
claudeVersion: buildSupportedVersionSummary(overrides.claudeVersion),
|
||||
auth: buildEligibleAuthSummary(overrides.auth),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('official channels runtime planning', () => {
|
||||
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.'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, string>;
|
||||
@@ -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<string, string>;
|
||||
@@ -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<string, string>;
|
||||
@@ -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<string, string>;
|
||||
@@ -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<string, string>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, unknown>): 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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, unknown>> = [];
|
||||
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<Parameters<typeof handleConfigCommand>[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<string, unknown>) => {
|
||||
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'
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<WebSearchCliInfo> & Pick<WebSearchCliInfo, 'id' | 'name'>): 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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Response> {
|
||||
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<void>((resolve, reject) => {
|
||||
server = app.listen(0, '127.0.0.1');
|
||||
const handleError = (error: Error) => reject(error);
|
||||
server.once('error', handleError);
|
||||
server.once('listening', () => {
|
||||
server.off('error', handleError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<CliproxyUsageApiResponse | null> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
|
||||
<TabsList className="grid w-full grid-cols-6">
|
||||
<TabsList className="grid w-full grid-cols-7">
|
||||
{tabs.map(({ value, label, icon: Icon }) => (
|
||||
<TabsTrigger key={value} value={value} className="gap-1.5 px-1 text-xs">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
@@ -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<string> {
|
||||
try {
|
||||
const data = (await response.json()) as { error?: unknown };
|
||||
return typeof data.error === 'string' && data.error.trim().length > 0 ? data.error : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function useOfficialChannelsConfig() {
|
||||
const [config, setConfig] = useState<OfficialChannelsConfig>(DEFAULT_CONFIG);
|
||||
const [status, setStatus] = useState<OfficialChannelsStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const flashSuccess = useCallback((message: string) => {
|
||||
setSuccess(message);
|
||||
window.setTimeout(() => setSuccess(null), 1500);
|
||||
}, []);
|
||||
|
||||
const fetchConfig = useCallback(async (): Promise<boolean> => {
|
||||
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<OfficialChannelsConfig>,
|
||||
successMessage = 'Settings saved'
|
||||
): Promise<boolean> => {
|
||||
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<boolean> => {
|
||||
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<boolean> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -48,6 +48,7 @@ function lazyWithRetry<T extends ComponentType<unknown>>(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() {
|
||||
<SectionErrorBoundary>
|
||||
<Suspense fallback={<SectionSkeleton />}>
|
||||
{activeTab === 'websearch' && <WebSearchSection />}
|
||||
{activeTab === 'channels' && <ChannelsSection />}
|
||||
{activeTab === 'globalenv' && <GlobalEnvSection />}
|
||||
{activeTab === 'thinking' && <ThinkingSection />}
|
||||
{activeTab === 'proxy' && <ProxySection />}
|
||||
@@ -153,6 +155,7 @@ function SettingsPageInner() {
|
||||
<SectionErrorBoundary>
|
||||
<Suspense fallback={<SectionSkeleton />}>
|
||||
{activeTab === 'websearch' && <WebSearchSection />}
|
||||
{activeTab === 'channels' && <ChannelsSection />}
|
||||
{activeTab === 'globalenv' && <GlobalEnvSection />}
|
||||
{activeTab === 'thinking' && <ThinkingSection />}
|
||||
{activeTab === 'proxy' && <ProxySection />}
|
||||
|
||||
@@ -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<OfficialChannelId, string>;
|
||||
|
||||
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<TokenDrafts>(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<void> => {
|
||||
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<void> => {
|
||||
const saved = await saveToken(channelId, tokenDrafts[channelId]);
|
||||
if (saved) {
|
||||
setTokenDrafts((current) => ({ ...current, [channelId]: '' }));
|
||||
await fetchRawConfig();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearToken = async (channelId: OfficialChannelId): Promise<void> => {
|
||||
const cleared = await clearToken(channelId);
|
||||
if (cleared) {
|
||||
setTokenDrafts((current) => ({ ...current, [channelId]: '' }));
|
||||
await fetchRawConfig();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<RefreshCw className="h-5 w-5 animate-spin" />
|
||||
<span>Loading</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
|
||||
error || success
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'pointer-events-none -translate-y-2 opacity-0'
|
||||
}`}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="py-2 shadow-lg">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
<span className="text-sm font-medium">{success}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-6 p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Official Channels</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure official Claude channels here, then run <code>ccs</code> normally on a
|
||||
supported native Claude session.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
CCS stores only channel selection in <code>config.yaml</code>. Claude keeps the
|
||||
machine-level channel state under <code>~/.claude/channels/</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className={`rounded-xl border p-4 ${getSummaryClasses(status.summary.state)}`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={status.summary.state === 'ready' ? 'default' : 'outline'}>
|
||||
{status.summary.title}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">{selectedChannelLabel}</span>
|
||||
</div>
|
||||
<p className="text-sm">{status.summary.message}</p>
|
||||
<p className="text-sm opacity-90">{status.summary.nextStep}</p>
|
||||
</div>
|
||||
<div className="min-w-[220px] rounded-lg border border-current/10 bg-background/60 p-3 text-sm text-foreground">
|
||||
<p className="font-medium">Machine checks</p>
|
||||
<div className="mt-2 space-y-1 text-muted-foreground">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span>Bun</span>
|
||||
<span>{status.bunInstalled ? 'Installed' : 'Missing'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span>Claude Code</span>
|
||||
<span>
|
||||
{status.claudeVersion.current
|
||||
? `v${status.claudeVersion.current}`
|
||||
: 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span>Claude auth</span>
|
||||
<span>{status.auth.authMethod ?? 'Unknown'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{status.summary.blockers.length > 0 && (
|
||||
<div className="mt-3 space-y-1 text-sm">
|
||||
{status.summary.blockers.map((blocker) => (
|
||||
<p key={blocker}>{blocker}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className="rounded-lg border bg-muted/20 p-4">
|
||||
<p className="font-medium">Fastest path</p>
|
||||
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
<p>1. Turn on the channels you want below.</p>
|
||||
<p>2. Save Telegram or Discord bot tokens here if that channel needs one.</p>
|
||||
<p>
|
||||
3. Run <code>ccs</code> or a native Claude account profile. CCS adds{' '}
|
||||
<code>--channels</code> for you on supported runs.
|
||||
</p>
|
||||
<p>{status.supportMessage}</p>
|
||||
</div>
|
||||
<details className="mt-3 rounded-lg border bg-background p-4">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Advanced notes and scope
|
||||
</summary>
|
||||
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
<p>{status.accountStatusCaveat}</p>
|
||||
<p>{status.stateScopeMessage}</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className="rounded-lg border bg-background p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">
|
||||
If you run <code>ccs</code> now
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{status.launchPreview.detail}</p>
|
||||
</div>
|
||||
<Badge variant={getLaunchPreviewBadgeVariant(status.launchPreview.state)}>
|
||||
{status.launchPreview.title}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="rounded-md bg-muted px-3 py-2 font-mono text-sm">
|
||||
<span className="text-muted-foreground">You type:</span>{' '}
|
||||
{status.launchPreview.command}
|
||||
</div>
|
||||
<div className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all">
|
||||
<span className="text-muted-foreground">CCS adds:</span>{' '}
|
||||
{status.launchPreview.appendedArgs.length > 0
|
||||
? status.launchPreview.appendedArgs.join(' ')
|
||||
: '(nothing yet)'}
|
||||
</div>
|
||||
</div>
|
||||
{status.launchPreview.skippedMessages.length > 0 && (
|
||||
<div className="mt-3 space-y-1 text-sm text-muted-foreground">
|
||||
{status.launchPreview.skippedMessages.map((message) => (
|
||||
<p key={message}>{message}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status?.claudeVersion.message && status.claudeVersion.state !== 'supported' && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{status.claudeVersion.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status?.auth.message && status.auth.state !== 'eligible' && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{status.auth.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status?.auth.orgRequirementMessage && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{status.auth.orgRequirementMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{status?.channels.map((channel) => {
|
||||
const enabled = config.selected.includes(channel.id);
|
||||
const tokenDraft = tokenDrafts[channel.id];
|
||||
|
||||
return (
|
||||
<div key={channel.id} className="rounded-lg border p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<Label className="text-base font-medium">{channel.displayName}</Label>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{channel.summary}</p>
|
||||
<p className="mt-2 font-mono text-xs text-muted-foreground">
|
||||
{channel.pluginSpec}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={getSetupBadgeVariant(channel.setup.state)}>
|
||||
{channel.setup.label}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={saving || (Boolean(channel.unavailableReason) && !enabled)}
|
||||
onCheckedChange={(checked) => void toggleChannel(channel.id, checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted/30 p-4 text-sm text-muted-foreground space-y-2">
|
||||
<p>{channel.setup.detail}</p>
|
||||
<p>{channel.setup.nextStep}</p>
|
||||
</div>
|
||||
|
||||
{channel.requiresToken && (
|
||||
<div className="space-y-3 rounded-lg bg-muted/30 p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{!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.`}
|
||||
</p>
|
||||
{channel.tokenConfigured && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Saving here writes the same <code>.env</code> file as{' '}
|
||||
<code>/{channel.id}:configure</code>, so you do not need to run the
|
||||
configure command again after a successful save.
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
type="password"
|
||||
value={tokenDraft}
|
||||
onChange={(event) => 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' && (
|
||||
<div className="text-xs text-muted-foreground break-all">
|
||||
{channel.tokenPath}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => void handleSaveToken(channel.id)}
|
||||
disabled={saving || !tokenDraft.trim()}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Token
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleClearToken(channel.id)}
|
||||
disabled={saving || !channel.tokenConfigured}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Clear Saved Token
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<details className="rounded-lg border bg-background p-4">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Claude-side setup commands
|
||||
</summary>
|
||||
<div className="mt-3 space-y-2">
|
||||
{(channel.manualSetupCommands ?? []).map((command) => (
|
||||
<div
|
||||
key={command}
|
||||
className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all"
|
||||
>
|
||||
{command}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
CCS injects <code>--channels</code> only for the current Claude session. Telegram,
|
||||
Discord, and iMessage stop receiving messages when that Claude session exits.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg bg-muted/30 p-4">
|
||||
<div className="flex gap-3">
|
||||
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Skip permission prompts on launch</Label>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Optional advanced behavior. CCS adds <code>--dangerously-skip-permissions</code>{' '}
|
||||
only when at least one selected channel is being auto-enabled and you did not
|
||||
already pass a permission flag yourself.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.unattended}
|
||||
disabled={saving}
|
||||
onCheckedChange={(checked) =>
|
||||
void (async () => {
|
||||
const updated = await updateConfig(
|
||||
{ unattended: checked },
|
||||
checked ? 'Unattended mode enabled' : 'Unattended mode disabled'
|
||||
);
|
||||
if (updated) {
|
||||
await fetchRawConfig();
|
||||
}
|
||||
})()
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={() => void refreshAll()} disabled={saving}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${saving ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -350,24 +350,6 @@ export default function WebSearchSection() {
|
||||
fetchRawConfig();
|
||||
}, [fetchConfig, fetchStatus, fetchRawConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.providers) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextDrafts: Record<string, string> = {};
|
||||
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,
|
||||
|
||||
@@ -60,9 +60,95 @@ export interface GlobalEnvConfig {
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
// === 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 ===
|
||||
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
|
||||
import ChannelsSection from '@/pages/settings/sections/channels';
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function textResponse(payload: string, status = 200): Response {
|
||||
return new Response(payload, {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function requestUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.toString();
|
||||
}
|
||||
return input.url;
|
||||
}
|
||||
|
||||
describe('ChannelsSection', () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('keeps the token draft when saving fails and shows the backend error', async () => {
|
||||
fetchMock.mockImplementation(async (input, init) => {
|
||||
const url = requestUrl(input);
|
||||
|
||||
if (url.endsWith('/api/channels') && (!init || init.method === undefined)) {
|
||||
return jsonResponse({
|
||||
config: { selected: ['discord'], unattended: false },
|
||||
status: {
|
||||
bunInstalled: true,
|
||||
supportedProfiles: ['default', 'account'],
|
||||
supportMessage: 'Native Claude only. Not for ccs glm.',
|
||||
accountStatusCaveat: 'Dashboard status reflects the current CCS process.',
|
||||
stateScopeMessage: 'Machine-level Claude state',
|
||||
claudeVersion: {
|
||||
current: '2.1.81',
|
||||
minimum: '2.1.80',
|
||||
state: 'supported',
|
||||
message: 'Claude Code v2.1.81',
|
||||
},
|
||||
auth: {
|
||||
checked: true,
|
||||
loggedIn: true,
|
||||
authMethod: 'claude.ai',
|
||||
subscriptionType: 'pro',
|
||||
state: 'eligible',
|
||||
eligible: true,
|
||||
message: 'Authenticated with claude.ai.',
|
||||
},
|
||||
summary: {
|
||||
state: 'needs_setup',
|
||||
title: 'Needs setup before CCS can auto-add these channels',
|
||||
message: 'Missing bot token for Discord.',
|
||||
nextStep: 'Save DISCORD_BOT_TOKEN below.',
|
||||
blockers: ['Missing bot token for Discord.'],
|
||||
},
|
||||
launchPreview: {
|
||||
state: 'blocked',
|
||||
title: 'Running `ccs` now will not auto-add channels',
|
||||
detail: 'Discord still needs a saved token before CCS can add it.',
|
||||
command: 'ccs',
|
||||
appendedArgs: [],
|
||||
appliedChannels: [],
|
||||
permissionBypassIncluded: false,
|
||||
skippedMessages: ['Discord still needs a saved token before CCS can add it.'],
|
||||
},
|
||||
channels: [
|
||||
{
|
||||
id: 'discord',
|
||||
selected: true,
|
||||
displayName: 'Discord',
|
||||
pluginSpec: 'plugin:discord@claude-plugins-official',
|
||||
summary: 'Bot token required.',
|
||||
requiresToken: true,
|
||||
envKey: 'DISCORD_BOT_TOKEN',
|
||||
tokenConfigured: false,
|
||||
tokenAvailable: false,
|
||||
tokenSource: 'missing',
|
||||
tokenPath: '/tmp/.claude/channels/discord/.env',
|
||||
savedInClaudeState: false,
|
||||
processEnvAvailable: false,
|
||||
setup: {
|
||||
state: 'needs_token',
|
||||
label: 'Needs token',
|
||||
detail: 'DISCORD_BOT_TOKEN is missing.',
|
||||
nextStep: 'Save DISCORD_BOT_TOKEN below.',
|
||||
},
|
||||
manualSetupCommands: ['/discord:configure <token>'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/config/raw')) {
|
||||
return textResponse('channels:\n selected:\n - discord\n');
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/channels/discord/token') && init?.method === 'PUT') {
|
||||
return jsonResponse({ error: 'Discord rejected token' }, 500);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
render(<ChannelsSection />, { withSettingsProvider: true });
|
||||
|
||||
const tokenInput = await screen.findByPlaceholderText('Paste DISCORD_BOT_TOKEN');
|
||||
await userEvent.type(tokenInput, 'discord-secret');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save Token' }));
|
||||
|
||||
expect(await screen.findByText('Discord rejected token')).toBeInTheDocument();
|
||||
expect(tokenInput).toHaveValue('discord-secret');
|
||||
});
|
||||
|
||||
it('keeps the token draft when refresh fails after a successful token save', async () => {
|
||||
let channelsRequestCount = 0;
|
||||
|
||||
fetchMock.mockImplementation(async (input, init) => {
|
||||
const url = requestUrl(input);
|
||||
|
||||
if (url.endsWith('/api/channels') && (!init || init.method === undefined)) {
|
||||
channelsRequestCount += 1;
|
||||
|
||||
if (channelsRequestCount === 1) {
|
||||
return jsonResponse({
|
||||
config: { selected: ['discord'], unattended: false },
|
||||
status: {
|
||||
bunInstalled: true,
|
||||
supportedProfiles: ['default', 'account'],
|
||||
supportMessage: 'Native Claude only. Not for ccs glm.',
|
||||
accountStatusCaveat: 'Dashboard status reflects the current CCS process.',
|
||||
stateScopeMessage: 'Machine-level Claude state',
|
||||
claudeVersion: {
|
||||
current: '2.1.81',
|
||||
minimum: '2.1.80',
|
||||
state: 'supported',
|
||||
message: 'Claude Code v2.1.81',
|
||||
},
|
||||
auth: {
|
||||
checked: true,
|
||||
loggedIn: true,
|
||||
authMethod: 'claude.ai',
|
||||
subscriptionType: 'pro',
|
||||
state: 'eligible',
|
||||
eligible: true,
|
||||
message: 'Authenticated with claude.ai.',
|
||||
},
|
||||
summary: {
|
||||
state: 'needs_setup',
|
||||
title: 'Needs setup before CCS can auto-add these channels',
|
||||
message: 'Missing bot token for Discord.',
|
||||
nextStep: 'Save DISCORD_BOT_TOKEN below.',
|
||||
blockers: ['Missing bot token for Discord.'],
|
||||
},
|
||||
launchPreview: {
|
||||
state: 'blocked',
|
||||
title: 'Running `ccs` now will not auto-add channels',
|
||||
detail: 'Discord still needs a saved token before CCS can add it.',
|
||||
command: 'ccs',
|
||||
appendedArgs: [],
|
||||
appliedChannels: [],
|
||||
permissionBypassIncluded: false,
|
||||
skippedMessages: ['Discord still needs a saved token before CCS can add it.'],
|
||||
},
|
||||
channels: [
|
||||
{
|
||||
id: 'discord',
|
||||
selected: true,
|
||||
displayName: 'Discord',
|
||||
pluginSpec: 'plugin:discord@claude-plugins-official',
|
||||
summary: 'Bot token required.',
|
||||
requiresToken: true,
|
||||
envKey: 'DISCORD_BOT_TOKEN',
|
||||
tokenConfigured: false,
|
||||
tokenAvailable: false,
|
||||
tokenSource: 'missing',
|
||||
tokenPath: '/tmp/.claude/channels/discord/.env',
|
||||
savedInClaudeState: false,
|
||||
processEnvAvailable: false,
|
||||
setup: {
|
||||
state: 'needs_token',
|
||||
label: 'Needs token',
|
||||
detail: 'DISCORD_BOT_TOKEN is missing.',
|
||||
nextStep: 'Save DISCORD_BOT_TOKEN below.',
|
||||
},
|
||||
manualSetupCommands: ['/discord:configure <token>'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResponse({ error: 'Failed to load Official Channels settings' }, 500);
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/config/raw')) {
|
||||
return textResponse('channels:\n selected:\n - discord\n');
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/channels/discord/token') && init?.method === 'PUT') {
|
||||
return jsonResponse({ success: true, tokenConfigured: true, tokenPath: '/tmp/.env' });
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
render(<ChannelsSection />, { withSettingsProvider: true });
|
||||
|
||||
const tokenInput = await screen.findByPlaceholderText('Paste DISCORD_BOT_TOKEN');
|
||||
await userEvent.type(tokenInput, 'discord-secret');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save Token' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Failed to load Official Channels settings')
|
||||
).toBeInTheDocument();
|
||||
expect(tokenInput).toHaveValue('discord-secret');
|
||||
expect(screen.queryByText('discord token saved')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lets users turn off an unsupported selected channel', async () => {
|
||||
let channelsRequestCount = 0;
|
||||
|
||||
fetchMock.mockImplementation(async (input, init) => {
|
||||
const url = requestUrl(input);
|
||||
|
||||
if (url.endsWith('/api/channels') && (!init || init.method === undefined)) {
|
||||
channelsRequestCount += 1;
|
||||
|
||||
return jsonResponse(
|
||||
channelsRequestCount === 1
|
||||
? {
|
||||
config: { selected: ['imessage'], unattended: false },
|
||||
status: {
|
||||
bunInstalled: true,
|
||||
supportedProfiles: ['default', 'account'],
|
||||
supportMessage: 'Native Claude only. Not for ccs glm.',
|
||||
accountStatusCaveat: 'Dashboard status reflects the current CCS process.',
|
||||
stateScopeMessage: 'Machine-level Claude state',
|
||||
claudeVersion: {
|
||||
current: '2.1.81',
|
||||
minimum: '2.1.80',
|
||||
state: 'supported',
|
||||
message: 'Claude Code v2.1.81',
|
||||
},
|
||||
auth: {
|
||||
checked: true,
|
||||
loggedIn: true,
|
||||
authMethod: 'claude.ai',
|
||||
subscriptionType: 'pro',
|
||||
state: 'eligible',
|
||||
eligible: true,
|
||||
message: 'Authenticated with claude.ai.',
|
||||
},
|
||||
summary: {
|
||||
state: 'limited',
|
||||
title: 'Selected, but some channels still need manual setup',
|
||||
message: 'iMessage still needs Claude-side install.',
|
||||
nextStep: 'Review the channel cards below.',
|
||||
blockers: ['iMessage still needs Claude-side install.'],
|
||||
},
|
||||
launchPreview: {
|
||||
state: 'blocked',
|
||||
title: 'Running `ccs` now will not auto-add channels',
|
||||
detail: 'iMessage requires macOS on this machine.',
|
||||
command: 'ccs',
|
||||
appendedArgs: [],
|
||||
appliedChannels: [],
|
||||
permissionBypassIncluded: false,
|
||||
skippedMessages: ['iMessage requires macOS on this machine.'],
|
||||
},
|
||||
channels: [
|
||||
{
|
||||
id: 'imessage',
|
||||
selected: true,
|
||||
displayName: 'iMessage',
|
||||
pluginSpec: 'plugin:imessage@claude-plugins-official',
|
||||
summary: 'macOS only.',
|
||||
requiresToken: false,
|
||||
tokenConfigured: false,
|
||||
tokenAvailable: true,
|
||||
savedInClaudeState: false,
|
||||
processEnvAvailable: false,
|
||||
unavailableReason: 'Requires macOS.',
|
||||
setup: {
|
||||
state: 'unavailable',
|
||||
label: 'Requires macOS.',
|
||||
detail: 'iMessage is selected, but this machine cannot use it right now.',
|
||||
nextStep: 'Turn it off here, or switch to a supported machine.',
|
||||
},
|
||||
manualSetupCommands: ['/imessage:access allow +15551234567'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: {
|
||||
config: { selected: [], unattended: false },
|
||||
status: {
|
||||
bunInstalled: true,
|
||||
supportedProfiles: ['default', 'account'],
|
||||
supportMessage: 'Native Claude only. Not for ccs glm.',
|
||||
accountStatusCaveat: 'Dashboard status reflects the current CCS process.',
|
||||
stateScopeMessage: 'Machine-level Claude state',
|
||||
claudeVersion: {
|
||||
current: '2.1.81',
|
||||
minimum: '2.1.80',
|
||||
state: 'supported',
|
||||
message: 'Claude Code v2.1.81',
|
||||
},
|
||||
auth: {
|
||||
checked: true,
|
||||
loggedIn: true,
|
||||
authMethod: 'claude.ai',
|
||||
subscriptionType: 'pro',
|
||||
state: 'eligible',
|
||||
eligible: true,
|
||||
message: 'Authenticated with claude.ai.',
|
||||
},
|
||||
summary: {
|
||||
state: 'needs_setup',
|
||||
title: 'No channels selected yet',
|
||||
message: 'Choose at least one official channel before CCS can auto-add it.',
|
||||
nextStep: 'Turn on Telegram, Discord, and/or iMessage below.',
|
||||
blockers: ['Select at least one channel for auto-enable.'],
|
||||
},
|
||||
launchPreview: {
|
||||
state: 'disabled',
|
||||
title: 'Nothing will be auto-added yet',
|
||||
detail:
|
||||
'Turn on at least one channel below before `ccs` can add official channel flags.',
|
||||
command: 'ccs',
|
||||
appendedArgs: [],
|
||||
appliedChannels: [],
|
||||
permissionBypassIncluded: false,
|
||||
skippedMessages: [],
|
||||
},
|
||||
channels: [
|
||||
{
|
||||
id: 'imessage',
|
||||
selected: false,
|
||||
displayName: 'iMessage',
|
||||
pluginSpec: 'plugin:imessage@claude-plugins-official',
|
||||
summary: 'macOS only.',
|
||||
requiresToken: false,
|
||||
tokenConfigured: false,
|
||||
tokenAvailable: true,
|
||||
savedInClaudeState: false,
|
||||
processEnvAvailable: false,
|
||||
unavailableReason: 'Requires macOS.',
|
||||
setup: {
|
||||
state: 'not_selected',
|
||||
label: 'Not selected',
|
||||
detail: 'CCS will not auto-add this channel until you turn it on here.',
|
||||
nextStep:
|
||||
'Turn this channel on if you want CCS to add it on supported native Claude runs.',
|
||||
},
|
||||
manualSetupCommands: ['/imessage:access allow +15551234567'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/config/raw')) {
|
||||
return textResponse('channels:\n selected:\n - imessage\n');
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/channels') && init?.method === 'PUT') {
|
||||
return jsonResponse({
|
||||
config: { selected: [], unattended: false },
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
render(<ChannelsSection />, { withSettingsProvider: true });
|
||||
|
||||
const switches = await screen.findAllByRole('switch');
|
||||
const imessageSwitch = switches[0];
|
||||
|
||||
expect(imessageSwitch).not.toBeDisabled();
|
||||
|
||||
await userEvent.click(imessageSwitch);
|
||||
|
||||
await waitFor(() => {
|
||||
const updateCall = fetchMock.mock.calls.find(
|
||||
([input, init]) => requestUrl(input).endsWith('/api/channels') && init?.method === 'PUT'
|
||||
);
|
||||
|
||||
expect(updateCall).toBeDefined();
|
||||
expect(JSON.parse(String(updateCall?.[1]?.body))).toEqual({ selected: [] });
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces process-env readiness and the native-only limitation clearly', async () => {
|
||||
fetchMock.mockImplementation(async (input, init) => {
|
||||
const url = requestUrl(input);
|
||||
|
||||
if (url.endsWith('/api/channels') && (!init || init.method === undefined)) {
|
||||
return jsonResponse({
|
||||
config: { selected: ['discord'], unattended: false },
|
||||
status: {
|
||||
bunInstalled: true,
|
||||
supportedProfiles: ['default', 'account'],
|
||||
supportMessage:
|
||||
'Works only for native Claude default/account sessions. Not for ccs glm.',
|
||||
accountStatusCaveat: 'Dashboard status reflects the current CCS process.',
|
||||
stateScopeMessage: 'Machine-level Claude state',
|
||||
claudeVersion: {
|
||||
current: '2.1.81',
|
||||
minimum: '2.1.80',
|
||||
state: 'supported',
|
||||
message: 'Claude Code v2.1.81',
|
||||
},
|
||||
auth: {
|
||||
checked: true,
|
||||
loggedIn: true,
|
||||
authMethod: 'claude.ai',
|
||||
subscriptionType: 'pro',
|
||||
state: 'eligible',
|
||||
eligible: true,
|
||||
message: 'Authenticated with claude.ai.',
|
||||
},
|
||||
summary: {
|
||||
state: 'ready',
|
||||
title: 'Ready for the next native Claude run',
|
||||
message: 'CCS can auto-add Discord the next time you run ccs.',
|
||||
nextStep: 'Run ccs from a supported native Claude session.',
|
||||
blockers: [],
|
||||
},
|
||||
launchPreview: {
|
||||
state: 'ready',
|
||||
title: 'CCS will auto-add Discord',
|
||||
detail:
|
||||
'Running `ccs` will add the selected official channels automatically on this machine.',
|
||||
command: 'ccs',
|
||||
appendedArgs: ['--channels', 'plugin:discord@claude-plugins-official'],
|
||||
appliedChannels: ['discord'],
|
||||
permissionBypassIncluded: false,
|
||||
skippedMessages: [],
|
||||
},
|
||||
channels: [
|
||||
{
|
||||
id: 'discord',
|
||||
selected: true,
|
||||
displayName: 'Discord',
|
||||
pluginSpec: 'plugin:discord@claude-plugins-official',
|
||||
summary: 'Bot token required.',
|
||||
requiresToken: true,
|
||||
envKey: 'DISCORD_BOT_TOKEN',
|
||||
tokenConfigured: false,
|
||||
tokenAvailable: true,
|
||||
tokenSource: 'process_env',
|
||||
savedInClaudeState: false,
|
||||
processEnvAvailable: true,
|
||||
setup: {
|
||||
state: 'ready',
|
||||
label: 'Ready from current CCS process env',
|
||||
detail: 'DISCORD_BOT_TOKEN is available from the current CCS process env.',
|
||||
nextStep: 'Run CCS from this same env.',
|
||||
},
|
||||
manualSetupCommands: ['/discord:configure <token>'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/config/raw')) {
|
||||
return textResponse('channels:\n selected:\n - discord\n');
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
render(<ChannelsSection />, { withSettingsProvider: true });
|
||||
|
||||
expect(await screen.findByText('Ready for the next native Claude run')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fastest path')).toBeInTheDocument();
|
||||
expect(screen.getByText(/If you run/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('CCS will auto-add Discord')).toBeInTheDocument();
|
||||
expect(screen.getByText(/CCS adds:/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Not for ccs glm/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Ready from current CCS process env')).toBeInTheDocument();
|
||||
expect(screen.getByText('Skip permission prompts on launch')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText(
|
||||
'Using current CCS process env. Enter a new DISCORD_BOT_TOKEN to save it for Claude.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user