canvaslms-cli 1.4.1 → 1.4.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.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Canvas CLI Tool
3
+ Copyright (c) 2025 Dang Duy Toan
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,182 +1,106 @@
1
1
  # Canvas CLI Tool
2
2
 
3
- A powerful command-line interface for interacting with Canvas LMS API. This tool allows you to manage courses, assignments, submissions, and more directly from your terminal.
3
+ A modern, user-friendly command-line interface for Canvas LMS. Manage courses, assignments, submissions, grades, and more directly from your terminal.
4
+
5
+ ---
4
6
 
5
7
  ## Features
6
8
 
7
- - 📚 **Course Management**: List starred and enrolled courses
8
- - 📝 **Assignment Operations**: View assignments, grades, and submission status
9
- - 🚀 **File Submission**: Interactive file upload for assignments (single or multiple files)
10
- - 📢 **Announcements**: View course announcements
11
- - 👤 **Profile Management**: View user profile information
12
- - 🔧 **Raw API Access**: Direct access to Canvas API endpoints
9
+ - List and filter enrolled/starred courses
10
+ - View assignments, grades, and submission status
11
+ - Interactive file upload for assignments
12
+ - View course announcements
13
+ - Display user profile information
14
+ - Direct access to Canvas API endpoints
15
+
16
+ ---
13
17
 
14
18
  ## Installation
15
19
 
16
- ### Global Installation (Recommended)
20
+ ### Global (Recommended)
17
21
 
18
22
  ```bash
19
23
  npm install -g canvas-cli-tool
20
24
  ```
21
25
 
22
- ### Local Installation
26
+ ### Local (Project)
23
27
 
24
28
  ```bash
25
29
  npm install canvas-cli-tool
26
30
  ```
27
31
 
32
+ ---
33
+
28
34
  ## Setup
29
35
 
30
- 1. **Get your Canvas API token**:
31
- - Log into your Canvas instance
36
+ 1. **Get your Canvas API Token**
37
+ - Log in to Canvas
32
38
  - Go to Account → Settings
33
- - Scroll down to "Approved Integrations"
34
- - Click "+ New Access Token"
39
+ - Under Approved Integrations, click + New Access Token
35
40
  - Copy the generated token
36
41
 
37
- 2. **Configure the CLI**:
42
+ 2. **Configure the CLI**
43
+
38
44
  ```bash
39
45
  canvas config
40
46
  ```
41
47
 
42
- 3. **Create a `.env` file** in your project root:
48
+ 3. **Environment Variables**
49
+
50
+ Create a `.env` file in your project root:
51
+
43
52
  ```env
44
53
  CANVAS_DOMAIN=your-canvas-domain.instructure.com
45
54
  CANVAS_API_TOKEN=your-api-token
46
55
  ```
47
56
 
48
- ## Usage
49
-
50
- ### Basic Commands
51
-
52
- ```bash
53
- # Show configuration help
54
- canvas config
55
-
56
- # List starred courses (default)
57
- canvas list
58
-
59
- # List all enrolled courses
60
- canvas list -a
61
-
62
- # List courses with detailed information
63
- canvas list -v
64
-
65
- # Show user profile
66
- canvas profile
67
-
68
- # Show detailed profile information
69
- canvas profile -v
70
- ```
71
-
72
- ### Assignment Operations
73
-
74
- ```bash
75
- # List assignments for a course
76
- canvas assignments 12345
77
-
78
- # Show detailed assignment information
79
- canvas assignments 12345 -v
57
+ ---
80
58
 
81
- # Show only submitted assignments
82
- canvas assignments 12345 -s
83
-
84
- # Show only pending assignments
85
- canvas assignments 12345 -p
86
- ```
87
-
88
- ### File Submission
89
-
90
- ```bash
91
- # Interactive assignment submission
92
- canvas submit
93
-
94
- # Submit with specific course ID
95
- canvas submit -c 12345
96
-
97
- # Submit with specific assignment ID
98
- canvas submit -a 67890
99
-
100
- # Submit specific file
101
- canvas submit -f myfile.pdf
102
- ```
59
+ ## Usage
103
60
 
104
- ### Grades and Announcements
61
+ ### Common Commands
105
62
 
106
63
  ```bash
107
- # Show grades for all courses
108
- canvas grades
109
-
110
- # Show grades for specific course
111
- canvas grades 12345
112
-
113
- # Show recent announcements
114
- canvas announcements
115
-
116
- # Show announcements for specific course
117
- canvas announcements 12345
64
+ canvas config # Configure domain and API token
65
+ canvas list # List starred courses
66
+ canvas list -a # List all enrolled courses
67
+ canvas assignments <course> # List assignments for a course
68
+ canvas grades # Show grades for all courses
69
+ canvas announcements # Show recent announcements
70
+ canvas profile # Show user profile
71
+ canvas submit # Interactive assignment submission
118
72
  ```
119
73
 
120
- ### Raw API Access
74
+ ### Assignment Submission
121
75
 
122
76
  ```bash
123
- # GET request
124
- canvas get users/self
125
-
126
- # GET with query parameters
127
- canvas get courses -q enrollment_state=active
128
-
129
- # POST request with data
130
- canvas post courses/123/assignments -d '{"assignment": {"name": "Test"}}'
131
-
132
- # POST with data from file
133
- canvas post courses/123/assignments -d @assignment.json
77
+ canvas submit # Interactive mode
78
+ canvas submit -c <courseId> # Specify course
79
+ canvas submit -a <assignmentId> # Specify assignment
80
+ canvas submit -f <file> # Submit specific file
134
81
  ```
135
82
 
136
83
  ## Command Reference
137
84
 
138
- | Command | Alias | Description |
139
- |---------|-------|-------------|
140
- | `list` | `l` | List courses |
141
- | `assignments` | `assign` | List assignments |
142
- | `submit` | `sub` | Submit assignment files |
143
- | `grades` | `grade` | Show grades |
144
- | `announcements` | `announce` | Show announcements |
145
- | `profile` | `me` | Show user profile |
146
- | `config` | - | Show configuration |
147
- | `get` | `g` | GET API request |
148
- | `post` | `p` | POST API request |
149
- | `put` | - | PUT API request |
150
- | `delete` | `d` | DELETE API request |
151
-
152
- ## File Structure
85
+ | Command | Alias | Description |
86
+ |-----------------|-----------|-----------------------------------|
87
+ | `list` | `l` | List courses |
88
+ | `assignments` | `assign` | List assignments |
89
+ | `submit` | `sub` | Submit assignment files |
90
+ | `grades` | `grade` | Show grades |
91
+ | `announcements` | `announce`| Show announcements |
92
+ | `profile` | `me` | Show user profile |
93
+ | `config` | - | Show configuration |
153
94
 
154
- ```
155
- canvas-cli-tool/
156
- ├── src/
157
- │ └── index.js # Main CLI entry point
158
- ├── lib/
159
- │ ├── api-client.js # Canvas API client
160
- │ ├── config.js # Configuration management
161
- │ ├── file-upload.js # File upload utilities
162
- │ └── interactive.js # Interactive prompt utilities
163
- ├── commands/
164
- │ ├── list.js # List courses command
165
- │ ├── assignments.js # Assignments command
166
- │ ├── submit.js # Submit command
167
- │ ├── grades.js # Grades command
168
- │ ├── announcements.js # Announcements command
169
- │ ├── profile.js # Profile command
170
- │ ├── config.js # Config command
171
- │ └── api.js # Raw API commands
172
- └── package.json
173
- ```
95
+ ---
174
96
 
175
97
  ## Requirements
176
98
 
177
- - Node.js >= 14.0.0
178
- - npm >= 6.0.0
179
- - Valid Canvas LMS access with API token
99
+ - Node.js >= 14.x
100
+ - npm >= 6.x
101
+ - Valid Canvas LMS API token
102
+
103
+ ---
180
104
 
181
105
  ## Contributing
182
106
 
@@ -184,10 +108,12 @@ canvas-cli-tool/
184
108
  2. Create a feature branch: `git checkout -b feature-name`
185
109
  3. Make your changes
186
110
  4. Run tests: `npm test`
187
- 5. Commit your changes: `git commit -am 'Add feature'`
188
- 6. Push to the branch: `git push origin feature-name`
111
+ 5. Commit: `git commit -am 'Add feature'`
112
+ 6. Push: `git push origin feature-name`
189
113
  7. Submit a pull request
190
114
 
115
+ ---
116
+
191
117
  ## License
192
118
 
193
- MIT License - see [LICENSE](LICENSE) file for details.
119
+ MIT License - see [LICENSE](LICENSE) for details.
@@ -5,7 +5,7 @@
5
5
  import fs from 'fs';
6
6
  import path from 'path';
7
7
  import { makeCanvasRequest } from '../lib/api-client.js';
8
- import { createReadlineInterface, askQuestion, askConfirmation, selectFilesImproved, pad } from '../lib/interactive.js';
8
+ import { createReadlineInterface, askQuestion, askConfirmation, selectFilesImproved, selectFilesKeyboard, pad } from '../lib/interactive.js';
9
9
  import { uploadSingleFileToCanvas, submitAssignmentWithFiles } from '../lib/file-upload.js';
10
10
  import chalk from 'chalk';
11
11
 
@@ -137,9 +137,8 @@ export async function submitAssignment(options) {
137
137
  return;
138
138
  }
139
139
  assignmentId = selectedAssignment.id;
140
- console.log(chalk.green(`Success: Selected ${selectedAssignment.name}\n`));
141
- if (selectedAssignment.submission && selectedAssignment.submission.submitted_at) {
142
- const resubmit = await askConfirmation(rl, chalk.yellow('This assignment has already been submitted. Do you want to resubmit?'), false);
140
+ console.log(chalk.green(`Success: Selected ${selectedAssignment.name}\n`)); if (selectedAssignment.submission && selectedAssignment.submission.submitted_at) {
141
+ const resubmit = await askConfirmation(rl, chalk.yellow('This assignment has already been submitted. Do you want to resubmit?'), true);
143
142
  if (!resubmit) {
144
143
  console.log(chalk.yellow('Submission cancelled.'));
145
144
  rl.close();
@@ -147,14 +146,33 @@ export async function submitAssignment(options) {
147
146
  }
148
147
  }
149
148
  }
150
- // Step 3: Always list files in current directory and prompt user to select
149
+
150
+ // Step 3: Choose file selection method and select files
151
151
  let filePaths = [];
152
152
  console.log(chalk.cyan.bold('-'.repeat(60)));
153
- console.log(chalk.cyan.bold('File Selection'));
153
+ console.log(chalk.cyan.bold('File Selection Method'));
154
154
  console.log(chalk.cyan('-'.repeat(60)));
155
155
  console.log(chalk.white('Course: ') + chalk.bold(selectedCourse.name));
156
156
  console.log(chalk.white('Assignment: ') + chalk.bold(selectedAssignment.name) + '\n');
157
- filePaths = await selectFilesImproved(rl);
157
+
158
+ console.log(chalk.yellow('📁 Choose file selection method:'));
159
+ console.log(chalk.white('1. ') + chalk.cyan('Keyboard Navigator') + chalk.gray(' (NEW! - Use arrow keys and space bar to navigate and select)'));
160
+ console.log(chalk.white('2. ') + chalk.cyan('Text-based Selector') + chalk.gray(' (Traditional - Type filenames and wildcards)'));
161
+ console.log(chalk.white('3. ') + chalk.cyan('Basic Directory Listing') + chalk.gray(' (Simple - Select from numbered list)'));
162
+
163
+ const selectorChoice = await askQuestion(rl, chalk.bold.cyan('\nChoose method (1-3): '));
164
+
165
+ console.log(chalk.cyan.bold('-'.repeat(60)));
166
+ console.log(chalk.cyan.bold('File Selection'));
167
+ console.log(chalk.cyan('-'.repeat(60)));
168
+
169
+ if (selectorChoice === '1') {
170
+ filePaths = await selectFilesKeyboard(rl);
171
+ } else if (selectorChoice === '2') {
172
+ filePaths = await selectFilesImproved(rl);
173
+ } else {
174
+ filePaths = await selectFiles(rl);
175
+ }
158
176
  // Validate all selected files exist
159
177
  const validFiles = [];
160
178
  for (const file of filePaths) {
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import fs from 'fs';
6
+ import path from 'path';
6
7
  import axios from 'axios';
7
8
  import FormData from 'form-data';
8
9
  import { makeCanvasRequest } from './api-client.js';
@@ -17,7 +18,7 @@ export async function uploadSingleFileToCanvas(courseId, assignmentId, filePath)
17
18
  throw new Error(`File not found: ${filePath}`);
18
19
  }
19
20
 
20
- const fileName = require('path').basename(filePath);
21
+ const fileName = path.basename(filePath);
21
22
  const fileContent = fs.readFileSync(filePath);
22
23
 
23
24
  // Step 1: Get upload URL from Canvas
@@ -8,6 +8,19 @@ import path from 'path';
8
8
  import AdmZip from 'adm-zip';
9
9
  import chalk from 'chalk';
10
10
 
11
+ // Key codes for raw terminal input
12
+ const KEYS = {
13
+ UP: '\u001b[A',
14
+ DOWN: '\u001b[B',
15
+ LEFT: '\u001b[D',
16
+ RIGHT: '\u001b[C',
17
+ SPACE: ' ',
18
+ ENTER: '\r',
19
+ ESCAPE: '\u001b',
20
+ BACKSPACE: '\u007f',
21
+ TAB: '\t'
22
+ };
23
+
11
24
  /**
12
25
  * Create readline interface for user input
13
26
  */
@@ -51,15 +64,26 @@ function askQuestionWithValidation(rl, question, validator, errorMessage) {
51
64
  * Ask for confirmation (Y/n format)
52
65
  */
53
66
  async function askConfirmation(rl, question, defaultYes = true) {
54
- const suffix = defaultYes ? ' (Y/n)' : ' (y/N)';
55
- const answer = await askQuestion(rl, question + suffix + ': ');
67
+ const suffix = defaultYes ? " (Y/n)" : " (y/N)";
68
+ const answer = await askQuestion(rl, question + suffix + ": ");
69
+
70
+ const lower = answer.toLowerCase();
56
71
 
57
- if (answer.trim() === '') {
72
+ // If user presses Enter → return defaultYes (true by default)
73
+ if (lower === "") {
58
74
  return defaultYes;
59
75
  }
60
-
61
- const lower = answer.toLowerCase();
62
- return lower === 'y' || lower === 'yes';
76
+
77
+ // Convert input to boolean
78
+ if (lower === "y" || lower === "yes") {
79
+ return true;
80
+ }
81
+ if (lower === "n" || lower === "no") {
82
+ return false;
83
+ }
84
+
85
+ // If input is something else, fallback to defaultYes
86
+ return defaultYes;
63
87
  }
64
88
 
65
89
  /**
@@ -359,6 +383,490 @@ async function selectFilesImproved(rl, currentDir = process.cwd()) {
359
383
  return selectedFiles;
360
384
  }
361
385
 
386
+ /**
387
+ * Interactive file selector with tree view and keyboard navigation
388
+ */
389
+ async function selectFilesKeyboard(rl, currentDir = process.cwd()) {
390
+ const selectedFiles = [];
391
+ let expandedFolders = new Set();
392
+ let fileTree = [];
393
+ let fileList = [];
394
+ let currentPath = currentDir;
395
+ let currentIndex = 0;
396
+ let isNavigating = true;
397
+ let viewStartIndex = 0;
398
+ const maxVisibleItems = 15;
399
+
400
+ // Setup raw mode for keyboard input
401
+ process.stdin.setRawMode(true);
402
+ process.stdin.resume();
403
+ process.stdin.setEncoding('utf8');
404
+
405
+ // Helper function to build file tree
406
+ function buildFileTree(basePath = currentDir, level = 0, parentPath = '') {
407
+ const tree = [];
408
+ try {
409
+ const entries = fs.readdirSync(basePath).sort();
410
+
411
+ // Add directories first
412
+ entries.forEach(entry => {
413
+ const fullPath = path.join(basePath, entry);
414
+ const relativePath = parentPath ? `${parentPath}/${entry}` : entry;
415
+
416
+ try {
417
+ const stats = fs.statSync(fullPath);
418
+ if (stats.isDirectory() && !['node_modules', '.git', 'dist', 'build', '.vscode', '.next'].includes(entry)) {
419
+ const isExpanded = expandedFolders.has(fullPath);
420
+ tree.push({
421
+ name: entry,
422
+ path: fullPath,
423
+ relativePath,
424
+ type: 'directory',
425
+ level,
426
+ isExpanded,
427
+ size: 0
428
+ });
429
+
430
+ // If expanded, add children
431
+ if (isExpanded) {
432
+ const children = buildFileTree(fullPath, level + 1, relativePath);
433
+ tree.push(...children);
434
+ }
435
+ }
436
+ } catch (e) {}
437
+ });
438
+
439
+ // Add files
440
+ entries.forEach(entry => {
441
+ const fullPath = path.join(basePath, entry);
442
+ const relativePath = parentPath ? `${parentPath}/${entry}` : entry;
443
+
444
+ try {
445
+ const stats = fs.statSync(fullPath);
446
+ if (stats.isFile()) {
447
+ tree.push({
448
+ name: entry,
449
+ path: fullPath,
450
+ relativePath,
451
+ type: 'file',
452
+ level,
453
+ size: stats.size
454
+ });
455
+ }
456
+ } catch (e) {}
457
+ });
458
+ } catch (error) {
459
+ console.log(chalk.red('Error reading directory: ' + error.message));
460
+ }
461
+
462
+ return tree;
463
+ }
464
+
465
+ // Helper function to get file icon based on extension
466
+ function getFileIcon(filename) {
467
+ const ext = path.extname(filename).toLowerCase();
468
+ const icons = {
469
+ '.pdf': '📄', '.doc': '📄', '.docx': '📄', '.txt': '📄',
470
+ '.js': '📜', '.ts': '📜', '.py': '📜', '.java': '📜', '.cpp': '📜', '.c': '📜',
471
+ '.html': '🌐', '.css': '🎨', '.scss': '🎨', '.less': '🎨',
472
+ '.json': '⚙️', '.xml': '⚙️', '.yml': '⚙️', '.yaml': '⚙️',
473
+ '.zip': '📦', '.rar': '📦', '.7z': '📦', '.tar': '📦',
474
+ '.jpg': '🖼️', '.jpeg': '🖼️', '.png': '🖼️', '.gif': '🖼️', '.svg': '🖼️',
475
+ '.mp4': '🎬', '.avi': '🎬', '.mov': '🎬', '.mkv': '🎬',
476
+ '.mp3': '🎵', '.wav': '🎵', '.flac': '🎵'
477
+ };
478
+ return icons[ext] || '📋';
479
+ }
480
+ // Helper function to build breadcrumb path
481
+ function buildBreadcrumb() {
482
+ const relativePath = path.relative(currentDir, currentPath);
483
+ if (!relativePath || relativePath === '.') {
484
+ return ''
485
+ }
486
+
487
+ const parts = relativePath.split(path.sep);
488
+ const breadcrumb = parts.map((part, index) => {
489
+ if (index === parts.length - 1) {
490
+ return chalk.white.bold(part);
491
+ }
492
+ return chalk.gray(part);
493
+ }).join(chalk.gray(' › '));
494
+
495
+ return chalk.yellow('📂 ') + breadcrumb;
496
+ }
497
+
498
+ // Helper function to display the file browser
499
+ function displayBrowser() {
500
+ // Header with keyboard controls at the top
501
+ console.log(chalk.cyan.bold('╭' + '─'.repeat(100) + '╮'));
502
+ console.log(chalk.cyan.bold('│') + chalk.white.bold(' Keyboard File Selector'.padEnd(98)) + chalk.cyan.bold('│')); console.log(chalk.cyan.bold('├' + '─'.repeat(100) + '┤'));
503
+ // Keyboard controls - compact format at top
504
+ const controls = [
505
+ '↑↓←→:Navigate', 'Space:Select', 'Enter:Open/Finish', 'Backspace:Up', 'a:All', 'c:Clear', 'Esc:Exit'
506
+ ];
507
+ const controlLine = controls.map(c => {
508
+ const [key, desc] = c.split(':');
509
+ return chalk.white(key) + chalk.gray(':') + chalk.gray(desc);
510
+ }).join(chalk.gray(' │ '));
511
+ console.log(chalk.cyan.bold('│ ') + controlLine.padEnd(98) + chalk.cyan.bold(' │'));
512
+ console.log(chalk.cyan.bold('╰' + '─'.repeat(100) + '╯'));
513
+
514
+ console.log();
515
+ // Breadcrumb path
516
+ console.log(buildBreadcrumb());
517
+
518
+ // Selected files count
519
+ if (selectedFiles.length > 0) {
520
+ const totalSize = selectedFiles.reduce((sum, file) => {
521
+ try {
522
+ return sum + fs.statSync(file).size;
523
+ } catch {
524
+ return sum;
525
+ }
526
+ }, 0);
527
+ console.log(chalk.green(`✅ Selected: ${selectedFiles.length} files (${(totalSize / 1024).toFixed(1)} KB) - Press Enter to finish`));
528
+ } else {
529
+ console.log(chalk.gray('💡 Use Space to select files, then press Enter to finish'));
530
+ }
531
+
532
+ console.log();
533
+
534
+ if (fileList.length === 0) {
535
+ console.log(chalk.yellow('📭 No files found in this directory.'));
536
+ return;
537
+ }
538
+
539
+ // Display files with tree-like structure
540
+ displayFileTree();
541
+ }
542
+ // Helper function to display files in a horizontal grid layout
543
+ function displayFileTree() {
544
+ const terminalWidth = process.stdout.columns || 80;
545
+ const maxDisplayItems = 50; // Show more items in grid view
546
+ const startIdx = Math.max(0, currentIndex - Math.floor(maxDisplayItems / 2));
547
+ const endIdx = Math.min(fileList.length, startIdx + maxDisplayItems);
548
+ const visibleItems = fileList.slice(startIdx, endIdx);
549
+
550
+ // Show scroll indicators
551
+ if (startIdx > 0) {
552
+ console.log(chalk.gray(` ⋮ (${startIdx} items above)`));
553
+ }
554
+
555
+ // Calculate item width and columns
556
+ const maxItemWidth = Math.max(...visibleItems.map(item => {
557
+ const name = path.basename(item.path);
558
+ return name.length + 4; // 2 for icon + space, 2 for padding
559
+ }));
560
+
561
+ const itemWidth = Math.min(Math.max(maxItemWidth, 15), 25); // Min 15, max 25 chars
562
+ const columnsPerRow = Math.floor((terminalWidth - 4) / itemWidth); // Leave 4 chars margin
563
+ const actualColumns = Math.max(1, columnsPerRow);
564
+
565
+ // Group items into rows
566
+ let currentRow = '';
567
+ let itemsInCurrentRow = 0;
568
+
569
+ visibleItems.forEach((item, index) => {
570
+ const actualIndex = startIdx + index;
571
+ const isSelected = selectedFiles.includes(item.path);
572
+ const isCurrent = actualIndex === currentIndex;
573
+
574
+ // Get icon and name
575
+ let icon = '';
576
+ if (item.type === 'parent' || item.type === 'directory') {
577
+ icon = '📁';
578
+ } else {
579
+ icon = getFileIcon(path.basename(item.path));
580
+ }
581
+
582
+ const name = item.name || path.basename(item.path);
583
+ const truncatedName = name.length > itemWidth - 4 ? name.slice(0, itemWidth - 7) + '...' : name;
584
+
585
+ // Build item display string
586
+ let itemDisplay = `${icon} ${truncatedName}`;
587
+
588
+ // Apply styling based on state
589
+ if (isCurrent) {
590
+ if (isSelected) {
591
+ itemDisplay = chalk.black.bgGreen(` ${itemDisplay}`.padEnd(itemWidth - 1));
592
+ } else if (item.type === 'parent') {
593
+ itemDisplay = chalk.white.bgBlue(` ${itemDisplay}`.padEnd(itemWidth - 1));
594
+ } else if (item.type === 'directory') {
595
+ itemDisplay = chalk.black.bgCyan(` ${itemDisplay}`.padEnd(itemWidth - 1));
596
+ } else {
597
+ itemDisplay = chalk.black.bgWhite(` ${itemDisplay}`.padEnd(itemWidth - 1));
598
+ }
599
+ } else {
600
+ if (isSelected) {
601
+ itemDisplay = chalk.green(`✓${itemDisplay}`.padEnd(itemWidth));
602
+ } else if (item.type === 'parent') {
603
+ itemDisplay = chalk.blue(` ${itemDisplay}`.padEnd(itemWidth));
604
+ } else if (item.type === 'directory') {
605
+ itemDisplay = chalk.cyan(` ${itemDisplay}`.padEnd(itemWidth));
606
+ } else {
607
+ itemDisplay = chalk.white(` ${itemDisplay}`.padEnd(itemWidth));
608
+ }
609
+ }
610
+
611
+ // Add to current row
612
+ currentRow += itemDisplay;
613
+ itemsInCurrentRow++;
614
+
615
+ // Check if we need to start a new row
616
+ if (itemsInCurrentRow >= actualColumns || index === visibleItems.length - 1) {
617
+ console.log(currentRow);
618
+ currentRow = '';
619
+ itemsInCurrentRow = 0;
620
+ }
621
+ });
622
+
623
+ // Show scroll indicators
624
+ if (endIdx < fileList.length) {
625
+ console.log(chalk.gray(` ⋮ (${fileList.length - endIdx} items below)`));
626
+ }
627
+
628
+ console.log();
629
+
630
+ // Show current position and navigation info
631
+ if (fileList.length > maxDisplayItems) {
632
+ console.log(chalk.gray(`Showing ${startIdx + 1}-${endIdx} of ${fileList.length} items | Current: ${currentIndex + 1}`));
633
+ } else {
634
+ console.log(chalk.gray(`${fileList.length} items | Current: ${currentIndex + 1}`));
635
+ }
636
+
637
+ // Show grid info
638
+ console.log(chalk.gray(`Grid: ${actualColumns} columns × ${itemWidth} chars | Terminal width: ${terminalWidth}`));
639
+ }
640
+ // Helper function to refresh file list for current directory
641
+ function refreshFileList() {
642
+ fileList = [];
643
+
644
+ try {
645
+ // Add parent directory option if not at root
646
+ if (currentPath !== currentDir) {
647
+ fileList.push({
648
+ type: 'parent',
649
+ path: path.dirname(currentPath),
650
+ name: '..'
651
+ });
652
+ }
653
+
654
+ // Read current directory
655
+ const entries = fs.readdirSync(currentPath).sort();
656
+
657
+ // Add directories first
658
+ entries.forEach(entry => {
659
+ const fullPath = path.join(currentPath, entry);
660
+ const stat = fs.statSync(fullPath);
661
+
662
+ if (stat.isDirectory()) {
663
+ fileList.push({
664
+ type: 'directory',
665
+ path: fullPath,
666
+ name: entry
667
+ });
668
+ }
669
+ });
670
+
671
+ // Add files
672
+ entries.forEach(entry => {
673
+ const fullPath = path.join(currentPath, entry);
674
+ const stat = fs.statSync(fullPath);
675
+
676
+ if (stat.isFile()) {
677
+ fileList.push({
678
+ type: 'file',
679
+ path: fullPath,
680
+ name: entry,
681
+ size: stat.size
682
+ });
683
+ }
684
+ });
685
+
686
+ // Ensure currentIndex is within bounds
687
+ if (currentIndex >= fileList.length) {
688
+ currentIndex = Math.max(0, fileList.length - 1);
689
+ }
690
+
691
+ } catch (error) {
692
+ console.error('Error reading directory:', error.message);
693
+ fileList = [];
694
+ }
695
+ }
696
+ // Main keyboard event handler
697
+ function handleKeyInput(key) {
698
+ // Calculate grid dimensions for navigation
699
+ const terminalWidth = process.stdout.columns || 80;
700
+ const maxItemWidth = Math.max(...fileList.map(item => {
701
+ const name = path.basename(item.path);
702
+ return name.length + 4;
703
+ }));
704
+ const itemWidth = Math.min(Math.max(maxItemWidth, 15), 25);
705
+ const columnsPerRow = Math.max(1, Math.floor((terminalWidth - 4) / itemWidth));
706
+
707
+ switch (key) {
708
+ case KEYS.UP:
709
+ // Move up by one row (subtract columns)
710
+ const newUpIndex = currentIndex - columnsPerRow;
711
+ if (newUpIndex >= 0) {
712
+ currentIndex = newUpIndex;
713
+ displayBrowser();
714
+ }
715
+ break;
716
+
717
+ case KEYS.DOWN:
718
+ // Move down by one row (add columns)
719
+ const newDownIndex = currentIndex + columnsPerRow;
720
+ if (newDownIndex < fileList.length) {
721
+ currentIndex = newDownIndex;
722
+ displayBrowser();
723
+ }
724
+ break;
725
+
726
+ case KEYS.LEFT:
727
+ // Move left by one column
728
+ if (currentIndex > 0) {
729
+ currentIndex--;
730
+ displayBrowser();
731
+ }
732
+ break;
733
+
734
+ case KEYS.RIGHT:
735
+ // Move right by one column
736
+ if (currentIndex < fileList.length - 1) {
737
+ currentIndex++;
738
+ displayBrowser();
739
+ }
740
+ break;
741
+
742
+ case KEYS.SPACE:
743
+ if (fileList.length > 0) {
744
+ const item = fileList[currentIndex];
745
+ if (item.type === 'file') {
746
+ const index = selectedFiles.indexOf(item.path);
747
+ if (index === -1) {
748
+ selectedFiles.push(item.path);
749
+ } else {
750
+ selectedFiles.splice(index, 1);
751
+ }
752
+ displayBrowser();
753
+ }
754
+ }
755
+ break;
756
+ case KEYS.ENTER:
757
+ if (fileList.length > 0) {
758
+ const item = fileList[currentIndex];
759
+ if (item.type === 'parent' || item.type === 'directory') {
760
+ currentPath = item.path;
761
+ currentIndex = 0;
762
+ refreshFileList();
763
+ displayBrowser();
764
+ } else {
765
+ // When Enter is pressed on a file, finish selection if files are selected
766
+ if (selectedFiles.length > 0) {
767
+ isNavigating = false;
768
+ }
769
+ }
770
+ } else {
771
+ // Finish selection when directory is empty and Enter is pressed (if files selected)
772
+ if (selectedFiles.length > 0) {
773
+ isNavigating = false;
774
+ }
775
+ }
776
+ break;
777
+
778
+ case KEYS.BACKSPACE:
779
+ if (currentPath !== currentDir) {
780
+ currentPath = path.dirname(currentPath);
781
+ currentIndex = 0;
782
+ refreshFileList();
783
+ displayBrowser();
784
+ }
785
+ break;
786
+
787
+ case 'a':
788
+ // Select all files in current directory
789
+ let addedCount = 0;
790
+ fileList.forEach(item => {
791
+ if (item.type === 'file' && !selectedFiles.includes(item.path)) {
792
+ selectedFiles.push(item.path);
793
+ addedCount++;
794
+ }
795
+ });
796
+ if (addedCount > 0) {
797
+ displayBrowser();
798
+ }
799
+ break;
800
+
801
+ case 'c':
802
+ // Clear all selections
803
+ selectedFiles.length = 0;
804
+ displayBrowser();
805
+ break;
806
+
807
+ case KEYS.ESCAPE:
808
+ case '\u001b': // ESC key
809
+ isNavigating = false;
810
+ break;
811
+
812
+ default:
813
+ // Ignore other keys
814
+ break;
815
+ }
816
+ }
817
+
818
+ // Initialize and start navigation
819
+ return new Promise((resolve) => {
820
+ refreshFileList();
821
+ displayBrowser();
822
+
823
+ process.stdin.on('data', (key) => {
824
+ if (!isNavigating) {
825
+ return;
826
+ }
827
+
828
+ const keyStr = key.toString();
829
+ handleKeyInput(keyStr);
830
+
831
+ if (!isNavigating) {
832
+ // Cleanup
833
+ process.stdin.removeAllListeners('data');
834
+ process.stdin.setRawMode(false);
835
+ process.stdin.pause();
836
+
837
+ // Display completion summary
838
+ if (selectedFiles.length > 0) {
839
+ console.log(chalk.green.bold('✅ File Selection Complete!'));
840
+ console.log(chalk.cyan('-'.repeat(50)));
841
+
842
+ const totalSize = selectedFiles.reduce((sum, file) => {
843
+ try {
844
+ return sum + fs.statSync(file).size;
845
+ } catch {
846
+ return sum;
847
+ }
848
+ }, 0);
849
+
850
+ console.log(chalk.white(`Selected ${selectedFiles.length} files (${(totalSize / 1024).toFixed(1)} KB total):`));
851
+ selectedFiles.forEach((file, index) => {
852
+ try {
853
+ const stats = fs.statSync(file);
854
+ const size = (stats.size / 1024).toFixed(1) + ' KB';
855
+ console.log(pad(chalk.green(`${index + 1}.`), 5) + pad(path.basename(file), 35) + chalk.gray(size));
856
+ } catch (e) {
857
+ console.log(pad(chalk.red(`${index + 1}.`), 5) + chalk.red(path.basename(file) + ' (Error reading file)'));
858
+ }
859
+ });
860
+ console.log(chalk.cyan('-'.repeat(50)));
861
+ } else {
862
+ console.log(chalk.yellow('No files selected.'));
863
+ }
864
+
865
+ resolve(selectedFiles);
866
+ }
867
+ });
868
+ });
869
+ }
362
870
 
363
871
  export {
364
872
  createReadlineInterface,
@@ -367,6 +875,7 @@ export {
367
875
  askConfirmation,
368
876
  selectFromList,
369
877
  selectFilesImproved,
878
+ selectFilesKeyboard,
370
879
  getFilesMatchingWildcard,
371
880
  getSubfoldersRecursive,
372
881
  pad
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvaslms-cli",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "A command line tool for interacting with Canvas LMS API",
5
5
  "keywords": [
6
6
  "canvas",
@@ -38,8 +38,8 @@
38
38
  "CHANGELOG.md"
39
39
  ],
40
40
  "scripts": {
41
- "start": "node src/index.js",
42
- "dev": "node src/index.js",
41
+ "start": "bun src/index.js",
42
+ "dev": "bun src/index.js",
43
43
  "test": "echo \"Error: no test specified\" && exit 1",
44
44
  "lint": "echo \"Add linting here\"",
45
45
  "format": "echo \"Add formatting here\"",