mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 18:18:21 +00:00
feat(channels): support telegram and imessage
- replace the Discord-only config with multi-channel official channels selection - add Telegram token handling and iMessage platform-aware runtime gating - expand CLI, dashboard, and tests for Telegram, Discord, and iMessage Refs #783
This commit is contained in:
1 parent
4bce1559dd
commit
6f1f032c63
19 files changed
+1416
-725
No files matched your search
+38
-35
@@ -1,14 +1,14 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { DiscordChannelsConfig, DiscordChannelsStatus } from '../types';
|
||||
import type { OfficialChannelId, OfficialChannelsConfig, OfficialChannelsStatus } from '../types';
|
||||
|
||||
const DEFAULT_CONFIG: DiscordChannelsConfig = {
|
||||
enabled: false,
|
||||
const DEFAULT_CONFIG: OfficialChannelsConfig = {
|
||||
selected: [],
|
||||
unattended: false,
|
||||
};
|
||||
|
||||
export function useDiscordChannelsConfig() {
|
||||
const [config, setConfig] = useState<DiscordChannelsConfig>(DEFAULT_CONFIG);
|
||||
const [status, setStatus] = useState<DiscordChannelsStatus | null>(null);
|
||||
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);
|
||||
@@ -25,12 +25,12 @@ export function useDiscordChannelsConfig() {
|
||||
setError(null);
|
||||
const res = await fetch('/api/channels');
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to load Discord Channels settings');
|
||||
throw new Error('Failed to load Official Channels settings');
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
config?: DiscordChannelsConfig;
|
||||
status?: DiscordChannelsStatus;
|
||||
config?: OfficialChannelsConfig;
|
||||
status?: OfficialChannelsStatus;
|
||||
};
|
||||
|
||||
setConfig(data.config ?? DEFAULT_CONFIG);
|
||||
@@ -43,7 +43,7 @@ export function useDiscordChannelsConfig() {
|
||||
}, []);
|
||||
|
||||
const updateConfig = useCallback(
|
||||
async (updates: Partial<DiscordChannelsConfig>, successMessage = 'Settings saved') => {
|
||||
async (updates: Partial<OfficialChannelsConfig>, successMessage = 'Settings saved') => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
@@ -56,10 +56,10 @@ export function useDiscordChannelsConfig() {
|
||||
|
||||
if (!res.ok) {
|
||||
const data = (await res.json()) as { error?: string };
|
||||
throw new Error(data.error || 'Failed to save Discord Channels settings');
|
||||
throw new Error(data.error || 'Failed to save Official Channels settings');
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { config?: DiscordChannelsConfig };
|
||||
const data = (await res.json()) as { config?: OfficialChannelsConfig };
|
||||
setConfig(data.config ?? { ...config, ...updates });
|
||||
flashSuccess(successMessage);
|
||||
} catch (err) {
|
||||
@@ -72,12 +72,12 @@ export function useDiscordChannelsConfig() {
|
||||
);
|
||||
|
||||
const saveToken = useCallback(
|
||||
async (token: string) => {
|
||||
async (channelId: OfficialChannelId, token: string) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch('/api/channels/discord/token', {
|
||||
const res = await fetch(`/api/channels/${channelId}/token`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
@@ -85,11 +85,11 @@ export function useDiscordChannelsConfig() {
|
||||
|
||||
if (!res.ok) {
|
||||
const data = (await res.json()) as { error?: string };
|
||||
throw new Error(data.error || 'Failed to save Discord bot token');
|
||||
throw new Error(data.error || `Failed to save ${channelId} token`);
|
||||
}
|
||||
|
||||
await fetchConfig();
|
||||
flashSuccess('Discord bot token saved');
|
||||
flashSuccess(`${channelId} token saved`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
@@ -99,28 +99,31 @@ export function useDiscordChannelsConfig() {
|
||||
[fetchConfig, flashSuccess]
|
||||
);
|
||||
|
||||
const clearToken = useCallback(async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const clearToken = useCallback(
|
||||
async (channelId: OfficialChannelId) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch('/api/channels/discord/token', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const res = await fetch(`/api/channels/${channelId}/token`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = (await res.json()) as { error?: string };
|
||||
throw new Error(data.error || 'Failed to clear Discord bot token');
|
||||
if (!res.ok) {
|
||||
const data = (await res.json()) as { error?: string };
|
||||
throw new Error(data.error || `Failed to clear ${channelId} token`);
|
||||
}
|
||||
|
||||
await fetchConfig();
|
||||
flashSuccess(`${channelId} token cleared`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
await fetchConfig();
|
||||
flashSuccess('Discord bot token cleared');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [fetchConfig, flashSuccess]);
|
||||
},
|
||||
[fetchConfig, flashSuccess]
|
||||
);
|
||||
|
||||
return {
|
||||
config,
|
||||
@@ -7,7 +7,6 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
@@ -15,8 +14,17 @@ import {
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useDiscordChannelsConfig } from '../hooks/use-discord-channels-config';
|
||||
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: '',
|
||||
};
|
||||
|
||||
export default function ChannelsSection() {
|
||||
const {
|
||||
@@ -30,9 +38,9 @@ export default function ChannelsSection() {
|
||||
updateConfig,
|
||||
saveToken,
|
||||
clearToken,
|
||||
} = useDiscordChannelsConfig();
|
||||
} = useOfficialChannelsConfig();
|
||||
const { fetchRawConfig } = useRawConfig();
|
||||
const [tokenDraft, setTokenDraft] = useState('');
|
||||
const [tokenDrafts, setTokenDrafts] = useState<TokenDrafts>(EMPTY_DRAFTS);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchConfig();
|
||||
@@ -43,31 +51,39 @@ export default function ChannelsSection() {
|
||||
await Promise.all([fetchConfig(), fetchRawConfig()]);
|
||||
};
|
||||
|
||||
const handleToggle = async (
|
||||
updates: Partial<typeof config>,
|
||||
successMessage: string
|
||||
): Promise<void> => {
|
||||
await updateConfig(updates, successMessage);
|
||||
const toggleChannel = async (channelId: OfficialChannelId, checked: boolean): Promise<void> => {
|
||||
const nextSelected = checked
|
||||
? [...new Set([...config.selected, channelId])]
|
||||
: config.selected.filter((value) => value !== channelId);
|
||||
|
||||
await updateConfig(
|
||||
{ selected: nextSelected },
|
||||
checked ? `${channelId} enabled` : `${channelId} disabled`
|
||||
);
|
||||
await fetchRawConfig();
|
||||
};
|
||||
|
||||
const handleSaveToken = async (): Promise<void> => {
|
||||
await saveToken(tokenDraft);
|
||||
setTokenDraft('');
|
||||
const updateTokenDraft = (channelId: OfficialChannelId, value: string) => {
|
||||
setTokenDrafts((current) => ({ ...current, [channelId]: value }));
|
||||
};
|
||||
|
||||
const handleSaveToken = async (channelId: OfficialChannelId): Promise<void> => {
|
||||
await saveToken(channelId, tokenDrafts[channelId]);
|
||||
setTokenDrafts((current) => ({ ...current, [channelId]: '' }));
|
||||
await fetchRawConfig();
|
||||
};
|
||||
|
||||
const handleClearToken = async (): Promise<void> => {
|
||||
await clearToken();
|
||||
setTokenDraft('');
|
||||
const handleClearToken = async (channelId: OfficialChannelId): Promise<void> => {
|
||||
await clearToken(channelId);
|
||||
setTokenDrafts((current) => ({ ...current, [channelId]: '' }));
|
||||
await fetchRawConfig();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
<RefreshCw className="h-5 w-5 animate-spin" />
|
||||
<span>Loading</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,8 +95,8 @@ export default function ChannelsSection() {
|
||||
<div
|
||||
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
|
||||
error || success
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-2 pointer-events-none'
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'pointer-events-none -translate-y-2 opacity-0'
|
||||
}`}
|
||||
>
|
||||
{error && (
|
||||
@@ -90,7 +106,7 @@ export default function ChannelsSection() {
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
||||
<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>
|
||||
@@ -98,20 +114,22 @@ export default function ChannelsSection() {
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-5 space-y-6">
|
||||
<div className="space-y-6 p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageSquare className="w-5 h-5 text-primary" />
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Auto-enable Anthropic's official Discord Channels plugin for compatible Claude
|
||||
sessions. CCS stores only the booleans in <code>config.yaml</code>; the bot token
|
||||
stays in Claude's official channels env file.
|
||||
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">Runtime</p>
|
||||
<p className="mt-1 font-medium">{status?.pluginSpec ?? 'Unknown plugin'}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Selected</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{config.selected.length > 0 ? config.selected.join(', ') : 'None'}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Applies only to native Claude <code>default</code> and <code>account</code>{' '}
|
||||
sessions.
|
||||
@@ -125,48 +143,23 @@ export default function ChannelsSection() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>Bot token</span>
|
||||
<span className={status?.tokenConfigured ? 'text-green-600' : 'text-amber-600'}>
|
||||
{status?.tokenConfigured ? 'Configured' : 'Not configured'}
|
||||
</span>
|
||||
<span>Supported profiles</span>
|
||||
<span>{status?.supportedProfiles.join(', ')}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground break-all">{status?.tokenPath}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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">Enable Discord Channels on launch</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
When enabled, CCS appends the official Discord Channels plugin at runtime unless
|
||||
you already passed your own <code>--channels</code> flag.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
disabled={saving}
|
||||
onCheckedChange={(checked) =>
|
||||
void handleToggle(
|
||||
{ enabled: checked },
|
||||
checked
|
||||
? 'Discord Channels auto-enable enabled'
|
||||
: 'Discord Channels auto-enable disabled'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</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="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
|
||||
<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="text-sm text-muted-foreground mt-1">
|
||||
Opt-in only. CCS adds the bypass flag only when it is auto-enabling Discord
|
||||
Channels and you did not already set a permission flag yourself.
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,83 +167,109 @@ export default function ChannelsSection() {
|
||||
checked={config.unattended}
|
||||
disabled={saving}
|
||||
onCheckedChange={(checked) =>
|
||||
void handleToggle(
|
||||
void updateConfig(
|
||||
{ unattended: checked },
|
||||
checked
|
||||
? 'Unattended Discord Channels enabled'
|
||||
: 'Unattended Discord Channels disabled'
|
||||
checked ? 'Unattended mode enabled' : 'Unattended mode disabled'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
<Label className="text-base font-medium">Discord bot token</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Save <code>DISCORD_BOT_TOKEN</code> into Claude's official Discord channel env
|
||||
file. The dashboard never reads the token value back after save.
|
||||
</p>
|
||||
<Input
|
||||
type="password"
|
||||
value={tokenDraft}
|
||||
onChange={(event) => setTokenDraft(event.target.value)}
|
||||
placeholder={
|
||||
status?.tokenConfigured
|
||||
? 'Configured. Enter a new token to replace it.'
|
||||
: 'Paste DISCORD_BOT_TOKEN'
|
||||
}
|
||||
disabled={saving}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => void handleSaveToken()}
|
||||
disabled={saving || !tokenDraft.trim()}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Token
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleClearToken()}
|
||||
disabled={saving || !status?.tokenConfigured}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Clear Token
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => void refreshAll()} disabled={saving}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${saving ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{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.
|
||||
</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.`
|
||||
: `Paste ${channel.envKey}`
|
||||
}
|
||||
disabled={saving}
|
||||
/>
|
||||
<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 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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
CCS does not persist a global Claude setting for channels. It only prepares the token
|
||||
file and injects runtime flags when the session is compatible and the prerequisites
|
||||
are present.
|
||||
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.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<Label className="text-base font-medium">Claude-side setup</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
If the plugin is not ready yet, complete the official setup once inside Claude.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{(status?.manualSetupCommands ?? []).map((command) => (
|
||||
<div
|
||||
key={command}
|
||||
className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all"
|
||||
>
|
||||
{command}
|
||||
</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>
|
||||
|
||||
@@ -60,22 +60,34 @@ export interface GlobalEnvConfig {
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
// === Discord Channels Types ===
|
||||
// === Official Channels Types ===
|
||||
|
||||
export interface DiscordChannelsConfig {
|
||||
enabled: boolean;
|
||||
export type OfficialChannelId = 'telegram' | 'discord' | 'imessage';
|
||||
|
||||
export interface OfficialChannelsConfig {
|
||||
selected: OfficialChannelId[];
|
||||
unattended: boolean;
|
||||
}
|
||||
|
||||
export interface DiscordChannelsStatus {
|
||||
bunInstalled: boolean;
|
||||
tokenConfigured: boolean;
|
||||
tokenPath: string;
|
||||
export interface OfficialChannelStatus {
|
||||
id: OfficialChannelId;
|
||||
displayName: string;
|
||||
pluginSpec: string;
|
||||
supportedProfiles: string[];
|
||||
summary: string;
|
||||
requiresToken: boolean;
|
||||
envKey?: string;
|
||||
tokenConfigured: boolean;
|
||||
tokenPath?: string;
|
||||
unavailableReason?: string;
|
||||
manualSetupCommands: string[];
|
||||
}
|
||||
|
||||
export interface OfficialChannelsStatus {
|
||||
bunInstalled: boolean;
|
||||
supportedProfiles: string[];
|
||||
channels: OfficialChannelStatus[];
|
||||
}
|
||||
|
||||
// === Tab Types ===
|
||||
|
||||
export type SettingsTab =
|
||||
|
||||
Reference in new issue
Block a user