atcoder-workspace 1.1.0-beta.1

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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/THIRD_PARTY_LICENSES +21 -0
  4. package/dist/atcoder/client.d.ts +2 -0
  5. package/dist/atcoder/client.js +23 -0
  6. package/dist/atcoder/new.d.ts +15 -0
  7. package/dist/atcoder/new.js +123 -0
  8. package/dist/atcoder/parser/contest-tasks.d.ts +6 -0
  9. package/dist/atcoder/parser/contest-tasks.js +86 -0
  10. package/dist/atcoder/parser/limits.d.ts +10 -0
  11. package/dist/atcoder/parser/limits.js +71 -0
  12. package/dist/atcoder/parser/problem-page.d.ts +12 -0
  13. package/dist/atcoder/parser/problem-page.js +136 -0
  14. package/dist/atcoder/parser/submission-status.d.ts +8 -0
  15. package/dist/atcoder/parser/submission-status.js +78 -0
  16. package/dist/atcoder/submit.d.ts +9 -0
  17. package/dist/atcoder/submit.js +182 -0
  18. package/dist/cli.d.ts +2 -0
  19. package/dist/cli.js +508 -0
  20. package/dist/config/config-store.d.ts +17 -0
  21. package/dist/config/config-store.js +92 -0
  22. package/dist/session/auth.d.ts +8 -0
  23. package/dist/session/auth.js +117 -0
  24. package/dist/session/store.d.ts +15 -0
  25. package/dist/session/store.js +75 -0
  26. package/dist/test-runner/diff.d.ts +7 -0
  27. package/dist/test-runner/diff.js +32 -0
  28. package/dist/test-runner/runner.d.ts +46 -0
  29. package/dist/test-runner/runner.js +274 -0
  30. package/dist/utils/errors.d.ts +15 -0
  31. package/dist/utils/errors.js +35 -0
  32. package/dist/utils/format.d.ts +9 -0
  33. package/dist/utils/format.js +89 -0
  34. package/dist/utils/i18n.d.ts +345 -0
  35. package/dist/utils/i18n.js +413 -0
  36. package/dist/utils/open.d.ts +8 -0
  37. package/dist/utils/open.js +50 -0
  38. package/dist/workspace/finder.d.ts +9 -0
  39. package/dist/workspace/finder.js +62 -0
  40. package/dist/workspace/initializer.d.ts +4 -0
  41. package/dist/workspace/initializer.js +109 -0
  42. package/package.json +38 -0
  43. package/src/atcoder/client.ts +21 -0
  44. package/src/atcoder/new.ts +107 -0
  45. package/src/atcoder/parser/contest-tasks.test.ts +37 -0
  46. package/src/atcoder/parser/contest-tasks.ts +61 -0
  47. package/src/atcoder/parser/limits.test.ts +52 -0
  48. package/src/atcoder/parser/limits.ts +75 -0
  49. package/src/atcoder/parser/problem-page.test.ts +68 -0
  50. package/src/atcoder/parser/problem-page.ts +126 -0
  51. package/src/atcoder/parser/submission-status.test.ts +36 -0
  52. package/src/atcoder/parser/submission-status.ts +54 -0
  53. package/src/atcoder/submit.ts +170 -0
  54. package/src/cli.ts +554 -0
  55. package/src/config/config-store.ts +72 -0
  56. package/src/session/auth.ts +87 -0
  57. package/src/session/store.ts +50 -0
  58. package/src/test-runner/diff.test.ts +26 -0
  59. package/src/test-runner/diff.ts +42 -0
  60. package/src/test-runner/runner.test.ts +70 -0
  61. package/src/test-runner/runner.ts +315 -0
  62. package/src/utils/errors.ts +31 -0
  63. package/src/utils/format.test.ts +69 -0
  64. package/src/utils/format.ts +95 -0
  65. package/src/utils/i18n.test.ts +74 -0
  66. package/src/utils/i18n.ts +418 -0
  67. package/src/utils/open.ts +47 -0
  68. package/src/workspace/finder.ts +29 -0
  69. package/src/workspace/initializer.ts +85 -0
  70. package/tsconfig.json +16 -0
package/src/cli.ts ADDED
@@ -0,0 +1,554 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import * as p from '@clack/prompts';
5
+ import pc from 'picocolors';
6
+ import ora from 'ora';
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+
10
+ import { findWorkspaceRoot } from './workspace/finder';
11
+ import { initWorkspace } from './workspace/initializer';
12
+ import { loginWithCookie, whoami } from './session/auth';
13
+ import { clearSession } from './session/store';
14
+ import { fetchContestTasks, setupTask } from './atcoder/new';
15
+ import { loadConfig, saveConfig } from './config/config-store';
16
+ import { runAllTests, resolveTaskDirectory, detectCodeFile } from './test-runner/runner';
17
+ import { submitTask } from './atcoder/submit';
18
+ import { createAtCoderClient } from './atcoder/client';
19
+ import { parseSubmissionStatus } from './atcoder/parser/submission-status';
20
+ import { parseProblemPage } from './atcoder/parser/problem-page';
21
+ import { AtcError, WorkspaceNotFoundError } from './utils/errors';
22
+ import { formatOutputLines, formatErrorOutputLines } from './utils/format';
23
+ import { getLanguage, t } from './utils/i18n';
24
+ import { openUrl, copyToClipboard } from './utils/open';
25
+
26
+ const workspaceRoot = (() => {
27
+ try {
28
+ return findWorkspaceRoot();
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ })();
33
+ const lang = getLanguage(workspaceRoot);
34
+
35
+ const program = new Command();
36
+
37
+ program
38
+ .name('atc')
39
+ .description('AtCoder All-in-One CLI (Local-first)')
40
+ .version('1.1.0-betas');
41
+
42
+ function handleAction(fn: (...args: any[]) => Promise<void>) {
43
+ return async (...args: any[]) => {
44
+ try {
45
+ await fn(...args);
46
+ } catch (err: any) {
47
+ let errMsg = err.message || 'An unexpected error occurred.';
48
+ if (err instanceof WorkspaceNotFoundError) {
49
+ errMsg = t('workspaceNotFound', lang);
50
+ }
51
+ p.log.error(pc.red(errMsg));
52
+ process.exit(1);
53
+ }
54
+ };
55
+ }
56
+
57
+ program
58
+ .command('init')
59
+ .description(t('descInit', lang))
60
+ .action(
61
+ handleAction(async () => {
62
+ p.intro(pc.cyan(t('initIntro', lang)));
63
+
64
+ const defaultLanguage = await p.select({
65
+ message: t('initSelectLang', lang),
66
+ options: [
67
+ { value: 'cpp', label: 'C++ (cpp)' },
68
+ { value: 'python', label: 'Python (python)' }
69
+ ]
70
+ });
71
+
72
+ if (p.isCancel(defaultLanguage)) {
73
+ p.cancel(t('initCancelled', lang));
74
+ process.exit(0);
75
+ }
76
+
77
+ const targetDir = process.cwd();
78
+ const s = p.spinner();
79
+ s.start(t('initSpinner', lang));
80
+
81
+ const { alreadyInitialized, gitignoreUpdated } = initWorkspace(targetDir, defaultLanguage as string);
82
+
83
+ s.stop(t('initFilesSet', lang));
84
+
85
+ if (alreadyInitialized) {
86
+ p.log.warn(t('initAlreadyInitialized', lang));
87
+ } else {
88
+ p.log.success(t('initCreatedConfig', lang, defaultLanguage));
89
+ }
90
+
91
+ if (gitignoreUpdated) {
92
+ p.log.success(t('initGitignoreUpdated', lang));
93
+ }
94
+
95
+ p.outro(pc.green(t('initOutro', lang)));
96
+ })
97
+ );
98
+
99
+ program
100
+ .command('login')
101
+ .description(t('descLogin', lang))
102
+ .action(
103
+ handleAction(async () => {
104
+ p.intro(pc.cyan(t('loginIntro', lang)));
105
+ const workspaceRoot = findWorkspaceRoot();
106
+
107
+ p.note(t('loginNote', lang));
108
+
109
+ const username = await promptManualCookie(workspaceRoot);
110
+
111
+ p.outro(pc.green(t('loginWelcome', lang, username)));
112
+ })
113
+ );
114
+
115
+ async function promptManualCookie(workspaceRoot: string): Promise<string> {
116
+ while (true) {
117
+ const sessionVal = await p.text({
118
+ message: t('loginEnterCookie', lang),
119
+ placeholder: t('loginPlaceholder', lang),
120
+ validate: (val) => (!val.trim() ? t('loginCookieNotEmpty', lang) : undefined)
121
+ });
122
+
123
+ if (p.isCancel(sessionVal)) {
124
+ p.cancel(t('loginCancelled', lang));
125
+ process.exit(0);
126
+ }
127
+
128
+ const s = p.spinner();
129
+ s.start(t('loginVerifying', lang));
130
+ try {
131
+ const username = await loginWithCookie(workspaceRoot, sessionVal);
132
+ s.stop(t('loginVerifySuccess', lang));
133
+ return username;
134
+ } catch (err: any) {
135
+ s.stop(t('loginVerifyFailed', lang));
136
+ p.log.error(pc.red(err.message));
137
+
138
+ const retry = await p.confirm({
139
+ message: t('loginRetryConfirm', lang),
140
+ initialValue: true
141
+ });
142
+
143
+ if (p.isCancel(retry) || !retry) {
144
+ p.cancel(t('loginAborted', lang));
145
+ process.exit(1);
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ program
152
+ .command('logout')
153
+ .description(t('descLogout', lang))
154
+ .action(
155
+ handleAction(async () => {
156
+ const workspaceRoot = findWorkspaceRoot();
157
+ clearSession(workspaceRoot);
158
+ p.log.success(t('logoutSuccess', lang));
159
+ })
160
+ );
161
+
162
+ program
163
+ .command('whoami')
164
+ .description(t('descWhoami', lang))
165
+ .action(
166
+ handleAction(async () => {
167
+ const workspaceRoot = findWorkspaceRoot();
168
+ const s = p.spinner();
169
+ s.start('Verifying session...');
170
+ const username = await whoami(workspaceRoot);
171
+ s.stop(`Logged in as: ${pc.bold(pc.cyan(username))}`);
172
+ })
173
+ );
174
+
175
+ program
176
+ .command('new <contest> [task]')
177
+ .description(t('descNew', lang))
178
+ .option('-a, --all', 'Download all tasks for the contest')
179
+ .action(
180
+ handleAction(async (contestId: string, taskLabel: string | undefined, options: { all?: boolean }) => {
181
+ const workspaceRoot = findWorkspaceRoot();
182
+ const config = loadConfig(workspaceRoot);
183
+ const contestParentDir = config.contestDir ? path.join(workspaceRoot, config.contestDir) : workspaceRoot;
184
+
185
+ const contestDir = path.join(contestParentDir, contestId);
186
+ if (fs.existsSync(contestDir)) {
187
+ throw new AtcError(t('newContestDirExists', lang, contestId));
188
+ }
189
+
190
+ p.intro(pc.cyan(t('newIntro', lang, contestId)));
191
+
192
+ const s = p.spinner();
193
+ s.start(t('newFetchingTasks', lang, contestId));
194
+ const tasks = await fetchContestTasks(workspaceRoot, contestId);
195
+ s.stop(t('newFoundTasks', lang, tasks.length));
196
+
197
+ if (tasks.length === 0) {
198
+ throw new AtcError(t('newNoTasksFound', lang, contestId));
199
+ }
200
+
201
+ let selectedTasks = tasks;
202
+
203
+ if (options.all) {
204
+ selectedTasks = tasks;
205
+ } else if (taskLabel) {
206
+ const matched = tasks.find(t => t.label.toLowerCase() === taskLabel.toLowerCase());
207
+ if (!matched) {
208
+ throw new AtcError(t('newLabelNotFound', lang, taskLabel, contestId, tasks.map(t => t.label).join(', ')));
209
+ }
210
+ selectedTasks = [matched];
211
+ } else {
212
+ const taskOptions = tasks.map(t => ({
213
+ value: t.id,
214
+ label: `${t.label.toUpperCase()} - ${t.id}`
215
+ }));
216
+
217
+ const selection = await p.multiselect({
218
+ message: t('newMultiselectMessage', lang),
219
+ options: taskOptions,
220
+ initialValues: tasks.map(t => t.id),
221
+ required: true
222
+ }) as string[];
223
+
224
+ if (p.isCancel(selection)) {
225
+ p.cancel(t('newCancelled', lang));
226
+ process.exit(0);
227
+ }
228
+
229
+ selectedTasks = tasks.filter(t => selection.includes(t.id));
230
+ }
231
+
232
+ const setupSpinner = p.spinner();
233
+ for (let i = 0; i < selectedTasks.length; i++) {
234
+ const tObj = selectedTasks[i];
235
+
236
+ if (i > 0) {
237
+ await new Promise(resolve => setTimeout(resolve, 300));
238
+ }
239
+
240
+ setupSpinner.start(t('newSettingUpTask', lang, tObj.label.toUpperCase(), tObj.id));
241
+ const res = await setupTask(workspaceRoot, contestId, tObj);
242
+ setupSpinner.stop(t('newSetupSuccess', lang, tObj.label.toUpperCase(), res.sampleCount));
243
+ }
244
+
245
+ p.outro(pc.green(t('newScaffoldingComplete', lang, selectedTasks.length)));
246
+ })
247
+ );
248
+
249
+ function resolveArgs(workspaceRoot: string, taskArg: string | undefined, fileArg: string | undefined) {
250
+ let resolvedTaskDir = '';
251
+ let resolvedFile: string | undefined;
252
+
253
+ let isFile = false;
254
+ let filePath = '';
255
+
256
+ if (taskArg) {
257
+ const pathsToCheck = [
258
+ path.resolve(taskArg),
259
+ path.resolve(workspaceRoot, taskArg)
260
+ ];
261
+
262
+ const config = loadConfig(workspaceRoot);
263
+ if (config.contestDir) {
264
+ pathsToCheck.push(path.resolve(workspaceRoot, config.contestDir, taskArg));
265
+ }
266
+
267
+ for (const p of pathsToCheck) {
268
+ if (fs.existsSync(p) && fs.statSync(p).isFile()) {
269
+ isFile = true;
270
+ filePath = p;
271
+ break;
272
+ }
273
+ }
274
+ }
275
+
276
+ if (isFile) {
277
+ resolvedFile = path.basename(filePath);
278
+ resolvedTaskDir = path.dirname(filePath);
279
+ } else {
280
+ resolvedTaskDir = resolveTaskDirectory(workspaceRoot, taskArg);
281
+ resolvedFile = fileArg;
282
+ }
283
+
284
+ const taskLabel = path.basename(resolvedTaskDir);
285
+ const contestId = path.basename(path.dirname(resolvedTaskDir));
286
+
287
+ return { resolvedTaskDir, resolvedFile, taskLabel, contestId };
288
+ }
289
+
290
+ program
291
+ .command('test [task] [file]')
292
+ .description(t('descTest', lang))
293
+ .action(
294
+ handleAction(async (taskArg: string | undefined, fileArg: string | undefined) => {
295
+ const workspaceRoot = findWorkspaceRoot();
296
+ const { resolvedTaskDir, resolvedFile, taskLabel, contestId } = resolveArgs(workspaceRoot, taskArg, fileArg);
297
+
298
+ p.intro(pc.cyan(t('testIntro', lang, contestId, taskLabel)));
299
+
300
+ const s = p.spinner();
301
+ s.start(t('testRetrievingLimits', lang));
302
+ let timeLimitMs = 2000;
303
+
304
+ try {
305
+ const client = createAtCoderClient(workspaceRoot);
306
+ const tasks = await fetchContestTasks(workspaceRoot, contestId);
307
+ const taskInfo = tasks.find(t => t.label.toLowerCase() === taskLabel.toLowerCase());
308
+
309
+ if (taskInfo) {
310
+ const res = await client.get(`/contests/${contestId}/tasks/${taskInfo.id}`);
311
+ const details = parseProblemPage(res.data);
312
+ timeLimitMs = details.timeLimitMs;
313
+ s.stop(t('testLoadedLimits', lang, timeLimitMs));
314
+ } else {
315
+ s.stop(t('testDefaultLimits', lang));
316
+ }
317
+ } catch (err) {
318
+ s.stop(t('testDefaultLimitsError', lang));
319
+ }
320
+
321
+ const testSpinner = p.spinner();
322
+ testSpinner.start(t('testCompilingRunning', lang));
323
+ const testRes = await runAllTests(workspaceRoot, resolvedTaskDir, resolvedFile, timeLimitMs);
324
+ testSpinner.stop(t('testFinished', lang));
325
+
326
+ if (testRes.compileError) {
327
+ p.log.error(pc.red(t('testCompilationFailed', lang)));
328
+ console.log(testRes.compileError);
329
+ process.exit(1);
330
+ }
331
+
332
+ if (testRes.results.length === 0) {
333
+ p.log.warn(t('testNoSamples', lang));
334
+ process.exit(0);
335
+ }
336
+
337
+ let allPassed = true;
338
+ for (const res of testRes.results) {
339
+ const label = `sample-${res.index}`;
340
+ const duration = `${res.durationMs.toFixed(0)} ms`;
341
+
342
+ if (res.status === 'AC') {
343
+ p.log.success(`${pc.green(pc.bold('[AC]'))} ${label}: Passed (${duration})`);
344
+ } else if (res.status === 'WA') {
345
+ allPassed = false;
346
+ p.log.error(`${pc.red(pc.bold('[WA]'))} ${label}: Failed (${duration})`);
347
+ console.log(` ${pc.gray('┌────────────────────────────────────────────────────────')}`);
348
+ console.log(` ${pc.gray('│')} ${pc.bold('Expected Output:')}`);
349
+ formatOutputLines(res.expectedOutput, res.firstDiffLine).forEach(l => console.log(l));
350
+ console.log(` ${pc.gray('├────────────────────────────────────────────────────────')}`);
351
+ console.log(` ${pc.gray('│')} ${pc.bold('Actual Output:')}`);
352
+ formatOutputLines(res.actualOutput, res.firstDiffLine).forEach(l => console.log(l));
353
+ if (res.firstDiffLine) {
354
+ console.log(` ${pc.gray('├────────────────────────────────────────────────────────')}`);
355
+ console.log(` ${pc.gray('│')} ${pc.yellow(`First mismatch on line ${res.firstDiffLine}`)}`);
356
+ }
357
+ console.log(` ${pc.gray('└────────────────────────────────────────────────────────')}`);
358
+ } else if (res.status === 'TLE') {
359
+ allPassed = false;
360
+ p.log.error(`${pc.red(pc.bold('[TLE]'))} ${label}: Time Limit Exceeded (${duration} vs Limit ${timeLimitMs} ms)`);
361
+ } else if (res.status === 'RE') {
362
+ allPassed = false;
363
+ p.log.error(`${pc.red(pc.bold('[RE]'))} ${label}: Runtime Error (${duration})`);
364
+ if (res.errorOutput) {
365
+ console.log(` ${pc.gray('┌────────────────────────────────────────────────────────')}`);
366
+ console.log(` ${pc.gray('│')} ${pc.bold('Error Output:')}`);
367
+ formatErrorOutputLines(res.errorOutput).forEach(l => console.log(l));
368
+ console.log(` ${pc.gray('└────────────────────────────────────────────────────────')}`);
369
+ }
370
+ }
371
+ }
372
+
373
+ p.outro(allPassed ? pc.green(t('testOutroPassed', lang)) : pc.red(t('testOutroFailed', lang)));
374
+ if (!allPassed) {
375
+ process.exit(1);
376
+ }
377
+ })
378
+ );
379
+
380
+ program
381
+ .command('submit [task] [file]')
382
+ .description(t('descSubmit', lang))
383
+ .action(
384
+ handleAction(async (taskArg: string | undefined, fileArg: string | undefined) => {
385
+ const workspaceRoot = findWorkspaceRoot();
386
+ const { resolvedTaskDir, resolvedFile, taskLabel, contestId } = resolveArgs(workspaceRoot, taskArg, fileArg);
387
+
388
+ p.intro(pc.cyan(t('submitPreparing', lang, contestId, taskLabel)));
389
+
390
+ const s = p.spinner();
391
+ s.start(t('submitRetrievingLimits', lang));
392
+ let timeLimitMs = 2000;
393
+ let taskId = '';
394
+
395
+ const client = createAtCoderClient(workspaceRoot);
396
+ const tasks = await fetchContestTasks(workspaceRoot, contestId);
397
+ const taskInfo = tasks.find(t => t.label.toLowerCase() === taskLabel.toLowerCase());
398
+
399
+ if (!taskInfo) {
400
+ throw new AtcError(`Task label "${taskLabel}" not found in contest "${contestId}".`);
401
+ }
402
+ taskId = taskInfo.id;
403
+
404
+ try {
405
+ const res = await client.get(`/contests/${contestId}/tasks/${taskId}`);
406
+ const details = parseProblemPage(res.data);
407
+ timeLimitMs = details.timeLimitMs;
408
+ s.stop(t('testLoadedLimits', lang, timeLimitMs));
409
+ } catch (err) {
410
+ s.stop(t('testDefaultLimitsError', lang));
411
+ }
412
+
413
+ p.log.step(t('submitRunningTests', lang));
414
+ const testRes = await runAllTests(workspaceRoot, resolvedTaskDir, resolvedFile, timeLimitMs);
415
+
416
+ if (testRes.compileError) {
417
+ p.log.error(pc.red(t('testCompilationFailed', lang)));
418
+ console.log(testRes.compileError);
419
+ process.exit(1);
420
+ }
421
+
422
+ const allPassed = testRes.results.length > 0 && testRes.results.every(r => r.status === 'AC');
423
+
424
+ if (testRes.results.length === 0) {
425
+ p.log.warn(t('submitNoSamples', lang));
426
+ } else if (!allPassed) {
427
+ p.log.warn(pc.yellow(t('submitTestsFailed', lang)));
428
+ const confirmSubmit = await p.confirm({
429
+ message: t('submitConfirmMessage', lang),
430
+ initialValue: false
431
+ });
432
+
433
+ if (p.isCancel(confirmSubmit) || !confirmSubmit) {
434
+ p.cancel(t('submitAborted', lang));
435
+ process.exit(0);
436
+ }
437
+ } else {
438
+ p.log.success(pc.green(t('submitTestsPassed', lang)));
439
+ }
440
+
441
+ const subSpinner = p.spinner();
442
+ subSpinner.start(t('submitSubmitting', lang));
443
+ let subDetails;
444
+ try {
445
+ subDetails = await submitTask(workspaceRoot, contestId, taskId, taskLabel, resolvedFile);
446
+ subSpinner.stop(t('submitSuccess', lang, subDetails.submissionId));
447
+ openUrl(`https://atcoder.jp${subDetails.url}`);
448
+ } catch (err: any) {
449
+ subSpinner.stop(pc.yellow(t('submitManualSubmission', lang)));
450
+
451
+ let codeContent = '';
452
+ try {
453
+ const config = loadConfig(workspaceRoot);
454
+ const { codeFile } = detectCodeFile(workspaceRoot, resolvedTaskDir, config, resolvedFile);
455
+ codeContent = fs.readFileSync(path.join(resolvedTaskDir, codeFile), 'utf8');
456
+ } catch {
457
+ // Ignore
458
+ }
459
+
460
+ const submitPageUrl = `https://atcoder.jp/contests/${contestId}/submit?taskScreenName=${taskId}`;
461
+ openUrl(submitPageUrl);
462
+
463
+ if (codeContent) {
464
+ await copyToClipboard(codeContent);
465
+ p.outro(pc.cyan(t('submitFallbackMessageWithClipboard', lang)));
466
+ } else {
467
+ p.outro(pc.cyan(t('submitFallbackMessage', lang)));
468
+ }
469
+
470
+ process.exit(0);
471
+ }
472
+
473
+ const pollSpinner = p.spinner();
474
+ pollSpinner.start(t('submitWaitingJudge', lang));
475
+
476
+ const pollInterval = 2000;
477
+ const timeout = 300000; // 5 minutes
478
+ const startTime = Date.now();
479
+ let completed = false;
480
+
481
+ while (Date.now() - startTime < timeout) {
482
+ try {
483
+ const detailRes = await client.get(subDetails.url);
484
+ const status = parseSubmissionStatus(detailRes.data);
485
+
486
+ pollSpinner.message(`Judge Status: ${pc.yellow(pc.bold(status.status))}`);
487
+
488
+ if (status.isCompleted) {
489
+ completed = true;
490
+ pollSpinner.stop(t('submitJudgeFinished', lang, status.status));
491
+
492
+ const stats = [];
493
+ if (status.score) stats.push(`Score: ${status.score}`);
494
+ if (status.time) stats.push(`Time: ${status.time}`);
495
+ if (status.memory) stats.push(`Memory: ${status.memory}`);
496
+ const statsStr = stats.length > 0 ? ` (${stats.join(', ')})` : '';
497
+
498
+ if (status.status === 'AC') {
499
+ p.log.success(`${pc.green(pc.bold('[AC]'))} ${t('submitAccepted', lang)}${statsStr}`);
500
+ } else {
501
+ p.log.error(`${pc.red(pc.bold(`[${status.status}]`))} ${t('submitFailed', lang)}${statsStr}`);
502
+ }
503
+ break;
504
+ }
505
+ } catch (e: any) {
506
+ // Ignore intermediate polling network errors
507
+ pollSpinner.message(`Polling status... (network retry: ${e.message})`);
508
+ }
509
+
510
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
511
+ }
512
+
513
+ if (!completed) {
514
+ pollSpinner.stop(t('submitTimeout', lang));
515
+ p.log.warn(t('submitTimeoutWarn', lang, subDetails.url));
516
+ }
517
+
518
+ p.outro(pc.green('Done.'));
519
+ })
520
+ );
521
+
522
+ program
523
+ .command('lang [language]')
524
+ .description(t('descLang', lang))
525
+ .action(
526
+ handleAction(async (targetLanguage: string | undefined) => {
527
+ let workspaceRoot: string;
528
+ try {
529
+ workspaceRoot = findWorkspaceRoot();
530
+ } catch {
531
+ p.log.error(pc.red(t('langWorkspaceRequired', lang)));
532
+ process.exit(1);
533
+ }
534
+
535
+ if (!targetLanguage) {
536
+ console.log(t('langCommandUsage', lang));
537
+ process.exit(0);
538
+ }
539
+
540
+ const cleanLang = targetLanguage.trim().toLowerCase();
541
+ if (cleanLang !== 'en' && cleanLang !== 'ja') {
542
+ p.log.error(pc.red(t('langInvalid', lang)));
543
+ process.exit(1);
544
+ }
545
+
546
+ const config = loadConfig(workspaceRoot);
547
+ config.lang = cleanLang as 'en' | 'ja';
548
+ saveConfig(workspaceRoot, config);
549
+
550
+ p.log.success(t('langSuccess', cleanLang as 'en' | 'ja', cleanLang));
551
+ })
552
+ );
553
+
554
+ program.parse(process.argv);
@@ -0,0 +1,72 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { findWorkspaceRoot } from '../workspace/finder';
4
+
5
+ export interface LanguageConfig {
6
+ extension: string;
7
+ templateDir: string;
8
+ build: string;
9
+ run: string;
10
+ }
11
+
12
+ export interface Config {
13
+ defaultLanguage: string;
14
+ languages: Record<string, LanguageConfig>;
15
+ testDirName: string;
16
+ contestDir?: string;
17
+ lang?: 'en' | 'ja';
18
+ }
19
+
20
+ export const DEFAULT_CONFIG: Config = {
21
+ defaultLanguage: 'cpp',
22
+ languages: {
23
+ cpp: {
24
+ extension: 'cpp',
25
+ templateDir: 'templates/cpp',
26
+ build: 'g++ -O2 -std=gnu++20 -o a.out main.cpp',
27
+ run: './a.out'
28
+ },
29
+ python: {
30
+ extension: 'py',
31
+ templateDir: 'templates/python',
32
+ build: '',
33
+ run: 'python3 main.py'
34
+ }
35
+ },
36
+ testDirName: 'tests',
37
+ contestDir: ''
38
+ };
39
+
40
+ export function getConfigPath(workspaceRoot: string): string {
41
+ return path.join(workspaceRoot, '.atcoder-cli', 'config.json');
42
+ }
43
+
44
+ export function loadConfig(workspaceRoot: string): Config {
45
+ const configPath = getConfigPath(workspaceRoot);
46
+ if (!fs.existsSync(configPath)) {
47
+ return DEFAULT_CONFIG;
48
+ }
49
+ try {
50
+ const raw = fs.readFileSync(configPath, 'utf8');
51
+ const parsed = JSON.parse(raw);
52
+ return {
53
+ ...DEFAULT_CONFIG,
54
+ ...parsed,
55
+ languages: {
56
+ ...DEFAULT_CONFIG.languages,
57
+ ...(parsed.languages || {})
58
+ }
59
+ };
60
+ } catch (e) {
61
+ return DEFAULT_CONFIG;
62
+ }
63
+ }
64
+
65
+ export function saveConfig(workspaceRoot: string, config: Config): void {
66
+ const configPath = getConfigPath(workspaceRoot);
67
+ const dir = path.dirname(configPath);
68
+ if (!fs.existsSync(dir)) {
69
+ fs.mkdirSync(dir, { recursive: true });
70
+ }
71
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
72
+ }