mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-26 10:41:41 +00:00
fix(cli): add "API Key" option to qwen auth interactive menu
The `qwen auth` CLI command only showed 2 options (Coding Plan, Qwen OAuth), while the interactive `/auth` dialog showed 3 (Coding Plan, API Key, Qwen OAuth). Users following the README instructions to configure OpenRouter/Fireworks via `qwen auth` had no API Key entry point. - Add "API Key" option to the `runInteractiveAuth` menu with two sub-paths: "Alibaba Cloud ModelStudio Standard API Key" (guided flow) and "Custom API Key" (prints docs link) - Add `qwen auth api-key` yargs subcommand for direct access - Extract `createMinimalArgv` / `loadAuthConfig` helpers to eliminate duplicated CliArgs boilerplate - Extract `promptForInput` to share raw-mode stdin logic between `promptForKey` and `promptForModelIds` - Improve `showAuthStatus` to distinguish Coding Plan, Standard API Key, and generic OpenAI-compatible configurations - Align menu labels and descriptions with the interactive `/auth` dialog Closes #3413 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
83d1e6dcae
commit
0d5221490d
2 changed files with 355 additions and 83 deletions
|
|
@ -7,6 +7,7 @@
|
|||
import type { CommandModule, Argv } from 'yargs';
|
||||
import {
|
||||
handleQwenAuth,
|
||||
handleApiKeyAuth,
|
||||
runInteractiveAuth,
|
||||
showAuthStatus,
|
||||
} from './auth/handler.js';
|
||||
|
|
@ -50,6 +51,14 @@ const codePlanCommand = {
|
|||
},
|
||||
};
|
||||
|
||||
const apiKeyCommand = {
|
||||
command: 'api-key',
|
||||
describe: t('Authenticate using an API key'),
|
||||
handler: async () => {
|
||||
await handleApiKeyAuth();
|
||||
},
|
||||
};
|
||||
|
||||
const statusCommand = {
|
||||
command: 'status',
|
||||
describe: t('Show current authentication status'),
|
||||
|
|
@ -61,12 +70,13 @@ const statusCommand = {
|
|||
export const authCommand: CommandModule = {
|
||||
command: 'auth',
|
||||
describe: t(
|
||||
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan',
|
||||
'Configure Qwen authentication with Coding Plan, API Key, or Qwen-OAuth',
|
||||
),
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.command(qwenOauthCommand)
|
||||
.command(codePlanCommand)
|
||||
.command(apiKeyCommand)
|
||||
.command(statusCommand)
|
||||
.demandCommand(0) // Don't require a subcommand
|
||||
.version(false),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
|||
import { loadCliConfig } from '../../config/config.js';
|
||||
import type { CliArgs } from '../../config/config.js';
|
||||
import { InteractiveSelector } from './interactiveSelector.js';
|
||||
import {
|
||||
ALIBABA_STANDARD_API_KEY_ENDPOINTS,
|
||||
DASHSCOPE_STANDARD_API_KEY_ENV_KEY,
|
||||
type AlibabaStandardRegion,
|
||||
} from '../../constants/alibabaStandardApiKey.js';
|
||||
|
||||
interface QwenAuthOptions {
|
||||
region?: string;
|
||||
|
|
@ -49,6 +54,76 @@ interface MergedSettingsWithCodingPlan {
|
|||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a minimal CliArgs for auth command config loading
|
||||
*/
|
||||
function createMinimalArgv(): CliArgs {
|
||||
return {
|
||||
query: undefined,
|
||||
model: undefined,
|
||||
sandbox: undefined,
|
||||
sandboxImage: undefined,
|
||||
debug: undefined,
|
||||
prompt: undefined,
|
||||
promptInteractive: undefined,
|
||||
yolo: undefined,
|
||||
bare: undefined,
|
||||
approvalMode: undefined,
|
||||
telemetry: undefined,
|
||||
checkpointing: undefined,
|
||||
telemetryTarget: undefined,
|
||||
telemetryOtlpEndpoint: undefined,
|
||||
telemetryOtlpProtocol: undefined,
|
||||
telemetryLogPrompts: undefined,
|
||||
telemetryOutfile: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
acp: undefined,
|
||||
experimentalAcp: undefined,
|
||||
experimentalLsp: undefined,
|
||||
extensions: [],
|
||||
listExtensions: undefined,
|
||||
openaiLogging: undefined,
|
||||
openaiApiKey: undefined,
|
||||
openaiBaseUrl: undefined,
|
||||
openaiLoggingDir: undefined,
|
||||
proxy: undefined,
|
||||
includeDirectories: undefined,
|
||||
screenReader: undefined,
|
||||
inputFormat: undefined,
|
||||
outputFormat: undefined,
|
||||
includePartialMessages: undefined,
|
||||
chatRecording: undefined,
|
||||
continue: undefined,
|
||||
resume: undefined,
|
||||
sessionId: undefined,
|
||||
maxSessionTurns: undefined,
|
||||
coreTools: undefined,
|
||||
excludeTools: undefined,
|
||||
disabledSlashCommands: undefined,
|
||||
authType: undefined,
|
||||
channel: undefined,
|
||||
systemPrompt: undefined,
|
||||
appendSystemPrompt: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads settings and config for auth commands
|
||||
*/
|
||||
async function loadAuthConfig(settings: LoadedSettings) {
|
||||
return loadCliConfig(
|
||||
settings.merged,
|
||||
createMinimalArgv(),
|
||||
process.cwd(),
|
||||
[],
|
||||
{
|
||||
userHooks: settings.getUserHooks(),
|
||||
projectHooks: settings.getProjectHooks(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the authentication process based on the specified command and options
|
||||
*/
|
||||
|
|
@ -58,69 +133,7 @@ export async function handleQwenAuth(
|
|||
) {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
|
||||
// Create a minimal argv for config loading
|
||||
const minimalArgv: CliArgs = {
|
||||
query: undefined,
|
||||
model: undefined,
|
||||
sandbox: undefined,
|
||||
sandboxImage: undefined,
|
||||
debug: undefined,
|
||||
prompt: undefined,
|
||||
promptInteractive: undefined,
|
||||
yolo: undefined,
|
||||
bare: undefined,
|
||||
approvalMode: undefined,
|
||||
telemetry: undefined,
|
||||
checkpointing: undefined,
|
||||
telemetryTarget: undefined,
|
||||
telemetryOtlpEndpoint: undefined,
|
||||
telemetryOtlpProtocol: undefined,
|
||||
telemetryLogPrompts: undefined,
|
||||
telemetryOutfile: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
acp: undefined,
|
||||
experimentalAcp: undefined,
|
||||
experimentalLsp: undefined,
|
||||
extensions: [],
|
||||
listExtensions: undefined,
|
||||
openaiLogging: undefined,
|
||||
openaiApiKey: undefined,
|
||||
openaiBaseUrl: undefined,
|
||||
openaiLoggingDir: undefined,
|
||||
proxy: undefined,
|
||||
includeDirectories: undefined,
|
||||
screenReader: undefined,
|
||||
inputFormat: undefined,
|
||||
outputFormat: undefined,
|
||||
includePartialMessages: undefined,
|
||||
chatRecording: undefined,
|
||||
continue: undefined,
|
||||
resume: undefined,
|
||||
sessionId: undefined,
|
||||
maxSessionTurns: undefined,
|
||||
coreTools: undefined,
|
||||
excludeTools: undefined,
|
||||
disabledSlashCommands: undefined,
|
||||
authType: undefined,
|
||||
channel: undefined,
|
||||
systemPrompt: undefined,
|
||||
appendSystemPrompt: undefined,
|
||||
};
|
||||
|
||||
// Create a minimal config to access settings and storage
|
||||
const config = await loadCliConfig(
|
||||
settings.merged,
|
||||
minimalArgv,
|
||||
process.cwd(),
|
||||
[], // No extensions for auth command
|
||||
// Pass separated hooks for proper source attribution
|
||||
{
|
||||
userHooks: settings.getUserHooks(),
|
||||
projectHooks: settings.getProjectHooks(),
|
||||
},
|
||||
);
|
||||
const config = await loadAuthConfig(settings);
|
||||
|
||||
if (command === 'qwen-oauth') {
|
||||
await handleQwenOAuth(config, settings);
|
||||
|
|
@ -303,16 +316,21 @@ async function promptForRegion(): Promise<CodingPlanRegion> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to enter an API key
|
||||
* Generic raw-mode text input prompt.
|
||||
* @param promptText - Text displayed before the cursor
|
||||
* @param options.mask - If true, echoes '*' instead of the typed character (for passwords)
|
||||
* @param options.defaultValue - Value returned when the user presses Enter on empty input
|
||||
*/
|
||||
async function promptForKey(): Promise<string> {
|
||||
// Create a simple password-style input (without echoing characters)
|
||||
async function promptForInput(
|
||||
promptText: string,
|
||||
options: { mask?: boolean; defaultValue?: string } = {},
|
||||
): Promise<string> {
|
||||
const { mask = false, defaultValue } = options;
|
||||
const stdin = process.stdin;
|
||||
const stdout = process.stdout;
|
||||
|
||||
stdout.write(t('Enter your Coding Plan API key: '));
|
||||
stdout.write(promptText);
|
||||
|
||||
// Set raw mode to capture keystrokes
|
||||
const wasRaw = stdin.isRaw;
|
||||
if (stdin.setRawMode) {
|
||||
stdin.setRawMode(true);
|
||||
|
|
@ -331,8 +349,10 @@ async function promptForKey(): Promise<string> {
|
|||
if (stdin.setRawMode) {
|
||||
stdin.setRawMode(wasRaw);
|
||||
}
|
||||
stdout.write('\n'); // New line after input
|
||||
resolve(input);
|
||||
stdout.write('\n');
|
||||
resolve(
|
||||
defaultValue !== undefined && !input ? defaultValue : input,
|
||||
);
|
||||
return;
|
||||
case '\x03': // Ctrl+C
|
||||
stdin.removeListener('data', onData);
|
||||
|
|
@ -351,10 +371,8 @@ async function promptForKey(): Promise<string> {
|
|||
}
|
||||
break;
|
||||
default:
|
||||
// Add character to input
|
||||
input += char;
|
||||
// Print asterisk instead of the actual character for security
|
||||
stdout.write('*');
|
||||
stdout.write(mask ? '*' : char);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -364,6 +382,15 @@ async function promptForKey(): Promise<string> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to enter an API key (masked input)
|
||||
*/
|
||||
async function promptForKey(
|
||||
promptText: string = t('Enter your Coding Plan API key: '),
|
||||
): Promise<string> {
|
||||
return promptForInput(promptText, { mask: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the interactive authentication flow
|
||||
*/
|
||||
|
|
@ -377,6 +404,11 @@ export async function runInteractiveAuth() {
|
|||
'Paid · Up to 6,000 requests/5 hrs · All Alibaba Cloud Coding Plan Models',
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'api-key' as const,
|
||||
label: t('API Key'),
|
||||
description: t('Bring your own API key'),
|
||||
},
|
||||
{
|
||||
value: 'qwen-oauth' as const,
|
||||
label: t('Qwen OAuth'),
|
||||
|
|
@ -400,9 +432,217 @@ export async function runInteractiveAuth() {
|
|||
|
||||
if (choice === 'coding-plan') {
|
||||
await handleQwenAuth('coding-plan', {});
|
||||
} else if (choice === 'api-key') {
|
||||
await handleApiKeyAuth();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles API Key authentication - shows sub-menu for Standard or Custom API key
|
||||
*/
|
||||
export async function handleApiKeyAuth() {
|
||||
try {
|
||||
const selector = new InteractiveSelector(
|
||||
[
|
||||
{
|
||||
value: 'alibaba-standard' as const,
|
||||
label: t('Alibaba Cloud ModelStudio Standard API Key'),
|
||||
description: t('Quick setup for Model Studio (China/International)'),
|
||||
},
|
||||
{
|
||||
value: 'custom' as const,
|
||||
label: t('Custom API Key'),
|
||||
description: t(
|
||||
'For other OpenAI / Anthropic / Gemini-compatible providers',
|
||||
),
|
||||
},
|
||||
],
|
||||
t('Select API key type:'),
|
||||
);
|
||||
|
||||
const choice = await selector.select();
|
||||
|
||||
if (choice === 'alibaba-standard') {
|
||||
await handleAlibabaStandardApiKeyAuth();
|
||||
} else if (choice === 'custom') {
|
||||
handleCustomApiKeyAuth();
|
||||
}
|
||||
} catch (error) {
|
||||
writeStderrLine(getErrorMessage(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Alibaba Cloud ModelStudio Standard API Key authentication
|
||||
*/
|
||||
async function handleAlibabaStandardApiKeyAuth(): Promise<void> {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const config = await loadAuthConfig(settings);
|
||||
|
||||
// Step 1: Select region
|
||||
const region = await promptForStandardRegion();
|
||||
|
||||
// Step 2: Enter API key
|
||||
const apiKey = await promptForKey(t('Enter your API key: '));
|
||||
const trimmedApiKey = apiKey.trim();
|
||||
if (!trimmedApiKey) {
|
||||
writeStderrLine(t('API key cannot be empty.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Step 3: Enter model IDs
|
||||
const modelIdsInput = await promptForModelIds();
|
||||
const modelIds = modelIdsInput
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(
|
||||
(id, index, array) => id.length > 0 && array.indexOf(id) === index,
|
||||
);
|
||||
if (modelIds.length === 0) {
|
||||
writeStderrLine(t('Model IDs cannot be empty.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
writeStdoutLine(
|
||||
t('Processing Alibaba Cloud ModelStudio Standard API Key...'),
|
||||
);
|
||||
|
||||
// Persist settings
|
||||
const baseUrl = ALIBABA_STANDARD_API_KEY_ENDPOINTS[region];
|
||||
const persistScope = getPersistScopeForModelSelection(settings);
|
||||
const settingsFile = settings.forScope(persistScope);
|
||||
backupSettingsFile(settingsFile.path);
|
||||
|
||||
// Store API key
|
||||
settings.setValue(
|
||||
persistScope,
|
||||
`env.${DASHSCOPE_STANDARD_API_KEY_ENV_KEY}`,
|
||||
trimmedApiKey,
|
||||
);
|
||||
process.env[DASHSCOPE_STANDARD_API_KEY_ENV_KEY] = trimmedApiKey;
|
||||
|
||||
// Build model configs
|
||||
const newConfigs: ModelConfig[] = modelIds.map((modelId) => ({
|
||||
id: modelId,
|
||||
name: `[ModelStudio Standard] ${modelId}`,
|
||||
baseUrl,
|
||||
envKey: DASHSCOPE_STANDARD_API_KEY_ENV_KEY,
|
||||
}));
|
||||
|
||||
// Get existing configs and filter out old Alibaba Standard entries
|
||||
const existingConfigs =
|
||||
(settings.merged.modelProviders as Record<string, ModelConfig[]>)?.[
|
||||
AuthType.USE_OPENAI
|
||||
] || [];
|
||||
|
||||
const nonAlibabaStandardConfigs = existingConfigs.filter(
|
||||
(existing) =>
|
||||
!(
|
||||
existing.envKey === DASHSCOPE_STANDARD_API_KEY_ENV_KEY &&
|
||||
typeof existing.baseUrl === 'string' &&
|
||||
Object.values(ALIBABA_STANDARD_API_KEY_ENDPOINTS).includes(
|
||||
existing.baseUrl,
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
const updatedConfigs = [...newConfigs, ...nonAlibabaStandardConfigs];
|
||||
|
||||
// Persist model providers and auth settings
|
||||
settings.setValue(
|
||||
persistScope,
|
||||
`modelProviders.${AuthType.USE_OPENAI}`,
|
||||
updatedConfigs,
|
||||
);
|
||||
settings.setValue(
|
||||
persistScope,
|
||||
'security.auth.selectedType',
|
||||
AuthType.USE_OPENAI,
|
||||
);
|
||||
settings.setValue(persistScope, 'model.name', modelIds[0]);
|
||||
|
||||
// Reload and refresh
|
||||
const updatedModelProviders: Record<string, ModelConfig[]> = {
|
||||
...(settings.merged.modelProviders as Record<string, ModelConfig[]>),
|
||||
[AuthType.USE_OPENAI]: updatedConfigs,
|
||||
};
|
||||
config.reloadModelProvidersConfig(updatedModelProviders);
|
||||
await config.refreshAuth(AuthType.USE_OPENAI);
|
||||
|
||||
writeStdoutLine(
|
||||
t(
|
||||
'Successfully configured Alibaba Cloud ModelStudio Standard API Key with {{modelCount}} model(s).',
|
||||
{ modelCount: String(modelIds.length) },
|
||||
),
|
||||
);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
writeStderrLine(getErrorMessage(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Custom API Key - prints docs link
|
||||
*/
|
||||
function handleCustomApiKeyAuth(): void {
|
||||
writeStdoutLine(
|
||||
t(
|
||||
'\nYou can configure your API key and models in settings.json.\nRefer to the documentation for setup instructions:\n https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/\n',
|
||||
),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to select a region for ModelStudio Standard API Key
|
||||
*/
|
||||
async function promptForStandardRegion(): Promise<AlibabaStandardRegion> {
|
||||
const selector = new InteractiveSelector(
|
||||
[
|
||||
{
|
||||
value: 'cn-beijing' as AlibabaStandardRegion,
|
||||
label: t('China (Beijing)'),
|
||||
description: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
},
|
||||
{
|
||||
value: 'sg-singapore' as AlibabaStandardRegion,
|
||||
label: t('Singapore'),
|
||||
description: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
},
|
||||
{
|
||||
value: 'us-virginia' as AlibabaStandardRegion,
|
||||
label: t('US (Virginia)'),
|
||||
description: 'https://dashscope-us.aliyuncs.com/compatible-mode/v1',
|
||||
},
|
||||
{
|
||||
value: 'cn-hongkong' as AlibabaStandardRegion,
|
||||
label: t('China (Hong Kong)'),
|
||||
description:
|
||||
'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
},
|
||||
],
|
||||
t('Select region:'),
|
||||
);
|
||||
|
||||
return await selector.select();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to enter comma-separated model IDs
|
||||
*/
|
||||
async function promptForModelIds(): Promise<string> {
|
||||
const defaultModels = 'qwen3.5-plus,glm-5,kimi-k2.5';
|
||||
return promptForInput(
|
||||
t('Enter model IDs (comma-separated, default: {{default}}): ', {
|
||||
default: defaultModels,
|
||||
}),
|
||||
{ defaultValue: defaultModels },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the current authentication status
|
||||
*/
|
||||
|
|
@ -421,12 +661,15 @@ export async function showAuthStatus(): Promise<void> {
|
|||
writeStdoutLine(t('Run one of the following commands to get started:\n'));
|
||||
writeStdoutLine(
|
||||
t(
|
||||
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)',
|
||||
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan',
|
||||
),
|
||||
);
|
||||
writeStdoutLine(
|
||||
t(' qwen auth api-key - Authenticate with an API key'),
|
||||
);
|
||||
writeStdoutLine(
|
||||
t(
|
||||
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n',
|
||||
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (discontinued)',
|
||||
),
|
||||
);
|
||||
writeStdoutLine(t('Or simply run:'));
|
||||
|
|
@ -451,12 +694,15 @@ export async function showAuthStatus(): Promise<void> {
|
|||
const codingPlanVersion = mergedSettings.codingPlan?.version;
|
||||
const modelName = mergedSettings.model?.name;
|
||||
|
||||
// Check if API key is set in environment
|
||||
const hasApiKey =
|
||||
// Check which API key type is configured
|
||||
const hasCodingPlanKey =
|
||||
!!process.env[CODING_PLAN_ENV_KEY] ||
|
||||
!!mergedSettings.env?.[CODING_PLAN_ENV_KEY];
|
||||
const hasStandardKey =
|
||||
!!process.env[DASHSCOPE_STANDARD_API_KEY_ENV_KEY] ||
|
||||
!!mergedSettings.env?.[DASHSCOPE_STANDARD_API_KEY_ENV_KEY];
|
||||
|
||||
if (hasApiKey) {
|
||||
if (hasCodingPlanKey) {
|
||||
writeStdoutLine(
|
||||
t('✓ Authentication Method: Alibaba Cloud Coding Plan'),
|
||||
);
|
||||
|
|
@ -484,16 +730,32 @@ export async function showAuthStatus(): Promise<void> {
|
|||
}
|
||||
|
||||
writeStdoutLine(t(' Status: API key configured\n'));
|
||||
} else {
|
||||
} else if (hasStandardKey) {
|
||||
writeStdoutLine(
|
||||
t(
|
||||
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)',
|
||||
'✓ Authentication Method: Alibaba Cloud ModelStudio Standard API Key',
|
||||
),
|
||||
);
|
||||
|
||||
if (modelName) {
|
||||
writeStdoutLine(
|
||||
t(' Current Model: {{model}}', { model: modelName }),
|
||||
);
|
||||
}
|
||||
|
||||
writeStdoutLine(t(' Status: API key configured\n'));
|
||||
} else {
|
||||
writeStdoutLine(
|
||||
t(' Issue: API key not found in environment or settings\n'),
|
||||
t('✓ Authentication Method: OpenAI-compatible API Key'),
|
||||
);
|
||||
writeStdoutLine(t(' Run `qwen auth coding-plan` to re-configure.\n'));
|
||||
|
||||
if (modelName) {
|
||||
writeStdoutLine(
|
||||
t(' Current Model: {{model}}', { model: modelName }),
|
||||
);
|
||||
}
|
||||
|
||||
writeStdoutLine(t(' Status: Configured\n'));
|
||||
}
|
||||
} else {
|
||||
writeStdoutLine(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue