feat(channels): auto-enable official Claude channels

This commit is contained in:
Tam Nhu Tran
2026-03-25 16:31:55 -04:00
parent 0e2f47802b
commit a97fc42b10
18 changed files with 2653 additions and 337 deletions
@@ -6,6 +6,15 @@ const DEFAULT_CONFIG: OfficialChannelsConfig = {
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);
@@ -19,13 +28,13 @@ export function useOfficialChannelsConfig() {
window.setTimeout(() => setSuccess(null), 1500);
}, []);
const fetchConfig = useCallback(async () => {
const fetchConfig = useCallback(async (): Promise<boolean> => {
try {
setLoading(true);
setError(null);
const res = await fetch('/api/channels');
if (!res.ok) {
throw new Error('Failed to load Official Channels settings');
throw new Error(await readErrorMessage(res, 'Failed to load Official Channels settings'));
}
const data = (await res.json()) as {
@@ -35,15 +44,20 @@ export function useOfficialChannelsConfig() {
setConfig(data.config ?? DEFAULT_CONFIG);
setStatus(data.status ?? null);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
return false;
} finally {
setLoading(false);
}
}, []);
const updateConfig = useCallback(
async (updates: Partial<OfficialChannelsConfig>, successMessage = 'Settings saved') => {
async (
updates: Partial<OfficialChannelsConfig>,
successMessage = 'Settings saved'
): Promise<boolean> => {
try {
setSaving(true);
setError(null);
@@ -55,24 +69,25 @@ export function useOfficialChannelsConfig() {
});
if (!res.ok) {
const data = (await res.json()) as { error?: string };
throw new Error(data.error || 'Failed to save Official Channels settings');
throw new Error(await readErrorMessage(res, 'Failed to save Official Channels settings'));
}
const data = (await res.json()) as { config?: OfficialChannelsConfig };
setConfig(data.config ?? { ...config, ...updates });
setConfig((current) => data.config ?? { ...current, ...updates });
flashSuccess(successMessage);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
return false;
} finally {
setSaving(false);
}
},
[config, flashSuccess]
[flashSuccess]
);
const saveToken = useCallback(
async (channelId: OfficialChannelId, token: string) => {
async (channelId: OfficialChannelId, token: string): Promise<boolean> => {
try {
setSaving(true);
setError(null);
@@ -84,14 +99,19 @@ export function useOfficialChannelsConfig() {
});
if (!res.ok) {
const data = (await res.json()) as { error?: string };
throw new Error(data.error || `Failed to save ${channelId} token`);
throw new Error(await readErrorMessage(res, `Failed to save ${channelId} token`));
}
const refreshed = await fetchConfig();
if (!refreshed) {
return false;
}
await fetchConfig();
flashSuccess(`${channelId} token saved`);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
return false;
} finally {
setSaving(false);
}
@@ -100,7 +120,7 @@ export function useOfficialChannelsConfig() {
);
const clearToken = useCallback(
async (channelId: OfficialChannelId) => {
async (channelId: OfficialChannelId): Promise<boolean> => {
try {
setSaving(true);
setError(null);
@@ -110,14 +130,19 @@ export function useOfficialChannelsConfig() {
});
if (!res.ok) {
const data = (await res.json()) as { error?: string };
throw new Error(data.error || `Failed to clear ${channelId} token`);
throw new Error(await readErrorMessage(res, `Failed to clear ${channelId} token`));
}
const refreshed = await fetchConfig();
if (!refreshed) {
return false;
}
await fetchConfig();
flashSuccess(`${channelId} token cleared`);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
return false;
} finally {
setSaving(false);
}
+287 -89
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -26,6 +27,62 @@ const EMPTY_DRAFTS: TokenDrafts = {
imessage: '',
};
function getSummaryClasses(state: 'ready' | 'needs_setup' | 'limited'): string {
if (state === 'ready') {
return 'border-green-200 bg-green-50 text-green-900 dark:border-green-900/60 dark:bg-green-950/40 dark:text-green-100';
}
if (state === 'limited') {
return 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100';
}
return 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-100';
}
function getSetupBadgeVariant(state: string): 'default' | 'secondary' | 'destructive' | 'outline' {
if (state === 'ready') {
return 'default';
}
if (state === 'not_selected') {
return 'secondary';
}
if (state === 'unavailable') {
return 'destructive';
}
return 'outline';
}
function getLaunchPreviewBadgeVariant(
state: 'disabled' | 'blocked' | 'partial' | 'ready'
): 'default' | 'secondary' | 'destructive' | 'outline' {
if (state === 'ready') {
return 'default';
}
if (state === 'partial') {
return 'outline';
}
if (state === 'blocked') {
return 'destructive';
}
return 'secondary';
}
function getSelectedChannelLabel(
selected: OfficialChannelId[],
channels: Array<{ id: OfficialChannelId; displayName: string }> | undefined
): string {
if (selected.length === 0) {
return 'None selected';
}
return selected
.map(
(channelId) => channels?.find((channel) => channel.id === channelId)?.displayName ?? channelId
)
.join(', ');
}
export default function ChannelsSection() {
const {
config,
@@ -41,6 +98,7 @@ export default function ChannelsSection() {
} = useOfficialChannelsConfig();
const { fetchRawConfig } = useRawConfig();
const [tokenDrafts, setTokenDrafts] = useState<TokenDrafts>(EMPTY_DRAFTS);
const selectedChannelLabel = getSelectedChannelLabel(config.selected, status?.channels);
useEffect(() => {
void fetchConfig();
@@ -56,11 +114,13 @@ export default function ChannelsSection() {
? [...new Set([...config.selected, channelId])]
: config.selected.filter((value) => value !== channelId);
await updateConfig(
const updated = await updateConfig(
{ selected: nextSelected },
checked ? `${channelId} enabled` : `${channelId} disabled`
checked ? `${channelId} selected for auto-enable` : `${channelId} removed from auto-enable`
);
await fetchRawConfig();
if (updated) {
await Promise.all([fetchConfig(), fetchRawConfig()]);
}
};
const updateTokenDraft = (channelId: OfficialChannelId, value: string) => {
@@ -68,15 +128,19 @@ export default function ChannelsSection() {
};
const handleSaveToken = async (channelId: OfficialChannelId): Promise<void> => {
await saveToken(channelId, tokenDrafts[channelId]);
setTokenDrafts((current) => ({ ...current, [channelId]: '' }));
await fetchRawConfig();
const saved = await saveToken(channelId, tokenDrafts[channelId]);
if (saved) {
setTokenDrafts((current) => ({ ...current, [channelId]: '' }));
await fetchRawConfig();
}
};
const handleClearToken = async (channelId: OfficialChannelId): Promise<void> => {
await clearToken(channelId);
setTokenDrafts((current) => ({ ...current, [channelId]: '' }));
await fetchRawConfig();
const cleared = await clearToken(channelId);
if (cleared) {
setTokenDrafts((current) => ({ ...current, [channelId]: '' }));
await fetchRawConfig();
}
};
if (loading) {
@@ -115,66 +179,145 @@ export default function ChannelsSection() {
<ScrollArea className="flex-1">
<div className="space-y-6 p-5">
<div className="flex items-center gap-3">
<div className="flex items-start gap-3">
<MessageSquare className="h-5 w-5 text-primary" />
<p className="text-sm text-muted-foreground">
Auto-enable Anthropic&apos;s official Claude channels for compatible native Claude
sessions. CCS stores only channel selection in <code>config.yaml</code>; bot tokens
stay in Claude&apos;s per-channel env files.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border bg-muted/30 p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Selected</p>
<p className="mt-1 font-medium">
{config.selected.length > 0 ? config.selected.join(', ') : 'None'}
<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="mt-2 text-sm text-muted-foreground">
Applies only to native Claude <code>default</code> and <code>account</code>{' '}
sessions.
<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 className="rounded-lg border bg-muted/30 p-4 space-y-2">
<div className="flex items-center justify-between text-sm">
<span>Bun</span>
<span className={status?.bunInstalled ? 'text-green-600' : 'text-amber-600'}>
{status?.bunInstalled ? 'Installed' : 'Missing'}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span>Supported profiles</span>
<span>{status?.supportedProfiles.join(', ')}</span>
</div>
</div>
</div>
<div 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">
Also add <code>--dangerously-skip-permissions</code>
</Label>
<p className="mt-1 text-sm text-muted-foreground">
Opt-in only. CCS adds the bypass flag once when at least one selected channel
is being auto-enabled and you did not already pass a permission flag yourself.
</p>
{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>
<Switch
checked={config.unattended}
disabled={saving}
onCheckedChange={(checked) =>
void updateConfig(
{ unattended: checked },
checked ? 'Unattended mode enabled' : 'Unattended mode disabled'
)
}
/>
{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>
</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) => {
@@ -190,23 +333,40 @@ export default function ChannelsSection() {
<p className="mt-2 font-mono text-xs text-muted-foreground">
{channel.pluginSpec}
</p>
{channel.unavailableReason && (
<p className="mt-2 text-sm text-amber-600">{channel.unavailableReason}</p>
)}
</div>
<Switch
checked={enabled}
disabled={saving || Boolean(channel.unavailableReason)}
onCheckedChange={(checked) => void toggleChannel(channel.id, checked)}
/>
<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">
Save <code>{channel.envKey}</code> in Claude&apos;s official channel env
file. The dashboard never reads the token value back after save.
{!channel.tokenConfigured && channel.tokenSource === 'process_env'
? `The current CCS process already has ${channel.envKey}. Save it here only if you want persistent Claude channel state.`
: channel.tokenConfigured && channel.processEnvAvailable
? `${channel.envKey} is saved in Claude channel state, and the current CCS process env also provides it.`
: `Save ${channel.envKey} in Claude's official channel env file. The dashboard never reads the token value back after save.`}
</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}
@@ -214,13 +374,17 @@ export default function ChannelsSection() {
placeholder={
channel.tokenConfigured
? `Configured. Enter a new ${channel.envKey} to replace it.`
: `Paste ${channel.envKey}`
: !channel.tokenConfigured && channel.tokenSource === 'process_env'
? `Using current CCS process env. Enter a new ${channel.envKey} to save it for Claude.`
: `Paste ${channel.envKey}`
}
disabled={saving}
/>
<div className="text-xs text-muted-foreground break-all">
{channel.tokenPath}
</div>
{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)}
@@ -235,23 +399,27 @@ export default function ChannelsSection() {
disabled={saving || !channel.tokenConfigured}
>
<Trash2 className="mr-2 h-4 w-4" />
Clear Token
Clear Saved Token
</Button>
</div>
</div>
)}
<div className="space-y-2">
<p className="text-sm font-medium">Claude-side setup</p>
{(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 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>
);
})}
@@ -259,12 +427,42 @@ export default function ChannelsSection() {
<Alert>
<AlertDescription>
CCS does not persist a global Claude setting for channels. It only prepares channel
env files and injects runtime flags when the selected channels are compatible and
ready.
CCS injects <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' : ''}`} />
+51
View File
@@ -71,20 +71,71 @@ export interface OfficialChannelsConfig {
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[];
}