mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 22:15:33 +00:00
feat(channels): auto-enable official Claude channels
This commit is contained in:
1 parent
0e2f47802b
commit
a97fc42b10
18 files changed
+2653
-337
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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's official Claude channels for compatible native Claude
|
||||
sessions. CCS stores only channel selection in <code>config.yaml</code>; bot tokens
|
||||
stay in Claude'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'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' : ''}`} />
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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