@jutge.org/toolkit 4.3.1 → 4.4.0

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.
@@ -61,6 +61,16 @@ Before we dive into configuration, it's important to know how to get help:
61
61
  jtk about
62
62
  ```
63
63
 
64
+ 5. **Enable shell completion (Bash, Zsh, Fish, PowerShell):**
65
+
66
+ Completions are available for commands, options, and values (e.g. language codes, compiler ids, template names). To enable them:
67
+
68
+ ```bash
69
+ jtk completion install
70
+ ```
71
+
72
+ After upgrading the toolkit with `jtk upgrade`, run `jtk completion install` again to refresh the completion script.
73
+
64
74
  ### Configuration
65
75
 
66
76
  Before using the toolkit, you should configure it with your preferences:
@@ -230,6 +240,7 @@ Here's a quick reference of the most commonly used commands:
230
240
  jtk --help # Show help
231
241
  jtk --version # Show version
232
242
  jtk upgrade # Upgrade to latest version
243
+ jtk completion install # Install/update shell completions
233
244
  jtk about # Show information about the toolkit
234
245
 
235
246
  jtk config show # Show configuration
@@ -263,7 +274,7 @@ jtk doctor # Check system dependencies
263
274
 
264
275
  ## Tips for Success
265
276
 
266
- 1. **Keep the toolkit updated:** Regularly run `jtk upgrade` to get the latest features and fixes.
277
+ 1. **Keep the toolkit updated:** Regularly run `jtk upgrade` to get the latest features and fixes. After upgrading, run `jtk completion install` to update shell completions.
267
278
 
268
279
  2. **Start simple:** Begin with a basic problem and gradually explore more features.
269
280
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jutge.org/toolkit",
3
3
  "description": "Toolkit to prepare problems for Jutge.org",
4
- "version": "4.3.1",
4
+ "version": "4.4.0",
5
5
  "homepage": "https://jutge.org",
6
6
  "author": {
7
7
  "name": "Jutge.org",
@@ -0,0 +1,202 @@
1
+ import { Command } from '@commander-js/extra-typings'
2
+ import { mkdir, readFile, writeFile } from 'fs/promises'
3
+ import { join } from 'path'
4
+ import tui from '../lib/tui'
5
+ import { complete } from '../lib/completion'
6
+
7
+ /** Hidden command used by completion scripts to get candidates */
8
+ export const completeInternalCmd = new Command('_complete')
9
+ .description('Internal: output completion candidates (used by shell completion scripts)')
10
+ .argument('<shell>', 'bash|zsh|fish|powershell')
11
+ .argument('<cword>', 'index of word being completed', (v) => parseInt(v, 10))
12
+ .argument('[words...]', 'command line words')
13
+ .action(async (shell: string, cword: number, words: string[]) => {
14
+ const result = await complete(words, cword)
15
+ for (const w of result.words) {
16
+ process.stdout.write(w + '\n')
17
+ }
18
+ })
19
+
20
+ function bashScript(): string {
21
+ return `# Bash completion for jtk/jutge-toolkit
22
+ _jtk_completion() {
23
+ local cur words cword
24
+ _get_comp_words_by_ref -n : cur words cword
25
+ local reply
26
+ reply=($(jtk _complete bash "$cword" -- "\${words[@]}" 2>/dev/null))
27
+ COMPREPLY=($(compgen -W "\${reply[*]}" -- "$cur"))
28
+ }
29
+ complete -o default -F _jtk_completion jtk jutge-toolkit 2>/dev/null || true
30
+ `
31
+ }
32
+
33
+ function zshScript(): string {
34
+ return `# Zsh completion for jtk/jutge-toolkit
35
+ _jtk_completion() {
36
+ local cword=$((CURRENT - 1))
37
+ local -a reply
38
+ reply=("\${(@f)"$(jtk _complete zsh "$cword" -- "\${words[@]}" 2>/dev/null)"}")
39
+ _describe 'jtk' reply
40
+ }
41
+ compdef _jtk_completion jtk jutge-toolkit
42
+ `
43
+ }
44
+
45
+ function fishScript(): string {
46
+ return `# Fish completion for jtk/jutge-toolkit
47
+ function __jtk_completion
48
+ set -l tokens (commandline -o)
49
+ set -l cword (math (count $tokens) - 1)
50
+ jtk _complete fish $cword -- $tokens 2>/dev/null
51
+ end
52
+ complete -c jtk -a '(__jtk_completion)'
53
+ complete -c jutge-toolkit -a '(__jtk_completion)'
54
+ `
55
+ }
56
+
57
+ function powershellScript(): string {
58
+ return `# PowerShell completion for jtk/jutge-toolkit
59
+ Register-ArgumentCompleter -CommandName jtk,jutge-toolkit -ScriptBlock {
60
+ param($wordToComplete, $commandAst, $cursorPosition)
61
+ $words = $commandAst.CommandElements | ForEach-Object { $_.ToString() }
62
+ $cword = [Math]::Max(0, $words.Count - 1)
63
+ $all = $words + $wordToComplete
64
+ $reply = jtk _complete powershell $cword -- @all 2>$null
65
+ $reply | Where-Object { $_ -like "$wordToComplete*" }
66
+ }
67
+ `
68
+ }
69
+
70
+ export const completionCmd = new Command('completion')
71
+ .description('Generate and install shell completion scripts for jtk')
72
+ .action(() => {
73
+ completionCmd.help()
74
+ })
75
+
76
+ completionCmd
77
+ .command('bash')
78
+ .description('Output Bash completion script')
79
+ .action(() => {
80
+ console.log(bashScript())
81
+ })
82
+
83
+ completionCmd
84
+ .command('zsh')
85
+ .description('Output Zsh completion script')
86
+ .action(() => {
87
+ console.log(zshScript())
88
+ })
89
+
90
+ completionCmd
91
+ .command('fish')
92
+ .description('Output Fish completion script')
93
+ .action(() => {
94
+ console.log(fishScript())
95
+ })
96
+
97
+ completionCmd
98
+ .command('powershell')
99
+ .description('Output PowerShell completion script')
100
+ .action(() => {
101
+ console.log(powershellScript())
102
+ })
103
+
104
+ completionCmd
105
+ .command('install [shell]')
106
+ .description('Install completion script for the current shell (or specify bash|zsh|fish|powershell)')
107
+ .action(async (shellArg?: string) => {
108
+ const shell = shellArg || detectShell()
109
+ const homedir = process.env.HOME || process.env.USERPROFILE || ''
110
+ if (!homedir) {
111
+ tui.print('Could not determine home directory (set HOME or USERPROFILE).')
112
+ return
113
+ }
114
+ const xdgData = process.env.XDG_DATA_HOME || join(homedir, '.local', 'share')
115
+
116
+ const writeScript = async (filePath: string, script: string): Promise<'created' | 'updated'> => {
117
+ let existing: string | null = null
118
+ try {
119
+ existing = await readFile(filePath, 'utf-8')
120
+ } catch {
121
+ // file does not exist
122
+ }
123
+ await writeFile(filePath, script, 'utf-8')
124
+ return existing !== null && existing !== script ? 'updated' : 'created'
125
+ }
126
+
127
+ if (shell === 'bash') {
128
+ const dir = join(xdgData, 'bash-completion', 'completions')
129
+ const filePath = join(dir, 'jtk')
130
+ await mkdir(dir, { recursive: true })
131
+ const status = await writeScript(filePath, bashScript())
132
+ tui.success(
133
+ status === 'created' ? `Created ${tui.hyperlink(filePath)}` : `Updated ${tui.hyperlink(filePath)}`,
134
+ )
135
+ tui.print('Restart your shell or run: source "' + filePath + '"')
136
+ } else if (shell === 'zsh') {
137
+ const dir = join(homedir, '.zsh')
138
+ const filePath = join(dir, '_jtk')
139
+ await mkdir(dir, { recursive: true })
140
+ const status = await writeScript(filePath, zshScript())
141
+ tui.success(
142
+ status === 'created' ? `Created ${tui.hyperlink(filePath)}` : `Updated ${tui.hyperlink(filePath)}`,
143
+ )
144
+ const zshrc = join(homedir, '.zshrc')
145
+ let zshrcContent: string | null = null
146
+ try {
147
+ zshrcContent = await readFile(zshrc, 'utf-8')
148
+ } catch {
149
+ /* .zshrc may not exist */
150
+ }
151
+ const lines: string[] = []
152
+ if (!zshrcContent?.includes(dir)) {
153
+ lines.push(`fpath=("${dir}" $fpath)`)
154
+ }
155
+ if (!zshrcContent?.includes('compinit')) {
156
+ lines.push('autoload -Uz compinit && compinit')
157
+ }
158
+ if (lines.length > 0) {
159
+ const toAppend = `\n# jtk completion\n${lines.join('\n')}\n`
160
+ await writeFile(zshrc, (zshrcContent ?? '') + toAppend, 'utf-8')
161
+ const added =
162
+ lines.length === 2
163
+ ? 'fpath and compinit'
164
+ : lines.length === 1 && lines[0]!.includes('fpath')
165
+ ? 'fpath'
166
+ : 'compinit'
167
+ tui.success(`Updated ${tui.hyperlink(zshrc)} (added ${added})`)
168
+ }
169
+ tui.print('Restart your shell to use completions.')
170
+ } else if (shell === 'fish') {
171
+ const dir = join(homedir, '.config', 'fish', 'completions')
172
+ const filePath = join(dir, 'jtk.fish')
173
+ await mkdir(dir, { recursive: true })
174
+ const status = await writeScript(filePath, fishScript())
175
+ tui.success(
176
+ status === 'created' ? `Created ${tui.hyperlink(filePath)}` : `Updated ${tui.hyperlink(filePath)}`,
177
+ )
178
+ tui.print('Restart your shell to use completions.')
179
+ } else if (shell === 'powershell') {
180
+ const dir = join(homedir, '.config', 'powershell')
181
+ await mkdir(dir, { recursive: true })
182
+ const filePath = join(dir, 'jtk-completion.ps1')
183
+ const status = await writeScript(filePath, powershellScript())
184
+ tui.success(
185
+ status === 'created' ? `Created ${tui.hyperlink(filePath)}` : `Updated ${tui.hyperlink(filePath)}`,
186
+ )
187
+ tui.print('Load it in this session: . "' + filePath.replace(/'/g, "''") + '"')
188
+ tui.print('To load automatically, add the line above to your $PROFILE.')
189
+ } else {
190
+ tui.print(`Unknown or unsupported shell: ${shell}`)
191
+ tui.print('Use: jtk completion install bash|zsh|fish|powershell')
192
+ }
193
+ })
194
+
195
+ function detectShell(): string {
196
+ const shell = process.env.SHELL || ''
197
+ if (shell.includes('fish')) return 'fish'
198
+ if (shell.includes('zsh')) return 'zsh'
199
+ if (shell.includes('bash')) return 'bash'
200
+ if (process.env.PSModulePath) return 'powershell'
201
+ return 'bash'
202
+ }
@@ -51,8 +51,8 @@ The original statement will be used as the source text for translation.
51
51
 
52
52
  Provide one or more target language from the following list:
53
53
  ${Object.entries(languageNames)
54
- .map(([key, name]) => ` - ${key}: ${name}`)
55
- .join('\n')}
54
+ .map(([key, name]) => ` - ${key}: ${name}`)
55
+ .join('\n')}
56
56
 
57
57
  The added translations will be saved in the problem directory overwrite possible existing files.`,
58
58
  )
@@ -82,8 +82,8 @@ The golden solution will be used as a reference for generating the alternatives.
82
82
 
83
83
  Provide one or more target programming languages from the following list:
84
84
  ${Object.entries(languageNames)
85
- .map(([key, name]) => ` - ${key}: ${name}`)
86
- .join('\n')}
85
+ .map(([key, name]) => ` - ${key}: ${name}`)
86
+ .join('\n')}
87
87
 
88
88
  The added solutions will be saved in the problem directory overwrite possible existing files.`,
89
89
  )
@@ -117,8 +117,8 @@ The main file for the golden solution will be used as a reference for generating
117
117
 
118
118
  Provide one or more target programming languages from the following list:
119
119
  ${Object.entries(languageNames)
120
- .map(([key, name]) => ` - ${key}: ${name}`)
121
- .join('\n')}
120
+ .map(([key, name]) => ` - ${key}: ${name}`)
121
+ .join('\n')}
122
122
 
123
123
  The added main files will be saved in the problem directory overwrite possible existing files.`,
124
124
  )
@@ -191,7 +191,6 @@ The new image will be saved as award.png in the problem directory, overriding an
191
191
  imagePrompt = 'A colorful award on a white background'
192
192
  }
193
193
  await tui.section('Generating award image', async () => {
194
- // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- generated API client
195
194
  const download = await jutge.instructor.jutgeai.createImage({
196
195
  model,
197
196
  label: 'award',
package/toolkit/index.ts CHANGED
@@ -22,6 +22,8 @@ import { submitCmd } from './submit'
22
22
  import { askCmd } from './ask'
23
23
  import { convertCmd } from './convert'
24
24
  import { stageCmd } from './stage'
25
+ import { lintCmd } from './lint'
26
+ import { completeInternalCmd, completionCmd } from './completion'
25
27
 
26
28
  program.name(Object.keys(packageJson.bin as Record<string, string>)[0] as string)
27
29
  program.alias(Object.keys(packageJson.bin as Record<string, string>)[1] as string)
@@ -37,6 +39,7 @@ program.addCommand(cleanCmd)
37
39
  program.addCommand(cloneCmd)
38
40
  program.addCommand(generateCmd)
39
41
  program.addCommand(verifyCmd)
42
+ program.addCommand(lintCmd)
40
43
  program.addCommand(submitCmd)
41
44
  program.addCommand(convertCmd)
42
45
  program.addCommand(stageCmd)
@@ -47,12 +50,14 @@ if (settings.developer) {
47
50
  }
48
51
  program.addCommand(configCmd)
49
52
  program.addCommand(upgradeCmd)
53
+ program.addCommand(completionCmd)
50
54
  program.addCommand(aboutCmd)
51
55
  program.addCommand(askCmd)
56
+ program.addCommand(completeInternalCmd, { hidden: true })
52
57
 
53
58
  try {
54
59
  await program.parseAsync()
55
- process.exit(0)
60
+ process.exit(process.exitCode ?? 0)
56
61
  } catch (error) {
57
62
  console.log()
58
63
  console.error('An error occurred:')
@@ -0,0 +1,64 @@
1
+ import { Command } from '@commander-js/extra-typings'
2
+ import chalk from 'chalk'
3
+ import { lintDirectories, type LintIssue } from '../lib/lint'
4
+
5
+ function formatIssue(issue: LintIssue): string {
6
+ const prefix = issue.severity === 'error' ? chalk.red('error') : chalk.yellow('warning')
7
+ const path = issue.path ? chalk.gray(` (${issue.path})`) : ''
8
+ return ` ${prefix} ${issue.code}: ${issue.message}${path}`
9
+ }
10
+
11
+ export const lintCmd = new Command('lint')
12
+ .summary('Lint a problem directory')
13
+ .description(
14
+ 'Check problem.yml/handler.yml schema, required files present, naming conventions, statement structure, sample vs public test consistency, etc.',
15
+ )
16
+ .argument('[directories...]', 'problem directories to lint (default: current directory)')
17
+ .option('-d, --directory <path>', 'problem directory when no arguments given', '.')
18
+ .action(async (directories: string[], { directory }) => {
19
+ const dirs = directories.length > 0 ? directories : [directory]
20
+ const results = await lintDirectories(dirs)
21
+
22
+ if (results.length === 0) {
23
+ console.log(chalk.yellow('No problem directories found (looked for handler.yml in the given path(s)).'))
24
+ return
25
+ }
26
+
27
+ let hasError = false
28
+ let hasWarning = false
29
+
30
+ for (const result of results) {
31
+ const errors = result.issues.filter((i) => i.severity === 'error')
32
+ const warnings = result.issues.filter((i) => i.severity === 'warning')
33
+ if (errors.length > 0) hasError = true
34
+ if (warnings.length > 0) hasWarning = true
35
+
36
+ const dirLabel = result.directory === dirs[0] && results.length === 1 ? result.directory : result.directory
37
+ if (result.issues.length === 0) {
38
+ console.log(chalk.green('✓'), dirLabel, chalk.gray('— no issues'))
39
+ } else {
40
+ console.log()
41
+ console.log(chalk.bold(dirLabel))
42
+ for (const issue of result.issues) {
43
+ console.log(formatIssue(issue))
44
+ }
45
+ }
46
+ }
47
+
48
+ if (results.length > 1) {
49
+ console.log()
50
+ const totalErrors = results.reduce((s, r) => s + r.issues.filter((i) => i.severity === 'error').length, 0)
51
+ const totalWarnings = results.reduce((s, r) => s + r.issues.filter((i) => i.severity === 'warning').length, 0)
52
+ if (totalErrors > 0 || totalWarnings > 0) {
53
+ console.log(
54
+ chalk.gray(
55
+ `Total: ${totalErrors} error(s), ${totalWarnings} warning(s) across ${results.length} problem(s)`,
56
+ ),
57
+ )
58
+ }
59
+ }
60
+
61
+ if (hasError) {
62
+ process.exitCode = 1
63
+ }
64
+ })
package/toolkit/submit.ts CHANGED
@@ -10,9 +10,10 @@ export const submitCmd = new Command('submit')
10
10
  .option('-c, --compiler <id>', 'compiler to use (default: auto-detect from file extension)', 'auto')
11
11
  .option('-l, --language <code>', 'language code (ca, es, en, ...)', 'en')
12
12
  .option('-n, --no-wait', 'do not wait for submissions to be judged')
13
+ .option('--no-browser', 'do not open the submission URL in the browser (only print URL and/or wait for verdict in terminal)')
13
14
  .option('-a, --annotation <annotation>', "annotation for the submission (default: 'jtk-submit-<nanoid16>')")
14
15
 
15
- .action(async (programs, { directory, compiler, language, wait, annotation }) => {
16
+ .action(async (programs, { directory, compiler, language, wait, browser, annotation }) => {
16
17
  const annotationValue = annotation ?? `jtk-submit-${nanoid16()}`
17
- await submitInDirectory(directory, language, programs, !wait, compiler, annotationValue)
18
+ await submitInDirectory(directory, language, programs, !wait, browser, compiler, annotationValue)
18
19
  })
package/toolkit/ai.ts DELETED
@@ -1,56 +0,0 @@
1
- import { Command } from '@commander-js/extra-typings'
2
- import sharp from 'sharp'
3
- import z from 'zod'
4
- import { complete, generateImage, listModels } from '../lib/ai.ts'
5
- import { settings } from '../lib/settings.ts'
6
- import tui from '../lib/tui.ts'
7
- import { convertStringToItsType } from '../lib/utils.ts'
8
-
9
- export const aiCmd = new Command('ai')
10
- .description('Query AI models')
11
-
12
- .action(() => {
13
- aiCmd.help()
14
- })
15
-
16
- aiCmd
17
- .command('models')
18
- .description('Show available AI models')
19
-
20
- .action(async () => {
21
- const models = await listModels()
22
- tui.yaml(models)
23
- })
24
-
25
- aiCmd
26
- .command('complete')
27
- .description('Complete a prompt using an AI model')
28
-
29
- .argument('<prompt>', 'the user prompt to complete')
30
- .option('-s, --system-prompt <system>', 'the system prompt to use', 'You are a helpful assistant.')
31
- .option('-m, --model <model>', 'the AI model to use', settings.defaultModel)
32
-
33
- .action(async (prompt, { model, systemPrompt }) => {
34
- prompt = prompt.trim()
35
- systemPrompt = systemPrompt.trim()
36
- const answer = await complete(model, systemPrompt, prompt)
37
- tui.print(answer)
38
- })
39
-
40
- // TODO: generate with different aspect ratios
41
- aiCmd
42
- .command('image')
43
- .description('Generate a square image using an AI model')
44
-
45
- .argument('<prompt>', 'description of the image to generate')
46
- .option('-m, --model <model>', 'the graphic AI model to use', 'openai/dall-e-3')
47
- .option('-s, --size <size>', 'the size of the image (in pixels)', '1024')
48
- .option('-o, --output <path>', 'the output image path', 'image.png')
49
-
50
- .action(async (prompt, { model, size, output }) => {
51
- const sizeInt = z.int().min(16).max(2048).parse(convertStringToItsType(size))
52
- const image = await generateImage(model, prompt)
53
- await sharp(image).resize(sizeInt, sizeInt).toFile(output)
54
- tui.success(`Generated image saved to ${output}`)
55
- await tui.image(output, 20, 10)
56
- })