atcoder-workspace 1.1.0-beta.1 → 1.1.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,9 +13,338 @@ export interface TaskDetails {
13
13
  timeLimitMs: number;
14
14
  memoryLimitBytes: number;
15
15
  samples: SampleCase[];
16
+ problemStatementMd?: string;
16
17
  }
17
18
 
18
- export function parseProblemPage(html: string): TaskDetails {
19
+ function escapeMarkdown(text: string): string {
20
+ return text
21
+ .replace(/\\/g, '\\\\')
22
+ .replace(/\*/g, '\\*')
23
+ .replace(/_/g, '\\_')
24
+ .replace(/`/g, '\\`')
25
+ .replace(/\[/g, '\\[')
26
+ .replace(/\]/g, '\\]');
27
+ }
28
+
29
+ function convertLineToMath(line: string): string {
30
+ const trimmed = line.trim();
31
+ if (trimmed === ':' || trimmed === '\\vdots') {
32
+ return '\\vdots';
33
+ }
34
+ if (trimmed === '...' || trimmed === '\\dots' || trimmed === '\\cdots') {
35
+ return '\\dots';
36
+ }
37
+
38
+ let result = '';
39
+ let i = 0;
40
+ let textBuffer = '';
41
+
42
+ const flushBuffer = () => {
43
+ if (textBuffer) {
44
+ result += `\\text{${textBuffer}}`;
45
+ textBuffer = '';
46
+ }
47
+ };
48
+
49
+ while (i < line.length) {
50
+ const remaining = line.slice(i);
51
+
52
+ // 0. Match existing \text{...} optionally followed by a subscript
53
+ const textSubMatch = remaining.match(/^\\text\{([^{}]+)\}(?:_([a-zA-Z0-9]+|\{[^{}]+\}))?/);
54
+ if (textSubMatch) {
55
+ flushBuffer();
56
+ const baseText = textSubMatch[1];
57
+ const subText = textSubMatch[2];
58
+ if (subText) {
59
+ const formattedSub = (subText.startsWith('{') && subText.endsWith('}'))
60
+ ? subText
61
+ : (subText.length > 1 ? `{${subText}}` : subText);
62
+ result += `\\text{${baseText}}_${formattedSub}`;
63
+ } else {
64
+ result += `\\text{${baseText}}`;
65
+ }
66
+ i += textSubMatch[0].length;
67
+ continue;
68
+ }
69
+
70
+ // 1. Match LaTeX commands like \dots, \vdots, \cdots
71
+ const cmdMatch = remaining.match(/^(\\[a-zA-Z]+)/);
72
+ if (cmdMatch) {
73
+ flushBuffer();
74
+ result += cmdMatch[1];
75
+ i += cmdMatch[1].length;
76
+ continue;
77
+ }
78
+
79
+ // 2. Match subscript pattern: e.g. A_i, query_1
80
+ const subMatch = remaining.match(/^([a-zA-Z]+)_([a-zA-Z0-9]+)/);
81
+ if (subMatch) {
82
+ flushBuffer();
83
+ const base = subMatch[1];
84
+ const sub = subMatch[2];
85
+ const formattedSub = sub.length > 1 ? `\\text{${sub}}` : sub;
86
+ result += `\\text{${base}}_${formattedSub}`;
87
+ i += subMatch[0].length;
88
+ continue;
89
+ }
90
+
91
+ // 3. Match raw ellipsis "..."
92
+ if (remaining.startsWith('...')) {
93
+ flushBuffer();
94
+ result += '\\dots';
95
+ i += 3;
96
+ continue;
97
+ }
98
+
99
+ // 4. Match colon ":"
100
+ if (remaining.startsWith(':')) {
101
+ flushBuffer();
102
+ result += '\\vdots';
103
+ i += 1;
104
+ continue;
105
+ }
106
+
107
+ // 5. Normal character (including spaces)
108
+ textBuffer += line[i];
109
+ i += 1;
110
+ }
111
+
112
+ flushBuffer();
113
+ return result;
114
+ }
115
+
116
+ function renderPreAsMathArray(node: any, $: cheerio.CheerioAPI): string {
117
+ const rawText = $(node).text();
118
+ const lines = rawText.split('\n');
119
+
120
+ let startIdx = 0;
121
+ while (startIdx < lines.length && lines[startIdx].trim() === '') {
122
+ startIdx++;
123
+ }
124
+ let endIdx = lines.length - 1;
125
+ while (endIdx >= startIdx && lines[endIdx].trim() === '') {
126
+ endIdx--;
127
+ }
128
+
129
+ const activeLines = lines.slice(startIdx, endIdx + 1);
130
+ if (activeLines.length === 0) {
131
+ return '';
132
+ }
133
+
134
+ const convertedLines = activeLines.map(line => convertLineToMath(line));
135
+
136
+ let result = '\n\n$$\n\\begin{array}{l}\n';
137
+ result += convertedLines.join(' \\\\\n');
138
+ result += '\n\\end{array}\n$$\n\n';
139
+
140
+ return result;
141
+ }
142
+
143
+ function processTextNode(text: string): string {
144
+ const parts: string[] = [];
145
+ let i = 0;
146
+ while (i < text.length) {
147
+ if (text.startsWith('\\[', i)) {
148
+ const start = i;
149
+ const end = text.indexOf('\\]', i + 2);
150
+ if (end !== -1) {
151
+ const mathContent = text.slice(start + 2, end);
152
+ parts.push(`$$\n${mathContent.trim()}\n$$`);
153
+ i = end + 2;
154
+ } else {
155
+ parts.push(escapeMarkdown(text.slice(i, i + 2)));
156
+ i += 2;
157
+ }
158
+ } else if (text.startsWith('\\(', i)) {
159
+ const start = i;
160
+ const end = text.indexOf('\\)', i + 2);
161
+ if (end !== -1) {
162
+ const mathContent = text.slice(start + 2, end);
163
+ parts.push(`$${mathContent.trim()}$`);
164
+ i = end + 2;
165
+ } else {
166
+ parts.push(escapeMarkdown(text.slice(i, i + 2)));
167
+ i += 2;
168
+ }
169
+ } else if (text.startsWith('$$', i)) {
170
+ const start = i;
171
+ const end = text.indexOf('$$', i + 2);
172
+ if (end !== -1) {
173
+ const mathContent = text.slice(start + 2, end);
174
+ parts.push(`$$\n${mathContent.trim()}\n$$`);
175
+ i = end + 2;
176
+ } else {
177
+ parts.push(escapeMarkdown(text.slice(i, i + 2)));
178
+ i += 2;
179
+ }
180
+ } else if (text.startsWith('$', i)) {
181
+ const start = i;
182
+ const end = text.indexOf('$', i + 1);
183
+ if (end !== -1) {
184
+ const mathContent = text.slice(start + 1, end);
185
+ parts.push(`$${mathContent.trim()}$`);
186
+ i = end + 1;
187
+ } else {
188
+ parts.push('$');
189
+ i += 1;
190
+ }
191
+ } else {
192
+ let nextIndex = -1;
193
+ let selectedDelim = '';
194
+
195
+ const delims = ['\\[', '\\(', '$$', '$'];
196
+ for (const delim of delims) {
197
+ const idx = text.indexOf(delim, i);
198
+ if (idx !== -1 && (nextIndex === -1 || idx < nextIndex)) {
199
+ nextIndex = idx;
200
+ selectedDelim = delim;
201
+ }
202
+ }
203
+
204
+ if (nextIndex === -1) {
205
+ parts.push(escapeMarkdown(text.slice(i)));
206
+ break;
207
+ } else {
208
+ parts.push(escapeMarkdown(text.slice(i, nextIndex)));
209
+ i = nextIndex;
210
+ }
211
+ }
212
+ }
213
+ return parts.join('');
214
+ }
215
+
216
+ function nodeToMarkdown(node: any, $: cheerio.CheerioAPI, isInsideMath = false): string {
217
+ if (node.type === 'text') {
218
+ return isInsideMath ? ((node as any).data || '') : processTextNode((node as any).data || '');
219
+ }
220
+
221
+ if (node.type === 'tag') {
222
+ const tagName = (node as any).name;
223
+ const className = $(node).attr('class') || '';
224
+ const classes = className.split(/\s+/);
225
+ const isMathContainer = tagName === 'var' || classes.includes('math');
226
+ const isBlockMath = tagName === 'div' && classes.includes('math');
227
+
228
+ if (isMathContainer) {
229
+ let mathBody = '';
230
+ if ((node as any).children) {
231
+ for (const child of (node as any).children) {
232
+ mathBody += nodeToMarkdown(child, $, true);
233
+ }
234
+ }
235
+ const delim = isBlockMath ? '$$' : '$';
236
+ if (isBlockMath) {
237
+ return `\n\n$$\n${mathBody.trim()}\n$$\n\n`;
238
+ }
239
+ return `${delim}${mathBody.trim()}${delim}`;
240
+ }
241
+
242
+ if (tagName === 'table') {
243
+ const $table = $(node).clone();
244
+ $table.find('a').each((_, el) => {
245
+ const href = $(el).attr('href');
246
+ if (href && href.startsWith('/') && !href.startsWith('//')) {
247
+ $(el).attr('href', 'https://atcoder.jp' + href);
248
+ }
249
+ });
250
+ $table.find('img').each((_, el) => {
251
+ const src = $(el).attr('src');
252
+ if (src && src.startsWith('/') && !src.startsWith('//')) {
253
+ $(el).attr('src', 'https://atcoder.jp' + src);
254
+ }
255
+ });
256
+ return '\n\n' + $.html($table) + '\n\n';
257
+ }
258
+
259
+ let childrenMarkdown = '';
260
+ if ((node as any).children) {
261
+ for (const child of (node as any).children) {
262
+ childrenMarkdown += nodeToMarkdown(child, $, isInsideMath);
263
+ }
264
+ }
265
+
266
+ switch (tagName) {
267
+ case 'h1':
268
+ case 'h2':
269
+ case 'h3':
270
+ case 'h4':
271
+ case 'h5':
272
+ case 'h6': {
273
+ const level = parseInt(tagName.substring(1), 10);
274
+ return `\n\n${'#'.repeat(level)} ${childrenMarkdown.trim()}\n\n`;
275
+ }
276
+ case 'p':
277
+ return `\n\n${childrenMarkdown.trim()}\n\n`;
278
+ case 'br':
279
+ return '\n';
280
+ case 'strong':
281
+ case 'b':
282
+ return `**${childrenMarkdown.trim()}**`;
283
+ case 'em':
284
+ case 'i':
285
+ return `*${childrenMarkdown.trim()}*`;
286
+ case 'code': {
287
+ const parentTagName = (node.parent as any)?.name;
288
+ if (parentTagName === 'pre') {
289
+ return childrenMarkdown;
290
+ }
291
+ return `\`${childrenMarkdown}\``;
292
+ }
293
+ case 'pre': {
294
+ const text = $(node).text();
295
+ const hasVar = $(node).find('var').length > 0;
296
+ const hasMathSymbols = text.includes('\\dots') || text.includes('\\vdots') || text.includes('\\cdots') || text.includes('\\le');
297
+ if (hasVar || hasMathSymbols) {
298
+ return renderPreAsMathArray(node, $);
299
+ }
300
+ return `\n\n\`\`\`\n${text.trim()}\n\`\`\`\n\n`;
301
+ }
302
+ case 'a': {
303
+ let href = $(node).attr('href') || '';
304
+ if (href.startsWith('/') && !href.startsWith('//')) {
305
+ href = 'https://atcoder.jp' + href;
306
+ }
307
+ return `[${childrenMarkdown.trim()}](${href})`;
308
+ }
309
+ case 'img': {
310
+ let src = $(node).attr('src') || '';
311
+ if (src.startsWith('/') && !src.startsWith('//')) {
312
+ src = 'https://atcoder.jp' + src;
313
+ }
314
+ const alt = $(node).attr('alt') || '';
315
+ return `![${alt}](${src})`;
316
+ }
317
+ case 'ul':
318
+ case 'ol':
319
+ return `\n\n${childrenMarkdown}\n\n`;
320
+ case 'li': {
321
+ let indentLevel = 0;
322
+ let parent = node.parent;
323
+ while (parent) {
324
+ const parentName = (parent as any).name;
325
+ if (parentName === 'ul' || parentName === 'ol') {
326
+ indentLevel++;
327
+ }
328
+ parent = parent.parent;
329
+ }
330
+ const indent = ' '.repeat(Math.max(0, indentLevel - 1));
331
+ const parentTagName = (node.parent as any)?.name;
332
+ const prefix = parentTagName === 'ol' ? '1. ' : '- ';
333
+ return `\n${indent}${prefix}${childrenMarkdown.trim()}`;
334
+ }
335
+ case 'div':
336
+ case 'span':
337
+ case 'section':
338
+ return childrenMarkdown;
339
+ default:
340
+ return childrenMarkdown;
341
+ }
342
+ }
343
+
344
+ return '';
345
+ }
346
+
347
+ export function parseProblemPage(html: string, preferredLang?: 'en' | 'ja'): TaskDetails {
19
348
  const $ = cheerio.load(html);
20
349
 
21
350
  let title = $('span.h2').first().text().trim();
@@ -53,10 +382,33 @@ export function parseProblemPage(html: string): TaskDetails {
53
382
  }
54
383
 
55
384
  let $container = $('#task-statement');
56
- if ($container.find('.lang-en').length > 0) {
57
- $container = $container.find('.lang-en');
58
- } else if ($container.find('.lang-ja').length > 0) {
59
- $container = $container.find('.lang-ja');
385
+ if (preferredLang === 'ja') {
386
+ if ($container.find('.lang-ja').length > 0) {
387
+ $container = $container.find('.lang-ja');
388
+ } else if ($container.find('.lang-en').length > 0) {
389
+ $container = $container.find('.lang-en');
390
+ }
391
+ } else {
392
+ if ($container.find('.lang-en').length > 0) {
393
+ $container = $container.find('.lang-en');
394
+ } else if ($container.find('.lang-ja').length > 0) {
395
+ $container = $container.find('.lang-ja');
396
+ }
397
+ }
398
+
399
+ // Convert task statement HTML to Markdown if task statement exists
400
+ let problemStatementMd = '';
401
+ if ($container.length > 0) {
402
+ let mdBody = '';
403
+ $container.contents().each((_, node) => {
404
+ mdBody += nodeToMarkdown(node, $);
405
+ });
406
+ mdBody = mdBody.replace(/\n{3,}/g, '\n\n').trim();
407
+
408
+ problemStatementMd = `# ${title}\n\n`;
409
+ problemStatementMd += `- Time Limit: ${timeLimitMs / 1000} sec\n`;
410
+ problemStatementMd += `- Memory Limit: ${Math.round(memoryLimitBytes / (1024 * 1024))} MB\n\n`;
411
+ problemStatementMd += mdBody + '\n';
60
412
  }
61
413
 
62
414
  const sampleMap: Record<number, { input?: string; output?: string }> = {};
@@ -121,6 +473,7 @@ export function parseProblemPage(html: string): TaskDetails {
121
473
  title,
122
474
  timeLimitMs,
123
475
  memoryLimitBytes,
124
- samples
476
+ samples,
477
+ problemStatementMd
125
478
  };
126
479
  }
package/src/cli.ts CHANGED
@@ -8,7 +8,7 @@ import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
 
10
10
  import { findWorkspaceRoot } from './workspace/finder';
11
- import { initWorkspace } from './workspace/initializer';
11
+ import { initWorkspace, LANGUAGE_PRESETS, addLanguage } from './workspace/initializer';
12
12
  import { loginWithCookie, whoami } from './session/auth';
13
13
  import { clearSession } from './session/store';
14
14
  import { fetchContestTasks, setupTask } from './atcoder/new';
@@ -36,8 +36,8 @@ const program = new Command();
36
36
 
37
37
  program
38
38
  .name('atc')
39
- .description('AtCoder All-in-One CLI (Local-first)')
40
- .version('1.1.0-betas');
39
+ .description('AtCoder Workspace')
40
+ .version('1.1.0-beta.3');
41
41
 
42
42
  function handleAction(fn: (...args: any[]) => Promise<void>) {
43
43
  return async (...args: any[]) => {
@@ -243,42 +243,101 @@ program
243
243
  }
244
244
 
245
245
  p.outro(pc.green(t('newScaffoldingComplete', lang, selectedTasks.length)));
246
+
247
+ if (config.extractProblemStatement) {
248
+ p.note(
249
+ t('newStatementWarningBody', lang),
250
+ pc.yellow(t('newStatementWarningTitle', lang))
251
+ );
252
+ }
246
253
  })
247
254
  );
248
255
 
249
- function resolveArgs(workspaceRoot: string, taskArg: string | undefined, fileArg: string | undefined) {
256
+ function resolveArgs(
257
+ workspaceRoot: string,
258
+ arg1: string | undefined,
259
+ arg2: string | undefined,
260
+ arg3: string | undefined
261
+ ) {
262
+ const cwd = process.cwd();
263
+ const config = loadConfig(workspaceRoot);
264
+ const contestDir = config.contestDir || '';
265
+
250
266
  let resolvedTaskDir = '';
251
267
  let resolvedFile: string | undefined;
252
268
 
253
- let isFile = false;
254
- let filePath = '';
255
-
256
- if (taskArg) {
257
- const pathsToCheck = [
258
- path.resolve(taskArg),
259
- path.resolve(workspaceRoot, taskArg)
269
+ // Helper function to check if relative path parts exist in cwd, workspaceRoot or contestDir
270
+ function checkPath(relativeParts: string[]): { isFile: boolean; isDir: boolean; path: string } | null {
271
+ const pathsToTry = [
272
+ path.resolve(cwd, ...relativeParts),
273
+ path.resolve(workspaceRoot, ...relativeParts),
274
+ path.resolve(workspaceRoot, contestDir, ...relativeParts)
260
275
  ];
261
-
262
- const config = loadConfig(workspaceRoot);
263
- if (config.contestDir) {
264
- pathsToCheck.push(path.resolve(workspaceRoot, config.contestDir, taskArg));
276
+
277
+ for (const p of pathsToTry) {
278
+ if (fs.existsSync(p)) {
279
+ const stat = fs.statSync(p);
280
+ return {
281
+ isFile: stat.isFile(),
282
+ isDir: stat.isDirectory(),
283
+ path: p
284
+ };
285
+ }
286
+ }
287
+ return null;
288
+ }
289
+
290
+ // 1. Three arguments provided: (arg1, arg2, arg3)
291
+ if (arg1 && arg2 && arg3) {
292
+ // e.g., abc300 a main.cpp -> check if arg1/arg2 is a directory
293
+ const checkDir = checkPath([arg1, arg2]);
294
+ if (checkDir && checkDir.isDir) {
295
+ resolvedTaskDir = checkDir.path;
296
+ resolvedFile = arg3;
265
297
  }
298
+ }
299
+
300
+ // 2. Two arguments provided: (arg1, arg2)
301
+ if (!resolvedTaskDir && arg1 && arg2) {
302
+ // Pattern A: arg1/arg2 is a directory (e.g., abc300 a)
303
+ const checkDir = checkPath([arg1, arg2]);
304
+ if (checkDir && checkDir.isDir) {
305
+ resolvedTaskDir = checkDir.path;
306
+ resolvedFile = undefined;
307
+ } else {
308
+ // Pattern B: arg1 is a directory, arg2 is a file (e.g., a main.cpp)
309
+ const checkArg1 = checkPath([arg1]);
310
+ if (checkArg1 && checkArg1.isDir) {
311
+ resolvedTaskDir = checkArg1.path;
312
+ resolvedFile = arg2;
313
+ }
314
+ }
315
+ }
266
316
 
267
- for (const p of pathsToCheck) {
268
- if (fs.existsSync(p) && fs.statSync(p).isFile()) {
269
- isFile = true;
270
- filePath = p;
271
- break;
317
+ // 3. One argument provided: (arg1)
318
+ if (!resolvedTaskDir && arg1) {
319
+ const checkArg1 = checkPath([arg1]);
320
+ if (checkArg1) {
321
+ if (checkArg1.isFile) {
322
+ // Pattern A: directly specified a file (e.g., main.cpp)
323
+ resolvedTaskDir = path.dirname(checkArg1.path);
324
+ resolvedFile = path.basename(checkArg1.path);
325
+ } else if (checkArg1.isDir) {
326
+ // Pattern B: directly specified a directory (e.g., a or abc300/a)
327
+ resolvedTaskDir = checkArg1.path;
328
+ resolvedFile = undefined;
272
329
  }
330
+ } else {
331
+ // Fallback to resolveTaskDirectory (legacy behavior)
332
+ resolvedTaskDir = resolveTaskDirectory(workspaceRoot, arg1);
333
+ resolvedFile = undefined;
273
334
  }
274
335
  }
275
336
 
276
- if (isFile) {
277
- resolvedFile = path.basename(filePath);
278
- resolvedTaskDir = path.dirname(filePath);
279
- } else {
280
- resolvedTaskDir = resolveTaskDirectory(workspaceRoot, taskArg);
281
- resolvedFile = fileArg;
337
+ // 4. No arguments provided
338
+ if (!resolvedTaskDir) {
339
+ resolvedTaskDir = resolveTaskDirectory(workspaceRoot, undefined);
340
+ resolvedFile = undefined;
282
341
  }
283
342
 
284
343
  const taskLabel = path.basename(resolvedTaskDir);
@@ -288,12 +347,12 @@ function resolveArgs(workspaceRoot: string, taskArg: string | undefined, fileArg
288
347
  }
289
348
 
290
349
  program
291
- .command('test [task] [file]')
350
+ .command('test [arg1] [arg2] [arg3]')
292
351
  .description(t('descTest', lang))
293
352
  .action(
294
- handleAction(async (taskArg: string | undefined, fileArg: string | undefined) => {
353
+ handleAction(async (arg1: string | undefined, arg2: string | undefined, arg3: string | undefined) => {
295
354
  const workspaceRoot = findWorkspaceRoot();
296
- const { resolvedTaskDir, resolvedFile, taskLabel, contestId } = resolveArgs(workspaceRoot, taskArg, fileArg);
355
+ const { resolvedTaskDir, resolvedFile, taskLabel, contestId } = resolveArgs(workspaceRoot, arg1, arg2, arg3);
297
356
 
298
357
  p.intro(pc.cyan(t('testIntro', lang, contestId, taskLabel)));
299
358
 
@@ -378,12 +437,12 @@ program
378
437
  );
379
438
 
380
439
  program
381
- .command('submit [task] [file]')
440
+ .command('submit [arg1] [arg2] [arg3]')
382
441
  .description(t('descSubmit', lang))
383
442
  .action(
384
- handleAction(async (taskArg: string | undefined, fileArg: string | undefined) => {
443
+ handleAction(async (arg1: string | undefined, arg2: string | undefined, arg3: string | undefined) => {
385
444
  const workspaceRoot = findWorkspaceRoot();
386
- const { resolvedTaskDir, resolvedFile, taskLabel, contestId } = resolveArgs(workspaceRoot, taskArg, fileArg);
445
+ const { resolvedTaskDir, resolvedFile, taskLabel, contestId } = resolveArgs(workspaceRoot, arg1, arg2, arg3);
387
446
 
388
447
  p.intro(pc.cyan(t('submitPreparing', lang, contestId, taskLabel)));
389
448
 
@@ -532,12 +591,26 @@ program
532
591
  process.exit(1);
533
592
  }
534
593
 
535
- if (!targetLanguage) {
536
- console.log(t('langCommandUsage', lang));
537
- process.exit(0);
594
+ let selectedLang = targetLanguage;
595
+
596
+ if (!selectedLang) {
597
+ const choice = await p.select({
598
+ message: t('langSelectMessage', lang),
599
+ options: [
600
+ { value: 'en', label: 'English (en)' },
601
+ { value: 'ja', label: '日本語 (ja)' }
602
+ ]
603
+ });
604
+
605
+ if (p.isCancel(choice)) {
606
+ p.cancel(t('langCancelled', lang));
607
+ process.exit(0);
608
+ }
609
+
610
+ selectedLang = choice as string;
538
611
  }
539
612
 
540
- const cleanLang = targetLanguage.trim().toLowerCase();
613
+ const cleanLang = selectedLang.trim().toLowerCase();
541
614
  if (cleanLang !== 'en' && cleanLang !== 'ja') {
542
615
  p.log.error(pc.red(t('langInvalid', lang)));
543
616
  process.exit(1);
@@ -551,4 +624,102 @@ program
551
624
  })
552
625
  );
553
626
 
627
+ program
628
+ .command('add-lang [langName]')
629
+ .description(t('descAddLang', lang))
630
+ .action(
631
+ handleAction(async (langName: string | undefined) => {
632
+ const root = findWorkspaceRoot();
633
+ const config = loadConfig(root);
634
+
635
+ let targetLang = langName;
636
+ if (!targetLang) {
637
+ targetLang = await p.text({
638
+ message: t('addLangEnterName', lang),
639
+ validate: (val) => (!val.trim() ? t('addLangNameNotEmpty', lang) : undefined)
640
+ }) as string;
641
+
642
+ if (p.isCancel(targetLang)) {
643
+ p.cancel(t('addLangCancelled', lang));
644
+ process.exit(0);
645
+ }
646
+ }
647
+
648
+ targetLang = targetLang.trim().toLowerCase();
649
+
650
+ // Check if already exists
651
+ if (config.languages[targetLang]) {
652
+ throw new AtcError(t('addLangAlreadyExists', lang, targetLang));
653
+ }
654
+
655
+ const preset = LANGUAGE_PRESETS[targetLang];
656
+ let extension = '';
657
+ let build = '';
658
+ let run = '';
659
+ let template = '';
660
+
661
+ if (preset) {
662
+ extension = preset.config.extension;
663
+ build = preset.config.build;
664
+ run = preset.config.run;
665
+ template = preset.template;
666
+ } else {
667
+ // If not preset, prompt for parameters
668
+ const extInput = await p.text({
669
+ message: t('addLangEnterExtension', lang, targetLang),
670
+ placeholder: targetLang,
671
+ validate: (val) => (!val.trim() ? t('addLangExtNotEmpty', lang) : undefined)
672
+ }) as string;
673
+
674
+ if (p.isCancel(extInput)) {
675
+ p.cancel(t('addLangCancelled', lang));
676
+ process.exit(0);
677
+ }
678
+
679
+ const buildInput = await p.text({
680
+ message: t('addLangEnterBuildCmd', lang),
681
+ placeholder: 'e.g. g++ -O2 main.cpp (leave empty if not needed)'
682
+ }) as string;
683
+
684
+ if (p.isCancel(buildInput)) {
685
+ p.cancel(t('addLangCancelled', lang));
686
+ process.exit(0);
687
+ }
688
+
689
+ const runInput = await p.text({
690
+ message: t('addLangEnterRunCmd', lang),
691
+ placeholder: `e.g. python3 main.py or ./a.out`,
692
+ validate: (val) => (!val.trim() ? t('addLangRunCmdNotEmpty', lang) : undefined)
693
+ }) as string;
694
+
695
+ if (p.isCancel(runInput)) {
696
+ p.cancel(t('addLangCancelled', lang));
697
+ process.exit(0);
698
+ }
699
+
700
+ extension = extInput.trim();
701
+ build = buildInput.trim();
702
+ run = runInput.trim();
703
+ template = `// Solve the problem here\n`;
704
+ }
705
+
706
+ const s = p.spinner();
707
+ s.start(t('addLangSpinner', lang));
708
+
709
+ try {
710
+ addLanguage(root, targetLang, {
711
+ extension,
712
+ build,
713
+ run,
714
+ template
715
+ });
716
+ } catch (e: any) {
717
+ s.stop('Failed');
718
+ throw e;
719
+ }
720
+
721
+ s.stop(t('addLangSuccess', lang, targetLang));
722
+ })
723
+ );
724
+
554
725
  program.parse(process.argv);