@lovelybunch/cli 1.0.75-alpha.0 → 1.0.75-alpha.10

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.
@@ -3,14 +3,80 @@ import chalk from 'chalk';
3
3
  import ora from 'ora';
4
4
  import fetch, { FormData, fileFromSync } from 'node-fetch';
5
5
  import { promises as fs } from 'fs';
6
- import { writeFileSync, unlinkSync, mkdtempSync } from 'fs';
6
+ import { writeFileSync, unlinkSync, mkdtempSync, existsSync } from 'fs';
7
7
  import path from 'path';
8
8
  import { tmpdir } from 'os';
9
+ import inquirer from 'inquirer';
9
10
  // ============================================================================
10
11
  // Shared Utilities
11
12
  // ============================================================================
12
13
  const DEFAULT_API_BASE = 'http://localhost';
13
14
  const GENERATION_TIMEOUT = 300_000; // 5 minutes
15
+ /**
16
+ * Determine MIME type from file extension
17
+ */
18
+ function getMimeType(filePath) {
19
+ const ext = path.extname(filePath).toLowerCase();
20
+ const mimeTypes = {
21
+ // Images
22
+ '.png': 'image/png',
23
+ '.jpg': 'image/jpeg',
24
+ '.jpeg': 'image/jpeg',
25
+ '.gif': 'image/gif',
26
+ '.webp': 'image/webp',
27
+ '.svg': 'image/svg+xml',
28
+ '.bmp': 'image/bmp',
29
+ '.ico': 'image/x-icon',
30
+ '.tiff': 'image/tiff',
31
+ '.tif': 'image/tiff',
32
+ '.avif': 'image/avif',
33
+ // Audio
34
+ '.mp3': 'audio/mpeg',
35
+ '.wav': 'audio/wav',
36
+ '.ogg': 'audio/ogg',
37
+ '.flac': 'audio/flac',
38
+ '.aac': 'audio/aac',
39
+ '.m4a': 'audio/mp4',
40
+ '.wma': 'audio/x-ms-wma',
41
+ '.opus': 'audio/opus',
42
+ // Video
43
+ '.mp4': 'video/mp4',
44
+ '.webm': 'video/webm',
45
+ '.mov': 'video/quicktime',
46
+ '.avi': 'video/x-msvideo',
47
+ '.mkv': 'video/x-matroska',
48
+ '.flv': 'video/x-flv',
49
+ '.wmv': 'video/x-ms-wmv',
50
+ '.m4v': 'video/mp4',
51
+ // Documents
52
+ '.pdf': 'application/pdf',
53
+ '.json': 'application/json',
54
+ '.xml': 'application/xml',
55
+ '.csv': 'text/csv',
56
+ '.txt': 'text/plain',
57
+ '.md': 'text/markdown',
58
+ '.html': 'text/html',
59
+ '.css': 'text/css',
60
+ '.js': 'text/javascript',
61
+ '.ts': 'text/typescript',
62
+ // Archives
63
+ '.zip': 'application/zip',
64
+ '.gz': 'application/gzip',
65
+ '.tar': 'application/x-tar',
66
+ };
67
+ return mimeTypes[ext] || 'application/octet-stream';
68
+ }
69
+ /**
70
+ * Format file size in bytes to human-readable string
71
+ */
72
+ function formatFileSize(bytes) {
73
+ if (bytes === 0)
74
+ return '0 B';
75
+ const k = 1024;
76
+ const sizes = ['B', 'KB', 'MB', 'GB'];
77
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
78
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
79
+ }
14
80
  /**
15
81
  * Check if the Coconut server is running
16
82
  */
@@ -557,12 +623,515 @@ configured via "nut config set replicate <token>".
557
623
  }
558
624
  });
559
625
  // ============================================================================
626
+ // List Resources Command
627
+ // ============================================================================
628
+ const listResourcesCommand = new Command('list')
629
+ .description('List all resources')
630
+ .alias('ls')
631
+ .option('-s, --search <query>', 'Filter resources by name, description, or tags')
632
+ .option('--type <type>', 'Filter by media type prefix (e.g. image, audio, video)')
633
+ .option('--json', 'Output as JSON')
634
+ .option('--api <url>', 'API server URL', DEFAULT_API_BASE)
635
+ .addHelpText('after', `
636
+ Examples:
637
+ $ nut resource list
638
+ $ nut resource ls --search "logo"
639
+ $ nut resource list --type image --json
640
+ `)
641
+ .action(async (options) => {
642
+ const isHealthy = await checkServerHealth(options.api);
643
+ if (!isHealthy) {
644
+ console.error(chalk.red('Error: Coconut server is not running.'));
645
+ console.error(chalk.yellow('Start the server with "nut serve" or specify a different URL with --api'));
646
+ process.exit(1);
647
+ }
648
+ const spinner = ora('Loading resources...').start();
649
+ try {
650
+ const response = await fetch(`${options.api}/api/v1/resources`);
651
+ const result = await response.json();
652
+ if (!response.ok || !result.success || !result.data) {
653
+ spinner.fail('Failed to load resources');
654
+ process.exit(1);
655
+ }
656
+ let resources = result.data;
657
+ // Apply type filter
658
+ if (options.type) {
659
+ const typePrefix = options.type.toLowerCase();
660
+ resources = resources.filter(r => r.type.toLowerCase().startsWith(typePrefix));
661
+ }
662
+ // Apply search filter (client-side, matching frontend behavior)
663
+ if (options.search) {
664
+ const query = options.search.toLowerCase();
665
+ resources = resources.filter(r => r.name.toLowerCase().includes(query) ||
666
+ r.metadata.description?.toLowerCase().includes(query) ||
667
+ r.metadata.tags?.some(tag => tag.toLowerCase().includes(query)));
668
+ }
669
+ spinner.stop();
670
+ if (options.json) {
671
+ console.log(JSON.stringify(resources, null, 2));
672
+ return;
673
+ }
674
+ if (resources.length === 0) {
675
+ if (options.search || options.type) {
676
+ console.log(chalk.yellow('No resources found matching your filters.'));
677
+ }
678
+ else {
679
+ console.log(chalk.yellow('No resources found.'));
680
+ console.log(chalk.gray('Use "nut resource add <file>" to upload one.'));
681
+ }
682
+ return;
683
+ }
684
+ console.log('\n' + chalk.bold.underline(`Resources (${resources.length})`));
685
+ console.log();
686
+ for (const r of resources) {
687
+ console.log(chalk.cyan('●') + ' ' + chalk.bold(r.name) + chalk.gray(` (${r.id})`));
688
+ console.log(' ' + chalk.gray(`Type: ${r.type} Size: ${formatFileSize(r.size)} Uploaded: ${new Date(r.uploadedAt).toLocaleDateString()}`));
689
+ if (r.metadata.description) {
690
+ const desc = r.metadata.description.length > 80
691
+ ? r.metadata.description.slice(0, 80).trimEnd() + '...'
692
+ : r.metadata.description;
693
+ console.log(' ' + chalk.gray(`Description: ${desc}`));
694
+ }
695
+ if (r.metadata.tags && r.metadata.tags.length > 0) {
696
+ console.log(' ' + chalk.gray(`Tags: ${r.metadata.tags.join(', ')}`));
697
+ }
698
+ console.log();
699
+ }
700
+ }
701
+ catch (error) {
702
+ spinner.fail('Failed to load resources');
703
+ console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error'));
704
+ process.exit(1);
705
+ }
706
+ });
707
+ // ============================================================================
708
+ // Get Resource Command
709
+ // ============================================================================
710
+ const getResourceCommand = new Command('get')
711
+ .description('Get details for a specific resource')
712
+ .alias('show')
713
+ .argument('<id>', 'Resource ID')
714
+ .option('--json', 'Output as JSON')
715
+ .option('-o, --output <path>', 'Download resource file to local path')
716
+ .option('--api <url>', 'API server URL', DEFAULT_API_BASE)
717
+ .addHelpText('after', `
718
+ Examples:
719
+ $ nut resource get res-1234567890-abc123def
720
+ $ nut resource show res-1234567890-abc123def --json
721
+ $ nut resource get res-1234567890-abc123def -o ./downloaded-file.png
722
+ `)
723
+ .action(async (id, options) => {
724
+ const isHealthy = await checkServerHealth(options.api);
725
+ if (!isHealthy) {
726
+ console.error(chalk.red('Error: Coconut server is not running.'));
727
+ console.error(chalk.yellow('Start the server with "nut serve" or specify a different URL with --api'));
728
+ process.exit(1);
729
+ }
730
+ const spinner = ora('Loading resource...').start();
731
+ try {
732
+ // Fetch all resources and find by ID (the list endpoint returns full metadata)
733
+ const response = await fetch(`${options.api}/api/v1/resources`);
734
+ const result = await response.json();
735
+ if (!response.ok || !result.success || !result.data) {
736
+ spinner.fail('Failed to load resources');
737
+ process.exit(1);
738
+ }
739
+ const resource = result.data.find(r => r.id === id);
740
+ if (!resource) {
741
+ spinner.fail(`Resource '${id}' not found`);
742
+ process.exit(1);
743
+ }
744
+ spinner.stop();
745
+ // Handle download
746
+ if (options.output) {
747
+ const downloadSpinner = ora(`Downloading to ${options.output}...`).start();
748
+ try {
749
+ await downloadToFile(`${options.api}/api/v1/resources/${id}?download=true`, options.output);
750
+ downloadSpinner.succeed(`Downloaded to ${chalk.cyan(path.resolve(options.output))}`);
751
+ }
752
+ catch (error) {
753
+ downloadSpinner.fail('Failed to download');
754
+ console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error'));
755
+ process.exit(1);
756
+ }
757
+ return;
758
+ }
759
+ if (options.json) {
760
+ console.log(JSON.stringify(resource, null, 2));
761
+ return;
762
+ }
763
+ // Display formatted details
764
+ console.log();
765
+ console.log(chalk.bold.cyan(resource.name));
766
+ console.log();
767
+ console.log(` ${chalk.gray('ID:')} ${resource.id}`);
768
+ console.log(` ${chalk.gray('Type:')} ${resource.type}`);
769
+ console.log(` ${chalk.gray('Size:')} ${formatFileSize(resource.size)}`);
770
+ console.log(` ${chalk.gray('Uploaded:')} ${new Date(resource.uploadedAt).toLocaleString()}`);
771
+ if (resource.metadata.tags && resource.metadata.tags.length > 0) {
772
+ console.log(` ${chalk.gray('Tags:')} ${resource.metadata.tags.join(', ')}`);
773
+ }
774
+ if (resource.metadata.description) {
775
+ console.log(` ${chalk.gray('Description:')} ${resource.metadata.description}`);
776
+ }
777
+ if (resource.metadata.generationPrompt) {
778
+ console.log(` ${chalk.gray('Prompt:')} ${resource.metadata.generationPrompt}`);
779
+ }
780
+ if (resource.metadata.generationModel) {
781
+ console.log(` ${chalk.gray('Model:')} ${resource.metadata.generationModel}`);
782
+ }
783
+ console.log();
784
+ console.log(chalk.bold('Paths:'));
785
+ console.log(` ${chalk.gray('File:')} .nut/resources/files/${resource.path}`);
786
+ if (resource.thumbnailPath) {
787
+ console.log(` ${chalk.gray('Thumbnail:')} .nut/resources/thumbnails/${resource.thumbnailPath}`);
788
+ }
789
+ console.log(` ${chalk.gray('Metadata:')} .nut/resources/metadata/${resource.id}.json`);
790
+ console.log();
791
+ }
792
+ catch (error) {
793
+ spinner.fail('Failed to load resource');
794
+ console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error'));
795
+ process.exit(1);
796
+ }
797
+ });
798
+ // ============================================================================
799
+ // Add Resource Command
800
+ // ============================================================================
801
+ /**
802
+ * Check if a string looks like a URL
803
+ */
804
+ function isUrl(input) {
805
+ return /^https?:\/\//i.test(input);
806
+ }
807
+ /**
808
+ * Download a URL to a temp file and return the temp path, filename, and MIME type.
809
+ * Caller is responsible for cleaning up the temp file/directory.
810
+ */
811
+ async function downloadUrlToTemp(url) {
812
+ const response = await fetch(url);
813
+ if (!response.ok) {
814
+ throw new Error(`Failed to download ${url}: ${response.statusText}`);
815
+ }
816
+ const buffer = Buffer.from(await response.arrayBuffer());
817
+ const contentType = response.headers.get('content-type') || '';
818
+ // Derive a filename from the URL path, falling back to a timestamp-based name
819
+ const urlPath = new URL(url).pathname;
820
+ let fileName = path.basename(urlPath);
821
+ if (!fileName || fileName === '/' || !path.extname(fileName)) {
822
+ // Try to infer extension from content-type
823
+ const extMap = {
824
+ 'image/png': '.png',
825
+ 'image/jpeg': '.jpg',
826
+ 'image/webp': '.webp',
827
+ 'image/gif': '.gif',
828
+ 'image/svg+xml': '.svg',
829
+ 'audio/mpeg': '.mp3',
830
+ 'audio/wav': '.wav',
831
+ 'audio/ogg': '.ogg',
832
+ 'video/mp4': '.mp4',
833
+ 'video/webm': '.webm',
834
+ 'application/pdf': '.pdf',
835
+ 'application/json': '.json',
836
+ };
837
+ const ext = Object.entries(extMap).find(([ct]) => contentType.includes(ct))?.[1] || '.bin';
838
+ fileName = `downloaded-${Date.now()}${ext}`;
839
+ }
840
+ const mimeType = contentType.split(';')[0].trim() || getMimeType(fileName);
841
+ const tempDir = mkdtempSync(path.join(tmpdir(), 'coconut-add-'));
842
+ const tempFilePath = path.join(tempDir, fileName);
843
+ writeFileSync(tempFilePath, buffer);
844
+ return { tempDir, tempFilePath, fileName, mimeType };
845
+ }
846
+ const addResourceCommand = new Command('add')
847
+ .description('Upload a file or URL to resources')
848
+ .alias('upload')
849
+ .argument('<file...>', 'File path(s) or URL(s) to upload')
850
+ .option('-t, --tags <tags>', 'Comma-separated tags')
851
+ .option('-d, --description <text>', 'Description of the resource')
852
+ .option('--json', 'Output as JSON')
853
+ .option('--api <url>', 'API server URL', DEFAULT_API_BASE)
854
+ .addHelpText('after', `
855
+ Examples:
856
+ $ nut resource add ./screenshot.png
857
+ $ nut resource upload ./logo.svg -t "branding, logo" -d "Company logo"
858
+ $ nut resource add ./image1.png ./image2.png -t "batch"
859
+ $ nut resource add https://example.com/photo.png
860
+ $ nut resource add ./data.json --json
861
+ `)
862
+ .action(async (files, options) => {
863
+ const isHealthy = await checkServerHealth(options.api);
864
+ if (!isHealthy) {
865
+ console.error(chalk.red('Error: Coconut server is not running.'));
866
+ console.error(chalk.yellow('Start the server with "nut serve" or specify a different URL with --api'));
867
+ process.exit(1);
868
+ }
869
+ const results = [];
870
+ for (const filePath of files) {
871
+ let localPath;
872
+ let fileName;
873
+ let mimeType;
874
+ let tempDir = null;
875
+ if (isUrl(filePath)) {
876
+ // ── URL: download to temp file first ──
877
+ const spinner = ora(`Downloading ${filePath}...`).start();
878
+ try {
879
+ const downloaded = await downloadUrlToTemp(filePath);
880
+ tempDir = downloaded.tempDir;
881
+ localPath = downloaded.tempFilePath;
882
+ fileName = downloaded.fileName;
883
+ mimeType = downloaded.mimeType;
884
+ spinner.succeed(`Downloaded ${chalk.cyan(fileName)}`);
885
+ }
886
+ catch (error) {
887
+ spinner.fail('Failed to download URL');
888
+ console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error'));
889
+ process.exit(1);
890
+ }
891
+ }
892
+ else {
893
+ // ── Local file ──
894
+ localPath = path.resolve(filePath);
895
+ if (!existsSync(localPath)) {
896
+ console.error(chalk.red(`File not found: ${localPath}`));
897
+ process.exit(1);
898
+ }
899
+ fileName = path.basename(filePath);
900
+ mimeType = getMimeType(localPath);
901
+ }
902
+ const uploadSpinner = ora(`Uploading ${fileName}...`).start();
903
+ try {
904
+ const formData = new FormData();
905
+ const file = fileFromSync(localPath, mimeType);
906
+ formData.set('file', file, fileName);
907
+ if (options.tags) {
908
+ formData.set('tags', options.tags);
909
+ }
910
+ if (options.description) {
911
+ formData.set('description', options.description);
912
+ }
913
+ const response = await fetch(`${options.api}/api/v1/resources`, {
914
+ method: 'POST',
915
+ body: formData,
916
+ });
917
+ const result = await response.json();
918
+ if (!response.ok || !result.success || !result.data) {
919
+ const errorMessage = result.error?.message || 'Upload failed';
920
+ uploadSpinner.fail(`Failed to upload ${fileName}`);
921
+ console.error(chalk.red(errorMessage));
922
+ process.exit(1);
923
+ }
924
+ const resource = result.data;
925
+ results.push(resource);
926
+ uploadSpinner.succeed(`Uploaded ${chalk.cyan(resource.name)}`);
927
+ if (!options.json) {
928
+ console.log();
929
+ console.log(` ${chalk.gray('ID:')} ${resource.id}`);
930
+ console.log(` ${chalk.gray('Name:')} ${resource.name}`);
931
+ console.log(` ${chalk.gray('Type:')} ${resource.type}`);
932
+ console.log(` ${chalk.gray('Size:')} ${formatFileSize(resource.size)}`);
933
+ if (resource.metadata.tags && resource.metadata.tags.length > 0) {
934
+ console.log(` ${chalk.gray('Tags:')} ${resource.metadata.tags.join(', ')}`);
935
+ }
936
+ if (resource.metadata.description) {
937
+ console.log(` ${chalk.gray('Desc:')} ${resource.metadata.description}`);
938
+ }
939
+ console.log(` ${chalk.gray('File:')} .nut/resources/files/${resource.path}`);
940
+ if (resource.thumbnailPath) {
941
+ console.log(` ${chalk.gray('Thumbnail:')} .nut/resources/thumbnails/${resource.thumbnailPath}`);
942
+ }
943
+ console.log(` ${chalk.gray('Metadata:')} .nut/resources/metadata/${resource.id}.json`);
944
+ console.log();
945
+ }
946
+ }
947
+ catch (error) {
948
+ uploadSpinner.fail(`Failed to upload ${fileName}`);
949
+ console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error'));
950
+ process.exit(1);
951
+ }
952
+ finally {
953
+ // Clean up temp directory if we downloaded from a URL
954
+ if (tempDir) {
955
+ try {
956
+ unlinkSync(localPath);
957
+ fs.rmdir(tempDir).catch(() => { });
958
+ }
959
+ catch {
960
+ // Ignore cleanup errors
961
+ }
962
+ }
963
+ }
964
+ }
965
+ if (options.json) {
966
+ console.log(JSON.stringify(results.length === 1 ? results[0] : results, null, 2));
967
+ }
968
+ });
969
+ // ============================================================================
970
+ // Update Resource Command
971
+ // ============================================================================
972
+ const updateResourceCommand = new Command('update')
973
+ .description('Update resource metadata (tags, description)')
974
+ .argument('<id>', 'Resource ID to update')
975
+ .option('-t, --tags <tags>', 'Comma-separated tags (replaces existing)')
976
+ .option('-d, --description <text>', 'Description (replaces existing)')
977
+ .option('--json', 'Output as JSON')
978
+ .option('--api <url>', 'API server URL', DEFAULT_API_BASE)
979
+ .addHelpText('after', `
980
+ Examples:
981
+ $ nut resource update res-123 -t "logo, branding"
982
+ $ nut resource update res-123 -d "Updated description"
983
+ $ nut resource update res-123 -t "new-tag" -d "New desc" --json
984
+ `)
985
+ .action(async (id, options) => {
986
+ if (!options.tags && !options.description) {
987
+ console.error(chalk.red('Error: At least one of --tags or --description must be provided.'));
988
+ process.exit(1);
989
+ }
990
+ const isHealthy = await checkServerHealth(options.api);
991
+ if (!isHealthy) {
992
+ console.error(chalk.red('Error: Coconut server is not running.'));
993
+ console.error(chalk.yellow('Start the server with "nut serve" or specify a different URL with --api'));
994
+ process.exit(1);
995
+ }
996
+ const spinner = ora('Updating resource...').start();
997
+ try {
998
+ const metadata = {};
999
+ if (options.tags !== undefined) {
1000
+ metadata.tags = options.tags.split(',').map(t => t.trim());
1001
+ }
1002
+ if (options.description !== undefined) {
1003
+ metadata.description = options.description;
1004
+ }
1005
+ const response = await fetch(`${options.api}/api/v1/resources/${id}`, {
1006
+ method: 'PUT',
1007
+ headers: { 'Content-Type': 'application/json' },
1008
+ body: JSON.stringify({ metadata }),
1009
+ });
1010
+ const result = await response.json();
1011
+ if (!response.ok || !result.success || !result.data) {
1012
+ const errorMessage = result.error?.message || 'Update failed';
1013
+ spinner.fail('Failed to update resource');
1014
+ console.error(chalk.red(errorMessage));
1015
+ process.exit(1);
1016
+ }
1017
+ const resource = result.data;
1018
+ spinner.succeed(`Updated ${chalk.cyan(resource.name)}`);
1019
+ if (options.json) {
1020
+ console.log(JSON.stringify(resource, null, 2));
1021
+ }
1022
+ else {
1023
+ console.log();
1024
+ console.log(` ${chalk.gray('ID:')} ${resource.id}`);
1025
+ console.log(` ${chalk.gray('Name:')} ${resource.name}`);
1026
+ if (resource.metadata.tags && resource.metadata.tags.length > 0) {
1027
+ console.log(` ${chalk.gray('Tags:')} ${resource.metadata.tags.join(', ')}`);
1028
+ }
1029
+ if (resource.metadata.description) {
1030
+ console.log(` ${chalk.gray('Description:')} ${resource.metadata.description}`);
1031
+ }
1032
+ console.log();
1033
+ }
1034
+ }
1035
+ catch (error) {
1036
+ spinner.fail('Failed to update resource');
1037
+ console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error'));
1038
+ process.exit(1);
1039
+ }
1040
+ });
1041
+ // ============================================================================
1042
+ // Delete Resource Command
1043
+ // ============================================================================
1044
+ const deleteResourceCommand = new Command('delete')
1045
+ .description('Delete a resource')
1046
+ .alias('rm')
1047
+ .argument('<id>', 'Resource ID to delete')
1048
+ .option('-f, --force', 'Skip confirmation prompt')
1049
+ .option('--json', 'Output as JSON')
1050
+ .option('--api <url>', 'API server URL', DEFAULT_API_BASE)
1051
+ .addHelpText('after', `
1052
+ Examples:
1053
+ $ nut resource delete res-1234567890-abc123def
1054
+ $ nut resource rm res-1234567890-abc123def --force
1055
+ $ nut resource delete res-123 --json
1056
+ `)
1057
+ .action(async (id, options) => {
1058
+ const isHealthy = await checkServerHealth(options.api);
1059
+ if (!isHealthy) {
1060
+ console.error(chalk.red('Error: Coconut server is not running.'));
1061
+ console.error(chalk.yellow('Start the server with "nut serve" or specify a different URL with --api'));
1062
+ process.exit(1);
1063
+ }
1064
+ // Fetch resource details first to show name in confirmation
1065
+ const listSpinner = ora('Loading resource...').start();
1066
+ let resourceName = id;
1067
+ try {
1068
+ const listResponse = await fetch(`${options.api}/api/v1/resources`);
1069
+ const listResult = await listResponse.json();
1070
+ if (listResult.success && listResult.data) {
1071
+ const found = listResult.data.find(r => r.id === id);
1072
+ if (found) {
1073
+ resourceName = found.name;
1074
+ }
1075
+ else {
1076
+ listSpinner.fail(`Resource '${id}' not found`);
1077
+ process.exit(1);
1078
+ }
1079
+ }
1080
+ listSpinner.stop();
1081
+ }
1082
+ catch {
1083
+ listSpinner.stop();
1084
+ // Proceed anyway; the delete endpoint will 404 if not found
1085
+ }
1086
+ // Confirm deletion
1087
+ if (!options.force) {
1088
+ const { confirm } = await inquirer.prompt([{
1089
+ type: 'confirm',
1090
+ name: 'confirm',
1091
+ message: `Delete resource "${resourceName}" (${id})?`,
1092
+ default: false,
1093
+ }]);
1094
+ if (!confirm) {
1095
+ console.log(chalk.yellow('Operation cancelled.'));
1096
+ return;
1097
+ }
1098
+ }
1099
+ const spinner = ora('Deleting resource...').start();
1100
+ try {
1101
+ const response = await fetch(`${options.api}/api/v1/resources/${id}`, {
1102
+ method: 'DELETE',
1103
+ });
1104
+ const result = await response.json();
1105
+ if (!response.ok || !result.success) {
1106
+ const errorMessage = result.error?.message || 'Delete failed';
1107
+ spinner.fail('Failed to delete resource');
1108
+ console.error(chalk.red(errorMessage));
1109
+ process.exit(1);
1110
+ }
1111
+ spinner.succeed(`Deleted ${chalk.cyan(resourceName)}`);
1112
+ if (options.json) {
1113
+ console.log(JSON.stringify({ success: true, id, name: resourceName }, null, 2));
1114
+ }
1115
+ }
1116
+ catch (error) {
1117
+ spinner.fail('Failed to delete resource');
1118
+ console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error'));
1119
+ process.exit(1);
1120
+ }
1121
+ });
1122
+ // ============================================================================
560
1123
  // Parent Resource Command
561
1124
  // ============================================================================
562
1125
  const resource = new Command('resource')
563
1126
  .description('Manage and generate resources (images, audio, video)')
564
1127
  .alias('resources');
565
- // Add subcommands
1128
+ // Add CRUD subcommands
1129
+ resource.addCommand(listResourcesCommand);
1130
+ resource.addCommand(getResourceCommand);
1131
+ resource.addCommand(addResourceCommand);
1132
+ resource.addCommand(updateResourceCommand);
1133
+ resource.addCommand(deleteResourceCommand);
1134
+ // Add generation subcommands
566
1135
  resource.addCommand(generateImageCommand);
567
1136
  resource.addCommand(generateAudioCommand);
568
1137
  resource.addCommand(generateVideoCommand);