reallink-cli 0.1.11 → 0.1.13

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/rust/src/main.rs CHANGED
@@ -1,9 +1,10 @@
1
1
  use anyhow::{anyhow, Context, Result};
2
- use clap::{ArgAction, Args, Parser, Subcommand};
2
+ use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
3
3
  use reqwest::{Method, StatusCode};
4
4
  use serde::{Deserialize, Serialize};
5
+ use sha2::Digest;
5
6
  use std::fs;
6
- use std::io::{self, Write};
7
+ use std::io::{self, Read, Write};
7
8
  use std::path::{Path, PathBuf};
8
9
  use std::process::Command;
9
10
  use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -11,10 +12,20 @@ use tokio::fs as tokio_fs;
11
12
  use tokio::io::AsyncWriteExt;
12
13
  use tokio::time::sleep;
13
14
 
15
+ mod unreal;
16
+ mod generated;
17
+ mod logs;
18
+ use unreal::{
19
+ LinkDoctorArgs, LinkOpenArgs, LinkPathsArgs, LinkPluginInstallArgs, LinkPluginListArgs,
20
+ LinkRemoveArgs, LinkRunArgs, LinkUnrealArgs, LinkUseArgs, PluginIndexFile, UnrealLinkRecord,
21
+ UnrealLinksConfig,
22
+ };
23
+
14
24
  const DEFAULT_BASE_URL: &str = "https://api.real-agent.link";
15
25
  const CONFIG_DIR_ENV: &str = "REALLINK_CONFIG_DIR";
16
26
  const SESSION_DIR_NAME: &str = "reallink";
17
27
  const SESSION_FILE_NAME: &str = "session.json";
28
+ const UNREAL_LINKS_FILE_NAME: &str = "unreal-links.json";
18
29
  const UPDATE_CACHE_FILE_NAME: &str = "update-check.json";
19
30
  const VERSION_CHECK_INTERVAL_MS: u128 = 24 * 60 * 60 * 1000;
20
31
  const DEFAULT_VERSION_FETCH_TIMEOUT_MS: u64 = 800;
@@ -27,16 +38,38 @@ const DEFAULT_VERSION_FETCH_TIMEOUT_MS: u64 = 800;
27
38
  about = "Reallink CLI"
28
39
  )]
29
40
  struct Cli {
41
+ #[arg(
42
+ long = "format",
43
+ global = true,
44
+ value_enum,
45
+ default_value_t = OutputFormat::Json,
46
+ help = "CLI output format (json is agent-friendly)"
47
+ )]
48
+ output: OutputFormat,
30
49
  #[command(subcommand)]
31
50
  command: Commands,
32
51
  }
33
52
 
53
+ #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
54
+ enum OutputFormat {
55
+ Json,
56
+ Text,
57
+ }
58
+
34
59
  #[derive(Subcommand)]
35
60
  enum Commands {
36
61
  Login(LoginArgs),
37
62
  Whoami(BaseArgs),
38
63
  Logout,
39
64
  SelfUpdate(SelfUpdateArgs),
65
+ Org {
66
+ #[command(subcommand)]
67
+ command: OrgCommands,
68
+ },
69
+ Link {
70
+ #[command(subcommand)]
71
+ command: unreal::LinkCommands,
72
+ },
40
73
  Project {
41
74
  #[command(subcommand)]
42
75
  command: ProjectCommands,
@@ -53,6 +86,10 @@ enum Commands {
53
86
  #[command(subcommand)]
54
87
  command: ToolCommands,
55
88
  },
89
+ Logs {
90
+ #[command(subcommand)]
91
+ command: LogsCommands,
92
+ },
56
93
  }
57
94
 
58
95
  #[derive(Args)]
@@ -93,29 +130,91 @@ enum ProjectCommands {
93
130
  Get(ProjectGetArgs),
94
131
  Update(ProjectUpdateArgs),
95
132
  Delete(ProjectDeleteArgs),
133
+ Members(ProjectMembersArgs),
134
+ AddMember(ProjectAddMemberArgs),
135
+ UpdateMember(ProjectUpdateMemberArgs),
136
+ RemoveMember(ProjectRemoveMemberArgs),
137
+ }
138
+
139
+ #[derive(Subcommand)]
140
+ enum OrgCommands {
141
+ List(BaseArgs),
142
+ Create(OrgCreateArgs),
143
+ Get(OrgGetArgs),
144
+ Update(OrgUpdateArgs),
145
+ Delete(OrgDeleteArgs),
146
+ Invites(OrgInvitesArgs),
147
+ Invite(OrgInviteArgs),
148
+ Members(OrgMembersArgs),
149
+ AddMember(OrgAddMemberArgs),
150
+ UpdateMember(OrgUpdateMemberArgs),
151
+ RemoveMember(OrgRemoveMemberArgs),
96
152
  }
97
153
 
98
154
  #[derive(Subcommand)]
99
155
  enum FileCommands {
100
156
  List(FileListArgs),
157
+ Tree(FileTreeArgs),
101
158
  Get(FileGetArgs),
102
159
  Stat(FileStatArgs),
160
+ Thumbnail(FileThumbnailArgs),
103
161
  Download(FileDownloadArgs),
104
162
  Upload(FileUploadArgs),
105
163
  Mkdir(FileMkdirArgs),
106
164
  Move(FileMoveArgs),
165
+ Set(FileSetArgs),
166
+ MoveFolder(FileMoveFolderArgs),
167
+ Rmdir(FileRemoveFolderArgs),
107
168
  Remove(FileRemoveArgs),
108
169
  }
109
170
 
171
+ #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
172
+ enum FileThumbnailSize {
173
+ Small,
174
+ Medium,
175
+ Large,
176
+ }
177
+
178
+ impl FileThumbnailSize {
179
+ fn as_str(self) -> &'static str {
180
+ match self {
181
+ FileThumbnailSize::Small => "small",
182
+ FileThumbnailSize::Medium => "medium",
183
+ FileThumbnailSize::Large => "large",
184
+ }
185
+ }
186
+ }
187
+
110
188
  #[derive(Subcommand)]
111
189
  enum ToolCommands {
112
190
  List(ToolListArgs),
113
191
  Register(ToolRegisterArgs),
192
+ Publish(ToolPublishArgs),
114
193
  Enable(ToolEnableArgs),
115
194
  Disable(ToolDisableArgs),
116
195
  Run(ToolRunArgs),
117
196
  Runs(ToolRunsArgs),
118
197
  GetRun(ToolGetRunArgs),
198
+ RunEvents(ToolRunEventsArgs),
199
+ Local {
200
+ #[command(subcommand)]
201
+ command: ToolLocalCommands,
202
+ },
203
+ }
204
+
205
+ #[derive(Subcommand)]
206
+ enum ToolLocalCommands {
207
+ Catalog(ToolLocalCatalogArgs),
208
+ Install(ToolLocalInstallArgs),
209
+ CompleteRun(ToolLocalCompleteRunArgs),
210
+ }
211
+
212
+ #[derive(Subcommand)]
213
+ enum LogsCommands {
214
+ Status,
215
+ Consent(LogsConsentArgs),
216
+ Tail(LogsTailArgs),
217
+ Upload(LogsUploadArgs),
119
218
  }
120
219
 
121
220
  #[derive(Args)]
@@ -149,6 +248,12 @@ struct FileListArgs {
149
248
  #[arg(long)]
150
249
  path: Option<String>,
151
250
  #[arg(long)]
251
+ offset: Option<u32>,
252
+ #[arg(long)]
253
+ limit: Option<u32>,
254
+ #[arg(long)]
255
+ include_folder_markers: Option<bool>,
256
+ #[arg(long)]
152
257
  base_url: Option<String>,
153
258
  }
154
259
 
@@ -160,6 +265,14 @@ struct FileGetArgs {
160
265
  base_url: Option<String>,
161
266
  }
162
267
 
268
+ #[derive(Args)]
269
+ struct FileTreeArgs {
270
+ #[arg(long)]
271
+ project_id: String,
272
+ #[arg(long)]
273
+ base_url: Option<String>,
274
+ }
275
+
163
276
  #[derive(Args)]
164
277
  struct FileStatArgs {
165
278
  #[arg(long)]
@@ -169,11 +282,23 @@ struct FileStatArgs {
169
282
  }
170
283
 
171
284
  #[derive(Args)]
172
- struct FileDownloadArgs {
285
+ struct FileThumbnailArgs {
173
286
  #[arg(long)]
174
287
  asset_id: String,
288
+ #[arg(long, value_enum, default_value_t = FileThumbnailSize::Medium)]
289
+ size: FileThumbnailSize,
290
+ #[arg(long = "output")]
291
+ output_path: Option<PathBuf>,
175
292
  #[arg(long)]
176
- output: Option<PathBuf>,
293
+ base_url: Option<String>,
294
+ }
295
+
296
+ #[derive(Args)]
297
+ struct FileDownloadArgs {
298
+ #[arg(long)]
299
+ asset_id: String,
300
+ #[arg(long = "output")]
301
+ output_path: Option<PathBuf>,
177
302
  #[arg(
178
303
  long,
179
304
  help = "Resume download from existing output file size using HTTP Range"
@@ -205,6 +330,8 @@ struct FileMkdirArgs {
205
330
  project_id: String,
206
331
  #[arg(long)]
207
332
  path: String,
333
+ #[arg(long, default_value = "private")]
334
+ visibility: String,
208
335
  #[arg(long)]
209
336
  base_url: Option<String>,
210
337
  }
@@ -219,6 +346,48 @@ struct FileMoveArgs {
219
346
  base_url: Option<String>,
220
347
  }
221
348
 
349
+ #[derive(Args)]
350
+ struct FileSetArgs {
351
+ #[arg(long)]
352
+ asset_id: String,
353
+ #[arg(long)]
354
+ file_name: Option<String>,
355
+ #[arg(long)]
356
+ asset_type: Option<String>,
357
+ #[arg(long)]
358
+ visibility: Option<String>,
359
+ #[arg(long)]
360
+ base_url: Option<String>,
361
+ }
362
+
363
+ #[derive(Args)]
364
+ struct FileMoveFolderArgs {
365
+ #[arg(long)]
366
+ project_id: String,
367
+ #[arg(long)]
368
+ source_path: String,
369
+ #[arg(long, default_value = "")]
370
+ target_path: String,
371
+ #[arg(long)]
372
+ base_url: Option<String>,
373
+ }
374
+
375
+ #[derive(Args)]
376
+ struct FileRemoveFolderArgs {
377
+ #[arg(long)]
378
+ project_id: String,
379
+ #[arg(long)]
380
+ path: String,
381
+ #[arg(long, default_value_t = false)]
382
+ recursive: bool,
383
+ #[arg(long, default_value_t = false)]
384
+ dry_run: bool,
385
+ #[arg(long, default_value_t = true)]
386
+ include_folder_markers: bool,
387
+ #[arg(long)]
388
+ base_url: Option<String>,
389
+ }
390
+
222
391
  #[derive(Args)]
223
392
  struct FileRemoveArgs {
224
393
  #[arg(long)]
@@ -263,6 +432,20 @@ struct ToolEnableArgs {
263
432
  base_url: Option<String>,
264
433
  }
265
434
 
435
+ #[derive(Args)]
436
+ struct ToolPublishArgs {
437
+ #[arg(long)]
438
+ tool_id: String,
439
+ #[arg(long)]
440
+ channel: Option<String>,
441
+ #[arg(long)]
442
+ visibility: Option<String>,
443
+ #[arg(long)]
444
+ notes: Option<String>,
445
+ #[arg(long)]
446
+ base_url: Option<String>,
447
+ }
448
+
266
449
  #[derive(Args)]
267
450
  struct ToolDisableArgs {
268
451
  #[arg(long)]
@@ -321,6 +504,114 @@ struct ToolGetRunArgs {
321
504
  base_url: Option<String>,
322
505
  }
323
506
 
507
+ #[derive(Args)]
508
+ struct ToolRunEventsArgs {
509
+ #[arg(long)]
510
+ run_id: String,
511
+ #[arg(long)]
512
+ limit: Option<u32>,
513
+ #[arg(long)]
514
+ status: Option<String>,
515
+ #[arg(long)]
516
+ stage_prefix: Option<String>,
517
+ #[arg(long, help = "Only include events created after this ISO-8601 timestamp")]
518
+ since: Option<String>,
519
+ #[arg(long, help = "Only include events created at/before this ISO-8601 timestamp")]
520
+ until: Option<String>,
521
+ #[arg(long)]
522
+ base_url: Option<String>,
523
+ }
524
+
525
+ #[derive(Args)]
526
+ struct ToolLocalCatalogArgs {
527
+ #[arg(long)]
528
+ org_id: Option<String>,
529
+ #[arg(long)]
530
+ project_id: Option<String>,
531
+ #[arg(long)]
532
+ platform: Option<String>,
533
+ #[arg(long)]
534
+ arch: Option<String>,
535
+ #[arg(long)]
536
+ base_url: Option<String>,
537
+ }
538
+
539
+ #[derive(Args)]
540
+ struct ToolLocalInstallArgs {
541
+ #[arg(long)]
542
+ tool_id: String,
543
+ #[arg(long)]
544
+ org_id: Option<String>,
545
+ #[arg(long)]
546
+ project_id: Option<String>,
547
+ #[arg(long)]
548
+ platform: Option<String>,
549
+ #[arg(long)]
550
+ arch: Option<String>,
551
+ #[arg(long)]
552
+ version: Option<String>,
553
+ #[arg(long = "output")]
554
+ output_path: Option<PathBuf>,
555
+ #[arg(long, help = "Resume download from existing output file size using HTTP Range")]
556
+ resume: bool,
557
+ #[arg(long, help = "Only print install intent, do not download")]
558
+ no_download: bool,
559
+ #[arg(long)]
560
+ base_url: Option<String>,
561
+ }
562
+
563
+ #[derive(Args)]
564
+ struct ToolLocalCompleteRunArgs {
565
+ #[arg(long)]
566
+ run_id: String,
567
+ #[arg(long, default_value = "succeeded")]
568
+ status: String,
569
+ #[arg(long, help = "Path to JSON/JSONC file for run output")]
570
+ output_file: Option<PathBuf>,
571
+ #[arg(long, help = "Inline JSON object for run output")]
572
+ output_json: Option<String>,
573
+ #[arg(long)]
574
+ error_message: Option<String>,
575
+ #[arg(long, help = "Path to JSON/JSONC metadata file")]
576
+ metadata_file: Option<PathBuf>,
577
+ #[arg(long)]
578
+ base_url: Option<String>,
579
+ }
580
+
581
+ #[derive(Args)]
582
+ struct LogsConsentArgs {
583
+ #[arg(long, default_value_t = false, conflicts_with = "disable")]
584
+ enable: bool,
585
+ #[arg(long, default_value_t = false, conflicts_with = "enable")]
586
+ disable: bool,
587
+ }
588
+
589
+ #[derive(Args)]
590
+ struct LogsTailArgs {
591
+ #[arg(long, default_value_t = 80)]
592
+ lines: usize,
593
+ }
594
+
595
+ #[derive(Args)]
596
+ struct LogsUploadArgs {
597
+ #[arg(long)]
598
+ project_id: String,
599
+ #[arg(long, action = ArgAction::Set, default_value_t = true)]
600
+ include_runtime: bool,
601
+ #[arg(long, action = ArgAction::Set, default_value_t = true)]
602
+ include_crash: bool,
603
+ #[arg(long)]
604
+ clear_on_success: bool,
605
+ #[arg(long, default_value = "other")]
606
+ asset_type: String,
607
+ #[arg(long, default_value = "private")]
608
+ visibility: String,
609
+ #[arg(long)]
610
+ dry_run: bool,
611
+ #[arg(long)]
612
+ base_url: Option<String>,
613
+ }
614
+
324
615
  #[derive(Args)]
325
616
  struct ProjectListArgs {
326
617
  #[arg(long)]
@@ -371,77 +662,217 @@ struct ProjectDeleteArgs {
371
662
  base_url: Option<String>,
372
663
  }
373
664
 
374
- #[derive(Debug, Serialize, Deserialize, Clone)]
375
- struct SessionConfig {
376
- base_url: String,
377
- access_token: String,
378
- refresh_token: String,
379
- session_id: String,
380
- updated_at_epoch_ms: u128,
381
- }
382
-
383
- #[derive(Debug, Serialize, Deserialize, Clone)]
384
- struct UpdateCheckCache {
385
- last_checked_epoch_ms: u128,
386
- latest_version: Option<String>,
665
+ #[derive(Args)]
666
+ struct OrgCreateArgs {
667
+ #[arg(long)]
668
+ name: String,
669
+ #[arg(long)]
670
+ base_url: Option<String>,
387
671
  }
388
672
 
389
- #[derive(Debug, Serialize, Deserialize)]
390
- #[serde(rename_all = "camelCase")]
391
- struct DeviceCodeRequest {
392
- client_id: String,
393
- scope: Vec<String>,
673
+ #[derive(Args)]
674
+ struct OrgGetArgs {
675
+ #[arg(long)]
676
+ org_id: String,
677
+ #[arg(long)]
678
+ base_url: Option<String>,
394
679
  }
395
680
 
396
- #[derive(Debug, Serialize, Deserialize)]
397
- #[serde(rename_all = "camelCase")]
398
- struct DeviceCodeResponse {
399
- device_code: String,
400
- user_code: String,
401
- verification_uri: String,
402
- verification_uri_complete: String,
403
- expires_in: u64,
404
- interval: u64,
681
+ #[derive(Args)]
682
+ struct OrgUpdateArgs {
683
+ #[arg(long)]
684
+ org_id: String,
685
+ #[arg(long)]
686
+ name: String,
687
+ #[arg(long)]
688
+ base_url: Option<String>,
405
689
  }
406
690
 
407
- #[derive(Debug, Serialize, Deserialize)]
408
- #[serde(rename_all = "camelCase")]
409
- struct DeviceTokenRequest {
410
- grant_type: String,
411
- device_code: String,
412
- client_id: String,
691
+ #[derive(Args)]
692
+ struct OrgDeleteArgs {
693
+ #[arg(long)]
694
+ org_id: String,
695
+ #[arg(long)]
696
+ base_url: Option<String>,
413
697
  }
414
698
 
415
- #[derive(Debug, Serialize, Deserialize)]
416
- #[serde(rename_all = "camelCase")]
417
- struct TokenPairResponse {
418
- access_token: String,
419
- refresh_token: String,
420
- session_id: String,
699
+ #[derive(Args)]
700
+ struct OrgMembersArgs {
701
+ #[arg(long)]
702
+ org_id: String,
703
+ #[arg(long)]
704
+ base_url: Option<String>,
421
705
  }
422
706
 
423
- #[derive(Debug, Serialize, Deserialize)]
424
- #[serde(rename_all = "camelCase")]
425
- struct RefreshRequest {
426
- refresh_token: String,
427
- session_id: String,
707
+ #[derive(Args)]
708
+ struct OrgAddMemberArgs {
709
+ #[arg(long)]
710
+ org_id: String,
711
+ #[arg(long)]
712
+ email: String,
713
+ #[arg(long, default_value = "member")]
714
+ role: String,
715
+ #[arg(long)]
716
+ base_url: Option<String>,
428
717
  }
429
718
 
430
- #[derive(Debug, Serialize, Deserialize)]
431
- #[serde(rename_all = "camelCase")]
432
- struct RefreshResponse {
433
- tokens: TokenPairResponse,
719
+ #[derive(Args)]
720
+ struct OrgUpdateMemberArgs {
721
+ #[arg(long)]
722
+ org_id: String,
723
+ #[arg(long)]
724
+ user_id: String,
725
+ #[arg(long)]
726
+ role: String,
727
+ #[arg(long)]
728
+ base_url: Option<String>,
434
729
  }
435
730
 
436
- #[derive(Debug, Serialize, Deserialize)]
437
- #[serde(rename_all = "camelCase")]
438
- struct DeviceTokenSuccess {
439
- access_token: String,
440
- refresh_token: String,
441
- session_id: String,
731
+ #[derive(Args)]
732
+ struct OrgRemoveMemberArgs {
733
+ #[arg(long)]
734
+ org_id: String,
735
+ #[arg(long)]
736
+ user_id: String,
737
+ #[arg(long)]
738
+ base_url: Option<String>,
442
739
  }
443
740
 
444
- #[derive(Debug, Serialize, Deserialize)]
741
+ #[derive(Args)]
742
+ struct OrgInvitesArgs {
743
+ #[arg(long)]
744
+ org_id: String,
745
+ #[arg(long)]
746
+ base_url: Option<String>,
747
+ }
748
+
749
+ #[derive(Args)]
750
+ struct OrgInviteArgs {
751
+ #[arg(long)]
752
+ org_id: String,
753
+ #[arg(long)]
754
+ email: String,
755
+ #[arg(long, default_value = "member")]
756
+ role: String,
757
+ #[arg(long, default_value_t = 7)]
758
+ expires_in_days: u32,
759
+ #[arg(long)]
760
+ base_url: Option<String>,
761
+ }
762
+
763
+ #[derive(Args)]
764
+ struct ProjectMembersArgs {
765
+ #[arg(long)]
766
+ project_id: String,
767
+ #[arg(long)]
768
+ base_url: Option<String>,
769
+ }
770
+
771
+ #[derive(Args)]
772
+ struct ProjectAddMemberArgs {
773
+ #[arg(long)]
774
+ project_id: String,
775
+ #[arg(long)]
776
+ email: String,
777
+ #[arg(long, default_value = "viewer")]
778
+ role: String,
779
+ #[arg(long)]
780
+ base_url: Option<String>,
781
+ }
782
+
783
+ #[derive(Args)]
784
+ struct ProjectUpdateMemberArgs {
785
+ #[arg(long)]
786
+ project_id: String,
787
+ #[arg(long)]
788
+ user_id: String,
789
+ #[arg(long)]
790
+ role: String,
791
+ #[arg(long)]
792
+ base_url: Option<String>,
793
+ }
794
+
795
+ #[derive(Args)]
796
+ struct ProjectRemoveMemberArgs {
797
+ #[arg(long)]
798
+ project_id: String,
799
+ #[arg(long)]
800
+ user_id: String,
801
+ #[arg(long)]
802
+ base_url: Option<String>,
803
+ }
804
+
805
+ #[derive(Debug, Serialize, Deserialize, Clone)]
806
+ struct SessionConfig {
807
+ base_url: String,
808
+ access_token: String,
809
+ refresh_token: String,
810
+ session_id: String,
811
+ updated_at_epoch_ms: u128,
812
+ }
813
+
814
+ #[derive(Debug, Serialize, Deserialize, Clone)]
815
+ struct UpdateCheckCache {
816
+ last_checked_epoch_ms: u128,
817
+ latest_version: Option<String>,
818
+ }
819
+
820
+ #[derive(Debug, Serialize, Deserialize)]
821
+ #[serde(rename_all = "camelCase")]
822
+ struct DeviceCodeRequest {
823
+ client_id: String,
824
+ scope: Vec<String>,
825
+ }
826
+
827
+ #[derive(Debug, Serialize, Deserialize)]
828
+ #[serde(rename_all = "camelCase")]
829
+ struct DeviceCodeResponse {
830
+ device_code: String,
831
+ user_code: String,
832
+ verification_uri: String,
833
+ verification_uri_complete: String,
834
+ expires_in: u64,
835
+ interval: u64,
836
+ }
837
+
838
+ #[derive(Debug, Serialize, Deserialize)]
839
+ #[serde(rename_all = "camelCase")]
840
+ struct DeviceTokenRequest {
841
+ grant_type: String,
842
+ device_code: String,
843
+ client_id: String,
844
+ }
845
+
846
+ #[derive(Debug, Serialize, Deserialize)]
847
+ #[serde(rename_all = "camelCase")]
848
+ struct TokenPairResponse {
849
+ access_token: String,
850
+ refresh_token: String,
851
+ session_id: String,
852
+ }
853
+
854
+ #[derive(Debug, Serialize, Deserialize)]
855
+ #[serde(rename_all = "camelCase")]
856
+ struct RefreshRequest {
857
+ refresh_token: String,
858
+ session_id: String,
859
+ }
860
+
861
+ #[derive(Debug, Serialize, Deserialize)]
862
+ #[serde(rename_all = "camelCase")]
863
+ struct RefreshResponse {
864
+ tokens: TokenPairResponse,
865
+ }
866
+
867
+ #[derive(Debug, Serialize, Deserialize)]
868
+ #[serde(rename_all = "camelCase")]
869
+ struct DeviceTokenSuccess {
870
+ access_token: String,
871
+ refresh_token: String,
872
+ session_id: String,
873
+ }
874
+
875
+ #[derive(Debug, Serialize, Deserialize)]
445
876
  struct DeviceTokenError {
446
877
  error: String,
447
878
  error_description: Option<String>,
@@ -513,6 +944,95 @@ struct ProjectResponse {
513
944
  access_level: Option<String>,
514
945
  }
515
946
 
947
+ #[derive(Debug, Serialize, Deserialize)]
948
+ #[serde(rename_all = "camelCase")]
949
+ struct OrgRecord {
950
+ id: String,
951
+ slug: String,
952
+ name: String,
953
+ owner_user_id: Option<String>,
954
+ created_at: Option<String>,
955
+ }
956
+
957
+ #[derive(Debug, Serialize, Deserialize)]
958
+ struct ListOrgsResponse {
959
+ orgs: Vec<OrgRecord>,
960
+ }
961
+
962
+ #[derive(Debug, Serialize, Deserialize)]
963
+ #[serde(rename_all = "camelCase")]
964
+ struct OrgResponse {
965
+ org: OrgRecord,
966
+ role: Option<String>,
967
+ }
968
+
969
+ #[derive(Debug, Serialize, Deserialize)]
970
+ #[serde(rename_all = "camelCase")]
971
+ struct OrgMemberRecord {
972
+ org_id: String,
973
+ user_id: String,
974
+ role: String,
975
+ created_at: Option<String>,
976
+ }
977
+
978
+ #[derive(Debug, Serialize, Deserialize)]
979
+ struct ListOrgMembersResponse {
980
+ members: Vec<OrgMemberRecord>,
981
+ }
982
+
983
+ #[derive(Debug, Serialize, Deserialize)]
984
+ struct OrgMemberResponse {
985
+ member: OrgMemberRecord,
986
+ }
987
+
988
+ #[derive(Debug, Serialize, Deserialize)]
989
+ #[serde(rename_all = "camelCase")]
990
+ struct OrgInviteRecordApi {
991
+ id: String,
992
+ org_id: String,
993
+ email: String,
994
+ role: String,
995
+ invited_by_user_id: Option<String>,
996
+ status: String,
997
+ expires_at: String,
998
+ accepted_by_user_id: Option<String>,
999
+ accepted_at: Option<String>,
1000
+ created_at: Option<String>,
1001
+ updated_at: Option<String>,
1002
+ url: Option<String>,
1003
+ }
1004
+
1005
+ #[derive(Debug, Serialize, Deserialize)]
1006
+ struct ListOrgInvitesResponse {
1007
+ invites: Vec<OrgInviteRecordApi>,
1008
+ }
1009
+
1010
+ #[derive(Debug, Serialize, Deserialize)]
1011
+ struct OrgInviteResponse {
1012
+ invite: OrgInviteRecordApi,
1013
+ }
1014
+
1015
+ #[derive(Debug, Serialize, Deserialize)]
1016
+ #[serde(rename_all = "camelCase")]
1017
+ struct ProjectMemberRecordApi {
1018
+ project_id: String,
1019
+ user_id: String,
1020
+ role: String,
1021
+ invited_by_user_id: Option<String>,
1022
+ created_at: Option<String>,
1023
+ updated_at: Option<String>,
1024
+ }
1025
+
1026
+ #[derive(Debug, Serialize, Deserialize)]
1027
+ struct ListProjectMembersResponse {
1028
+ members: Vec<ProjectMemberRecordApi>,
1029
+ }
1030
+
1031
+ #[derive(Debug, Serialize, Deserialize)]
1032
+ struct ProjectMemberResponse {
1033
+ member: ProjectMemberRecordApi,
1034
+ }
1035
+
516
1036
  #[derive(Debug, Serialize, Deserialize)]
517
1037
  #[serde(rename_all = "camelCase")]
518
1038
  struct AssetRecord {
@@ -533,6 +1053,12 @@ struct AssetRecord {
533
1053
  #[derive(Debug, Serialize, Deserialize)]
534
1054
  struct ListAssetsResponse {
535
1055
  assets: Vec<AssetRecord>,
1056
+ #[serde(default)]
1057
+ total: Option<usize>,
1058
+ #[serde(default)]
1059
+ offset: Option<usize>,
1060
+ #[serde(default)]
1061
+ limit: Option<usize>,
536
1062
  }
537
1063
 
538
1064
  #[derive(Debug, Serialize, Deserialize)]
@@ -654,6 +1180,19 @@ fn parse_object_from_value(
654
1180
  }
655
1181
  }
656
1182
 
1183
+ fn print_json(value: &serde_json::Value) -> Result<()> {
1184
+ println!("{}", serde_json::to_string_pretty(value)?);
1185
+ Ok(())
1186
+ }
1187
+
1188
+ fn emit_text_or_json(output: OutputFormat, text: &str, value: serde_json::Value) -> Result<()> {
1189
+ match output {
1190
+ OutputFormat::Text => println!("{}", text),
1191
+ OutputFormat::Json => print_json(&value)?,
1192
+ }
1193
+ Ok(())
1194
+ }
1195
+
657
1196
  fn now_epoch_ms() -> u128 {
658
1197
  SystemTime::now()
659
1198
  .duration_since(UNIX_EPOCH)
@@ -673,13 +1212,20 @@ fn resolve_config_root() -> Result<PathBuf> {
673
1212
  }
674
1213
 
675
1214
  fn config_path() -> Result<PathBuf> {
676
- let base = resolve_config_root()?;
677
- Ok(base.join(SESSION_DIR_NAME).join(SESSION_FILE_NAME))
1215
+ Ok(state_root_path()?.join(SESSION_FILE_NAME))
678
1216
  }
679
1217
 
680
1218
  fn update_cache_path() -> Result<PathBuf> {
1219
+ Ok(state_root_path()?.join(UPDATE_CACHE_FILE_NAME))
1220
+ }
1221
+
1222
+ fn unreal_links_path() -> Result<PathBuf> {
1223
+ Ok(state_root_path()?.join(UNREAL_LINKS_FILE_NAME))
1224
+ }
1225
+
1226
+ fn state_root_path() -> Result<PathBuf> {
681
1227
  let base = resolve_config_root()?;
682
- Ok(base.join(SESSION_DIR_NAME).join(UPDATE_CACHE_FILE_NAME))
1228
+ Ok(base.join(SESSION_DIR_NAME))
683
1229
  }
684
1230
 
685
1231
  fn session_path_display() -> String {
@@ -716,82 +1262,347 @@ fn save_session(session: &SessionConfig) -> Result<()> {
716
1262
  Ok(())
717
1263
  }
718
1264
 
719
- fn load_session() -> Result<SessionConfig> {
720
- let path = config_path()?;
721
- let raw = fs::read(&path).with_context(|| {
722
- format!(
723
- "No active session at {}. Run `reallink login` first.",
724
- path.display()
725
- )
726
- })?;
727
- let session: SessionConfig = serde_json::from_slice(&raw)
728
- .with_context(|| format!("Invalid session format in {}", path.display()))?;
729
- Ok(session)
730
- }
731
-
732
- fn clear_session() -> Result<bool> {
733
- let path = config_path()?;
734
- if path.exists() {
735
- fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?;
736
- return Ok(true);
1265
+ fn load_unreal_links() -> Result<UnrealLinksConfig> {
1266
+ let path = unreal_links_path()?;
1267
+ if !path.exists() {
1268
+ return Ok(UnrealLinksConfig::default());
737
1269
  }
738
- Ok(false)
739
- }
740
-
741
- fn load_update_cache() -> Option<UpdateCheckCache> {
742
- let path = update_cache_path().ok()?;
743
- let raw = fs::read(path).ok()?;
744
- serde_json::from_slice(&raw).ok()
1270
+ let raw = fs::read(&path)
1271
+ .with_context(|| format!("Failed to read unreal links {}", path.display()))?;
1272
+ let mut config: UnrealLinksConfig = serde_json::from_slice(&raw)
1273
+ .with_context(|| format!("Invalid unreal links format in {}", path.display()))?;
1274
+ if config.version == 0 {
1275
+ config.version = 1;
1276
+ }
1277
+ Ok(config)
745
1278
  }
746
1279
 
747
- fn save_update_cache(cache: &UpdateCheckCache) -> Result<()> {
748
- let path = update_cache_path()?;
1280
+ fn save_unreal_links(config: &UnrealLinksConfig) -> Result<()> {
1281
+ let path = unreal_links_path()?;
749
1282
  if let Some(parent) = path.parent() {
750
1283
  fs::create_dir_all(parent)
751
1284
  .with_context(|| format!("Failed to create {}", parent.display()))?;
752
1285
  }
753
- let payload = serde_json::to_vec_pretty(cache)?;
1286
+ let payload = serde_json::to_vec_pretty(config)?;
754
1287
  write_atomic(&path, &payload)?;
755
1288
  Ok(())
756
1289
  }
757
1290
 
758
- async fn read_error_body(response: reqwest::Response) -> String {
759
- match response.text().await {
760
- Ok(text) if !text.trim().is_empty() => text,
761
- _ => "No response body".to_string(),
1291
+ fn normalize_path_for_compare(path: &Path) -> String {
1292
+ let normalized = path.to_string_lossy().replace('\\', "/");
1293
+ if cfg!(windows) {
1294
+ normalized.to_ascii_lowercase()
1295
+ } else {
1296
+ normalized
762
1297
  }
763
1298
  }
764
1299
 
765
- fn with_cli_headers(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
766
- request
767
- .header("x-reallink-client", "cli")
768
- .header("x-reallink-cli-version", env!("CARGO_PKG_VERSION"))
1300
+ fn normalize_path_string_for_compare(path: &str) -> String {
1301
+ let normalized = path.replace('\\', "/");
1302
+ if cfg!(windows) {
1303
+ normalized.to_ascii_lowercase()
1304
+ } else {
1305
+ normalized
1306
+ }
769
1307
  }
770
1308
 
771
- fn parse_semver_triplet(version: &str) -> Option<(u64, u64, u64)> {
772
- let core = version.trim().split('-').next()?;
773
- let mut parts = core.split('.');
774
- let major = parts.next()?.parse::<u64>().ok()?;
775
- let minor = parts.next().unwrap_or("0").parse::<u64>().ok()?;
776
- let patch = parts.next().unwrap_or("0").parse::<u64>().ok()?;
777
- Some((major, minor, patch))
1309
+ fn canonicalize_existing_path(path: &Path, label: &str) -> Result<PathBuf> {
1310
+ let absolute = if path.is_absolute() {
1311
+ path.to_path_buf()
1312
+ } else {
1313
+ std::env::current_dir()
1314
+ .with_context(|| "Failed to resolve current working directory")?
1315
+ .join(path)
1316
+ };
1317
+ fs::canonicalize(&absolute)
1318
+ .with_context(|| format!("Failed to resolve {} path {}", label, absolute.display()))
778
1319
  }
779
1320
 
780
- fn is_newer_version(current: &str, latest: &str) -> bool {
781
- match (parse_semver_triplet(current), parse_semver_triplet(latest)) {
782
- (Some(current_parts), Some(latest_parts)) => latest_parts > current_parts,
783
- _ => false,
1321
+ fn resolve_uproject_path(path: &Path) -> Result<PathBuf> {
1322
+ let canonical = canonicalize_existing_path(path, "uproject")?;
1323
+ if canonical.is_file() {
1324
+ let is_uproject = canonical
1325
+ .extension()
1326
+ .and_then(|value| value.to_str())
1327
+ .map(|value| value.eq_ignore_ascii_case("uproject"))
1328
+ .unwrap_or(false);
1329
+ if !is_uproject {
1330
+ return Err(anyhow!(
1331
+ "Expected a .uproject file, got {}",
1332
+ canonical.display()
1333
+ ));
1334
+ }
1335
+ return Ok(canonical);
784
1336
  }
1337
+ if canonical.is_dir() {
1338
+ let mut entries: Vec<PathBuf> = fs::read_dir(&canonical)
1339
+ .with_context(|| format!("Failed to read directory {}", canonical.display()))?
1340
+ .filter_map(|entry| entry.ok().map(|item| item.path()))
1341
+ .filter(|entry| {
1342
+ entry
1343
+ .extension()
1344
+ .and_then(|value| value.to_str())
1345
+ .map(|value| value.eq_ignore_ascii_case("uproject"))
1346
+ .unwrap_or(false)
1347
+ })
1348
+ .collect();
1349
+ entries.sort();
1350
+ if entries.len() == 1 {
1351
+ return Ok(entries.remove(0));
1352
+ }
1353
+ if entries.is_empty() {
1354
+ return Err(anyhow!(
1355
+ "No .uproject file found in {}",
1356
+ canonical.display()
1357
+ ));
1358
+ }
1359
+ return Err(anyhow!(
1360
+ "Multiple .uproject files found in {}; pass --uproject with explicit file path",
1361
+ canonical.display()
1362
+ ));
1363
+ }
1364
+ Err(anyhow!(
1365
+ "Path {} is neither a file nor directory",
1366
+ canonical.display()
1367
+ ))
785
1368
  }
786
1369
 
787
- async fn fetch_latest_cli_version(client: &reqwest::Client) -> Option<String> {
788
- let timeout_ms = std::env::var("REALLINK_UPDATE_CHECK_TIMEOUT_MS")
789
- .ok()
790
- .and_then(|value| value.parse::<u64>().ok())
791
- .filter(|value| *value > 0)
792
- .unwrap_or(DEFAULT_VERSION_FETCH_TIMEOUT_MS);
793
- let response = with_cli_headers(client.get("https://registry.npmjs.org/reallink-cli/latest"))
794
- .timeout(Duration::from_millis(timeout_ms))
1370
+ fn candidate_editor_paths_from_engine_root(engine_root: &Path) -> Vec<PathBuf> {
1371
+ vec![
1372
+ engine_root
1373
+ .join("Engine")
1374
+ .join("Binaries")
1375
+ .join("Win64")
1376
+ .join("UnrealEditor.exe"),
1377
+ engine_root
1378
+ .join("Engine")
1379
+ .join("Binaries")
1380
+ .join("Win64")
1381
+ .join("UE4Editor.exe"),
1382
+ engine_root
1383
+ .join("Engine")
1384
+ .join("Binaries")
1385
+ .join("Linux")
1386
+ .join("UnrealEditor"),
1387
+ engine_root
1388
+ .join("Engine")
1389
+ .join("Binaries")
1390
+ .join("Linux")
1391
+ .join("UE4Editor"),
1392
+ engine_root
1393
+ .join("Engine")
1394
+ .join("Binaries")
1395
+ .join("Mac")
1396
+ .join("UnrealEditor.app")
1397
+ .join("Contents")
1398
+ .join("MacOS")
1399
+ .join("UnrealEditor"),
1400
+ engine_root
1401
+ .join("Engine")
1402
+ .join("Binaries")
1403
+ .join("Mac")
1404
+ .join("UE4Editor.app")
1405
+ .join("Contents")
1406
+ .join("MacOS")
1407
+ .join("UE4Editor"),
1408
+ ]
1409
+ }
1410
+
1411
+ fn resolve_editor_from_engine_root(engine_root: &Path) -> Result<PathBuf> {
1412
+ let root = canonicalize_existing_path(engine_root, "engine root")?;
1413
+ if !root.is_dir() {
1414
+ return Err(anyhow!(
1415
+ "Engine root must be a directory: {}",
1416
+ root.display()
1417
+ ));
1418
+ }
1419
+
1420
+ for candidate in candidate_editor_paths_from_engine_root(&root) {
1421
+ if candidate.exists() {
1422
+ return Ok(candidate);
1423
+ }
1424
+ }
1425
+
1426
+ Err(anyhow!(
1427
+ "Could not find Unreal editor binary under {}. Pass --editor explicitly.",
1428
+ root.display()
1429
+ ))
1430
+ }
1431
+
1432
+ fn detect_engine_root_from_editor(editor_path: &Path) -> Option<PathBuf> {
1433
+ let parts: Vec<String> = editor_path
1434
+ .components()
1435
+ .map(|component| component.as_os_str().to_string_lossy().to_string())
1436
+ .collect();
1437
+ let mut engine_index = None;
1438
+ for (index, value) in parts.iter().enumerate() {
1439
+ if value.eq_ignore_ascii_case("Engine") {
1440
+ engine_index = Some(index);
1441
+ break;
1442
+ }
1443
+ }
1444
+ let index = engine_index?;
1445
+ let mut root = PathBuf::new();
1446
+ for segment in &parts[..index] {
1447
+ root.push(segment);
1448
+ }
1449
+ if root.as_os_str().is_empty() {
1450
+ None
1451
+ } else {
1452
+ Some(root)
1453
+ }
1454
+ }
1455
+
1456
+ fn resolve_editor_and_engine(
1457
+ explicit_editor: Option<PathBuf>,
1458
+ explicit_engine_root: Option<PathBuf>,
1459
+ ) -> Result<(PathBuf, Option<PathBuf>)> {
1460
+ if let Some(editor) = explicit_editor {
1461
+ let editor_path = canonicalize_existing_path(&editor, "editor")?;
1462
+ if !editor_path.is_file() {
1463
+ return Err(anyhow!(
1464
+ "Editor path must be a file: {}",
1465
+ editor_path.display()
1466
+ ));
1467
+ }
1468
+ let engine_root = if let Some(root) = explicit_engine_root {
1469
+ Some(canonicalize_existing_path(&root, "engine root")?)
1470
+ } else {
1471
+ detect_engine_root_from_editor(&editor_path)
1472
+ };
1473
+ return Ok((editor_path, engine_root));
1474
+ }
1475
+
1476
+ if let Some(engine_root) = explicit_engine_root {
1477
+ let root = canonicalize_existing_path(&engine_root, "engine root")?;
1478
+ let editor_path = resolve_editor_from_engine_root(&root)?;
1479
+ return Ok((editor_path, Some(root)));
1480
+ }
1481
+
1482
+ if let Ok(editor_env) = std::env::var("REALLINK_UNREAL_EDITOR") {
1483
+ let trimmed = editor_env.trim();
1484
+ if !trimmed.is_empty() {
1485
+ let editor_path = canonicalize_existing_path(Path::new(trimmed), "editor")?;
1486
+ let engine_root = detect_engine_root_from_editor(&editor_path);
1487
+ return Ok((editor_path, engine_root));
1488
+ }
1489
+ }
1490
+ if let Ok(editor_env) = std::env::var("UE_EDITOR_PATH") {
1491
+ let trimmed = editor_env.trim();
1492
+ if !trimmed.is_empty() {
1493
+ let editor_path = canonicalize_existing_path(Path::new(trimmed), "editor")?;
1494
+ let engine_root = detect_engine_root_from_editor(&editor_path);
1495
+ return Ok((editor_path, engine_root));
1496
+ }
1497
+ }
1498
+ if let Ok(engine_env) = std::env::var("REALLINK_UNREAL_ENGINE_ROOT") {
1499
+ let trimmed = engine_env.trim();
1500
+ if !trimmed.is_empty() {
1501
+ let root = canonicalize_existing_path(Path::new(trimmed), "engine root")?;
1502
+ let editor_path = resolve_editor_from_engine_root(&root)?;
1503
+ return Ok((editor_path, Some(root)));
1504
+ }
1505
+ }
1506
+ if let Ok(engine_env) = std::env::var("UE_ENGINE_ROOT") {
1507
+ let trimmed = engine_env.trim();
1508
+ if !trimmed.is_empty() {
1509
+ let root = canonicalize_existing_path(Path::new(trimmed), "engine root")?;
1510
+ let editor_path = resolve_editor_from_engine_root(&root)?;
1511
+ return Ok((editor_path, Some(root)));
1512
+ }
1513
+ }
1514
+
1515
+ Err(anyhow!(
1516
+ "Unable to resolve Unreal editor. Pass --editor or --engine-root, or set REALLINK_UNREAL_EDITOR/REALLINK_UNREAL_ENGINE_ROOT."
1517
+ ))
1518
+ }
1519
+
1520
+ fn load_session() -> Result<SessionConfig> {
1521
+ let path = config_path()?;
1522
+ let raw = fs::read(&path).with_context(|| {
1523
+ format!(
1524
+ "No active session at {}. Run `reallink login` first.",
1525
+ path.display()
1526
+ )
1527
+ })?;
1528
+ let session: SessionConfig = serde_json::from_slice(&raw)
1529
+ .with_context(|| format!("Invalid session format in {}", path.display()))?;
1530
+ Ok(session)
1531
+ }
1532
+
1533
+ fn clear_session() -> Result<bool> {
1534
+ let path = config_path()?;
1535
+ if path.exists() {
1536
+ fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?;
1537
+ return Ok(true);
1538
+ }
1539
+ Ok(false)
1540
+ }
1541
+
1542
+ fn load_update_cache() -> Option<UpdateCheckCache> {
1543
+ let path = update_cache_path().ok()?;
1544
+ let raw = fs::read(path).ok()?;
1545
+ serde_json::from_slice(&raw).ok()
1546
+ }
1547
+
1548
+ fn save_update_cache(cache: &UpdateCheckCache) -> Result<()> {
1549
+ let path = update_cache_path()?;
1550
+ if let Some(parent) = path.parent() {
1551
+ fs::create_dir_all(parent)
1552
+ .with_context(|| format!("Failed to create {}", parent.display()))?;
1553
+ }
1554
+ let payload = serde_json::to_vec_pretty(cache)?;
1555
+ write_atomic(&path, &payload)?;
1556
+ Ok(())
1557
+ }
1558
+
1559
+ async fn read_error_body(response: reqwest::Response) -> String {
1560
+ match response.text().await {
1561
+ Ok(text) if !text.trim().is_empty() => text,
1562
+ _ => "No response body".to_string(),
1563
+ }
1564
+ }
1565
+
1566
+ #[derive(Deserialize)]
1567
+ struct ApiErrorEnvelope {
1568
+ code: Option<String>,
1569
+ message: Option<String>,
1570
+ }
1571
+
1572
+ fn parse_api_error(body: &str) -> Option<ApiErrorEnvelope> {
1573
+ serde_json::from_str::<ApiErrorEnvelope>(body).ok()
1574
+ }
1575
+
1576
+ fn with_cli_headers(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
1577
+ request
1578
+ .header("x-reallink-client", "cli")
1579
+ .header("x-reallink-cli-version", env!("CARGO_PKG_VERSION"))
1580
+ }
1581
+
1582
+ fn parse_semver_triplet(version: &str) -> Option<(u64, u64, u64)> {
1583
+ let core = version.trim().split('-').next()?;
1584
+ let mut parts = core.split('.');
1585
+ let major = parts.next()?.parse::<u64>().ok()?;
1586
+ let minor = parts.next().unwrap_or("0").parse::<u64>().ok()?;
1587
+ let patch = parts.next().unwrap_or("0").parse::<u64>().ok()?;
1588
+ Some((major, minor, patch))
1589
+ }
1590
+
1591
+ fn is_newer_version(current: &str, latest: &str) -> bool {
1592
+ match (parse_semver_triplet(current), parse_semver_triplet(latest)) {
1593
+ (Some(current_parts), Some(latest_parts)) => latest_parts > current_parts,
1594
+ _ => false,
1595
+ }
1596
+ }
1597
+
1598
+ async fn fetch_latest_cli_version(client: &reqwest::Client) -> Option<String> {
1599
+ let timeout_ms = std::env::var("REALLINK_UPDATE_CHECK_TIMEOUT_MS")
1600
+ .ok()
1601
+ .and_then(|value| value.parse::<u64>().ok())
1602
+ .filter(|value| *value > 0)
1603
+ .unwrap_or(DEFAULT_VERSION_FETCH_TIMEOUT_MS);
1604
+ let response = with_cli_headers(client.get("https://registry.npmjs.org/reallink-cli/latest"))
1605
+ .timeout(Duration::from_millis(timeout_ms))
795
1606
  .send()
796
1607
  .await
797
1608
  .ok()?;
@@ -810,6 +1621,7 @@ async fn maybe_notify_update(
810
1621
  client: &reqwest::Client,
811
1622
  force_refresh: bool,
812
1623
  allow_network_fetch: bool,
1624
+ output: OutputFormat,
813
1625
  ) {
814
1626
  if std::env::var("REALLINK_DISABLE_AUTO_UPDATE_CHECK")
815
1627
  .map(|value| value == "1")
@@ -844,10 +1656,12 @@ async fn maybe_notify_update(
844
1656
  let current = env!("CARGO_PKG_VERSION");
845
1657
  if let Some(latest) = latest_version {
846
1658
  if is_newer_version(current, &latest) {
847
- eprintln!(
848
- "Update available: {} -> {}. Run `reallink self-update`.",
849
- current, latest
850
- );
1659
+ if output == OutputFormat::Text {
1660
+ eprintln!(
1661
+ "Update available: {} -> {}. Run `reallink self-update`.",
1662
+ current, latest
1663
+ );
1664
+ }
851
1665
  }
852
1666
  }
853
1667
  }
@@ -987,65 +1801,130 @@ async fn existing_session_identity_for_base_url(
987
1801
  .map(|email| email.to_string())
988
1802
  }
989
1803
 
990
- async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()> {
1804
+ fn default_login_scopes() -> Vec<String> {
1805
+ generated::contract::CLI_DEFAULT_LOGIN_SCOPES
1806
+ .iter()
1807
+ .map(|value| (*value).to_string())
1808
+ .collect()
1809
+ }
1810
+
1811
+ fn default_login_scopes_with_tools() -> Vec<String> {
1812
+ generated::contract::CLI_DEFAULT_LOGIN_SCOPES_WITH_TOOLS
1813
+ .iter()
1814
+ .map(|value| (*value).to_string())
1815
+ .collect()
1816
+ }
1817
+
1818
+ async fn login_command(
1819
+ client: &reqwest::Client,
1820
+ args: LoginArgs,
1821
+ output: OutputFormat,
1822
+ ) -> Result<()> {
991
1823
  let base_url = normalize_base_url(&args.base_url);
992
1824
  if !args.force {
993
1825
  if let Some(email) = existing_session_identity_for_base_url(client, &base_url).await {
994
- println!("Already logged in as {} on {}.", email, base_url);
995
- println!("Use `reallink logout` to sign out or `reallink login --force` to replace this session.");
1826
+ let payload = serde_json::json!({
1827
+ "ok": true,
1828
+ "alreadyLoggedIn": true,
1829
+ "baseUrl": base_url,
1830
+ "email": email,
1831
+ "message": "Use `reallink logout` to sign out or `reallink login --force` to replace this session."
1832
+ });
1833
+ emit_text_or_json(
1834
+ output,
1835
+ &format!(
1836
+ "Already logged in. Use `reallink logout` to sign out or `reallink login --force` to replace this session."
1837
+ ),
1838
+ payload,
1839
+ )?;
996
1840
  return Ok(());
997
1841
  }
998
1842
  }
999
1843
 
1000
- let scope = if args.scope.is_empty() {
1001
- vec![
1002
- "core:read".to_string(),
1003
- "core:write".to_string(),
1004
- "assets:read".to_string(),
1005
- "assets:write".to_string(),
1006
- "trace:read".to_string(),
1007
- "trace:write".to_string(),
1008
- "tools:read".to_string(),
1009
- "tools:write".to_string(),
1010
- "tools:run".to_string(),
1011
- "org:admin".to_string(),
1012
- "project:admin".to_string(),
1013
- ]
1844
+ let (initial_scope, fallback_scope) = if args.scope.is_empty() {
1845
+ (
1846
+ default_login_scopes_with_tools(),
1847
+ Some(default_login_scopes()),
1848
+ )
1014
1849
  } else {
1015
- args.scope
1850
+ (args.scope, None)
1016
1851
  };
1017
1852
 
1018
- let device_code_response =
1853
+ let mut selected_scope = initial_scope;
1854
+ let mut device_code_response =
1019
1855
  with_cli_headers(client.post(format!("{}/auth/device/code", base_url)))
1020
1856
  .json(&DeviceCodeRequest {
1021
1857
  client_id: args.client_id.clone(),
1022
- scope,
1858
+ scope: selected_scope.clone(),
1023
1859
  })
1024
1860
  .send()
1025
1861
  .await?;
1026
1862
 
1027
1863
  if !device_code_response.status().is_success() {
1028
1864
  let body = read_error_body(device_code_response).await;
1029
- return Err(anyhow!("Failed to start device flow: {}", body));
1865
+ let can_fallback = fallback_scope.is_some()
1866
+ && body.contains("VALIDATION_ERROR")
1867
+ && body.contains("tools:");
1868
+ if can_fallback {
1869
+ if output == OutputFormat::Text {
1870
+ eprintln!(
1871
+ "Server rejected tool scopes for device-flow bootstrap; retrying with compatibility scopes."
1872
+ );
1873
+ }
1874
+ selected_scope = fallback_scope.unwrap_or_default();
1875
+ device_code_response =
1876
+ with_cli_headers(client.post(format!("{}/auth/device/code", base_url)))
1877
+ .json(&DeviceCodeRequest {
1878
+ client_id: args.client_id.clone(),
1879
+ scope: selected_scope.clone(),
1880
+ })
1881
+ .send()
1882
+ .await?;
1883
+ if !device_code_response.status().is_success() {
1884
+ let retry_body = read_error_body(device_code_response).await;
1885
+ return Err(anyhow!("Failed to start device flow: {}", retry_body));
1886
+ }
1887
+ } else {
1888
+ return Err(anyhow!("Failed to start device flow: {}", body));
1889
+ }
1030
1890
  }
1031
1891
 
1032
1892
  let device_code: DeviceCodeResponse = device_code_response.json().await?;
1033
- println!("Open this URL in your browser and approve the login:");
1034
- println!("{}", device_code.verification_uri_complete);
1035
- println!("User code: {}", device_code.user_code);
1036
- match webbrowser::open(&device_code.verification_uri_complete) {
1037
- Ok(_) => println!("Browser opened for device approval."),
1038
- Err(_) => println!("Could not open browser automatically. Open the URL manually."),
1893
+ let browser_opened = webbrowser::open(&device_code.verification_uri_complete).is_ok();
1894
+ if output == OutputFormat::Text {
1895
+ println!("Open this URL in your browser and approve the login:");
1896
+ println!("{}", device_code.verification_uri_complete);
1897
+ println!("User code: {}", device_code.user_code);
1898
+ if browser_opened {
1899
+ println!("Browser opened for device approval.");
1900
+ } else {
1901
+ println!("Could not open browser automatically. Open the URL manually.");
1902
+ }
1903
+ } else {
1904
+ print_json(&serde_json::json!({
1905
+ "ok": true,
1906
+ "phase": "authorization_pending",
1907
+ "baseUrl": base_url,
1908
+ "verificationUri": device_code.verification_uri,
1909
+ "verificationUriComplete": device_code.verification_uri_complete,
1910
+ "userCode": device_code.user_code,
1911
+ "browserOpened": browser_opened,
1912
+ "expiresInSeconds": device_code.expires_in,
1913
+ "pollIntervalSeconds": device_code.interval,
1914
+ "scope": selected_scope
1915
+ }))?;
1039
1916
  }
1040
1917
 
1041
1918
  let expires_at = std::time::Instant::now() + Duration::from_secs(device_code.expires_in);
1042
1919
  let mut poll_interval = Duration::from_secs(device_code.interval.max(1));
1043
1920
  let mut pending_polls = 0u32;
1044
- println!("Waiting for approval (press Ctrl+C to cancel)");
1921
+ if output == OutputFormat::Text {
1922
+ println!("Waiting for approval (press Ctrl+C to cancel)");
1923
+ }
1045
1924
 
1046
1925
  loop {
1047
1926
  if std::time::Instant::now() >= expires_at {
1048
- if pending_polls > 0 {
1927
+ if output == OutputFormat::Text && pending_polls > 0 {
1049
1928
  println!();
1050
1929
  }
1051
1930
  return Err(anyhow!("Device code expired before approval"));
@@ -1065,7 +1944,7 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
1065
1944
 
1066
1945
  if token_response.status().is_success() {
1067
1946
  let tokens: DeviceTokenSuccess = token_response.json().await?;
1068
- if pending_polls > 0 {
1947
+ if output == OutputFormat::Text && pending_polls > 0 {
1069
1948
  println!();
1070
1949
  }
1071
1950
  let session = SessionConfig {
@@ -1076,8 +1955,20 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
1076
1955
  updated_at_epoch_ms: now_epoch_ms(),
1077
1956
  };
1078
1957
  save_session(&session)?;
1079
- println!("Login successful.");
1080
- println!("Session stored at {}", session_path_display());
1958
+ let path = session_path_display();
1959
+ let payload = serde_json::json!({
1960
+ "ok": true,
1961
+ "phase": "authenticated",
1962
+ "baseUrl": base_url,
1963
+ "sessionPath": path,
1964
+ "sessionId": session.session_id
1965
+ });
1966
+ if output == OutputFormat::Text {
1967
+ println!("Login successful.");
1968
+ println!("Session stored at {}", session_path_display());
1969
+ } else {
1970
+ print_json(&payload)?;
1971
+ }
1081
1972
  return Ok(());
1082
1973
  }
1083
1974
 
@@ -1090,19 +1981,23 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
1090
1981
  match error_payload.error.as_str() {
1091
1982
  "authorization_pending" => {
1092
1983
  pending_polls = pending_polls.saturating_add(1);
1093
- print!(".");
1094
- let _ = io::stdout().flush();
1984
+ if output == OutputFormat::Text {
1985
+ print!(".");
1986
+ let _ = io::stdout().flush();
1987
+ }
1095
1988
  continue;
1096
1989
  }
1097
1990
  "slow_down" => {
1098
1991
  poll_interval += Duration::from_secs(1);
1099
1992
  pending_polls = pending_polls.saturating_add(1);
1100
- print!("+");
1101
- let _ = io::stdout().flush();
1993
+ if output == OutputFormat::Text {
1994
+ print!("+");
1995
+ let _ = io::stdout().flush();
1996
+ }
1102
1997
  continue;
1103
1998
  }
1104
1999
  _ => {
1105
- if pending_polls > 0 {
2000
+ if output == OutputFormat::Text && pending_polls > 0 {
1106
2001
  println!();
1107
2002
  }
1108
2003
  return Err(anyhow!(
@@ -1115,13 +2010,17 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
1115
2010
  }
1116
2011
  }
1117
2012
 
1118
- async fn logout_command(client: &reqwest::Client) -> Result<()> {
2013
+ async fn logout_command(client: &reqwest::Client, output: OutputFormat) -> Result<()> {
1119
2014
  let path_display = session_path_display();
1120
2015
  let mut session = match load_session() {
1121
2016
  Ok(session) => session,
1122
2017
  Err(_) => {
1123
- println!("No local session found at {}.", path_display);
1124
- println!("You are already logged out.");
2018
+ let payload = serde_json::json!({
2019
+ "ok": true,
2020
+ "alreadyLoggedOut": true,
2021
+ "sessionPath": path_display
2022
+ });
2023
+ emit_text_or_json(output, "You are already logged out.", payload)?;
1125
2024
  return Ok(());
1126
2025
  }
1127
2026
  };
@@ -1137,31 +2036,296 @@ async fn logout_command(client: &reqwest::Client) -> Result<()> {
1137
2036
  }
1138
2037
  Ok(response) => {
1139
2038
  let body = read_error_body(response).await;
1140
- eprintln!("Warning: remote logout request failed: {}", body);
2039
+ if output == OutputFormat::Text {
2040
+ eprintln!("Warning: remote logout request failed: {}", body);
2041
+ }
1141
2042
  }
1142
2043
  Err(error) => {
1143
- eprintln!("Warning: remote logout request failed: {}", error);
2044
+ if output == OutputFormat::Text {
2045
+ eprintln!("Warning: remote logout request failed: {}", error);
2046
+ }
1144
2047
  }
1145
2048
  }
1146
2049
 
1147
2050
  let removed = clear_session()?;
1148
2051
  if !removed {
1149
- println!("Local session was already cleared.");
2052
+ let payload = serde_json::json!({
2053
+ "ok": true,
2054
+ "alreadyLoggedOut": true,
2055
+ "sessionPath": path_display
2056
+ });
2057
+ emit_text_or_json(output, "Local session was already cleared.", payload)?;
1150
2058
  return Ok(());
1151
2059
  }
1152
2060
 
1153
- if remote_revoked {
2061
+ let payload = serde_json::json!({
2062
+ "ok": true,
2063
+ "sessionPath": path_display,
2064
+ "remoteRevoked": remote_revoked,
2065
+ "remoteUnavailable": remote_unavailable
2066
+ });
2067
+ if output == OutputFormat::Text {
2068
+ if remote_revoked {
2069
+ println!(
2070
+ "Logged out. Server session revoked and local session removed from {}.",
2071
+ session_path_display()
2072
+ );
2073
+ } else if remote_unavailable {
2074
+ println!(
2075
+ "Logged out locally. Removed session from {}.",
2076
+ session_path_display()
2077
+ );
2078
+ println!("Server logout endpoint is not available on this API deployment yet.");
2079
+ } else {
2080
+ println!(
2081
+ "Logged out locally. Removed session from {}.",
2082
+ session_path_display()
2083
+ );
2084
+ }
2085
+ } else {
2086
+ print_json(&payload)?;
2087
+ }
2088
+
2089
+ Ok(())
2090
+ }
2091
+
2092
+ fn sanitize_cli_args(raw: &[String]) -> Vec<String> {
2093
+ let sensitive_flags = [
2094
+ "--input-json",
2095
+ "--input-file",
2096
+ "--metadata-file",
2097
+ "--token",
2098
+ "--refresh-token",
2099
+ "--password",
2100
+ "--secret",
2101
+ ];
2102
+ let mut sanitized = Vec::with_capacity(raw.len());
2103
+ let mut redact_next = false;
2104
+ for value in raw {
2105
+ if redact_next {
2106
+ sanitized.push("<redacted>".to_string());
2107
+ redact_next = false;
2108
+ continue;
2109
+ }
2110
+ if let Some((flag, _rest)) = value.split_once('=') {
2111
+ if sensitive_flags.iter().any(|candidate| candidate == &flag) {
2112
+ sanitized.push(format!("{}=<redacted>", flag));
2113
+ continue;
2114
+ }
2115
+ }
2116
+ if sensitive_flags.iter().any(|candidate| candidate == value) {
2117
+ sanitized.push(value.clone());
2118
+ redact_next = true;
2119
+ continue;
2120
+ }
2121
+ sanitized.push(value.clone());
2122
+ }
2123
+ sanitized
2124
+ }
2125
+
2126
+ fn command_summary_for_logs() -> String {
2127
+ let args: Vec<String> = std::env::args().skip(1).collect();
2128
+ let sanitized = sanitize_cli_args(&args);
2129
+ if sanitized.is_empty() {
2130
+ "reallink".to_string()
2131
+ } else {
2132
+ format!("reallink {}", sanitized.join(" "))
2133
+ }
2134
+ }
2135
+
2136
+ fn append_runtime_log_event(
2137
+ command: &str,
2138
+ message: &str,
2139
+ level: &str,
2140
+ duration_ms: Option<u128>,
2141
+ exit_code: Option<i32>,
2142
+ ) {
2143
+ let state_root = match state_root_path() {
2144
+ Ok(path) => path,
2145
+ Err(_) => return,
2146
+ };
2147
+ let event = logs::RuntimeLogEvent {
2148
+ ts_epoch_ms: now_epoch_ms(),
2149
+ level: level.to_string(),
2150
+ command: command.to_string(),
2151
+ message: message.to_string(),
2152
+ duration_ms,
2153
+ exit_code,
2154
+ };
2155
+ let _ = logs::append_runtime_event(&state_root, &event);
2156
+ }
2157
+
2158
+ fn record_cli_crash_report(command: &str, error: &anyhow::Error) {
2159
+ let state_root = match state_root_path() {
2160
+ Ok(path) => path,
2161
+ Err(_) => return,
2162
+ };
2163
+ let report = logs::CrashReport {
2164
+ ts_epoch_ms: now_epoch_ms(),
2165
+ command: command.to_string(),
2166
+ message: error.to_string(),
2167
+ detail: Some(format!("{:#}", error)),
2168
+ };
2169
+ let _ = logs::write_crash_report(&state_root, &report);
2170
+ }
2171
+
2172
+ async fn logs_status_command(output: OutputFormat) -> Result<()> {
2173
+ let status = logs::status(&state_root_path()?)?;
2174
+ let payload = serde_json::to_value(&status)?;
2175
+ if output == OutputFormat::Text {
2176
+ println!(
2177
+ "Logs root: {}\nRuntime log: {}\nCrash dir: {}\nUpload consent: {}\nCrash reports: {}",
2178
+ status.logs_root,
2179
+ status.runtime_log_path,
2180
+ status.crash_dir,
2181
+ if status.consent.upload_enabled {
2182
+ "enabled"
2183
+ } else {
2184
+ "disabled"
2185
+ },
2186
+ status.crash_report_count
2187
+ );
2188
+ } else {
2189
+ print_json(&payload)?;
2190
+ }
2191
+ Ok(())
2192
+ }
2193
+
2194
+ async fn logs_consent_command(args: LogsConsentArgs, output: OutputFormat) -> Result<()> {
2195
+ let state_root = state_root_path()?;
2196
+ let consent = if args.enable {
2197
+ logs::set_upload_consent(&state_root, true)?
2198
+ } else if args.disable {
2199
+ logs::set_upload_consent(&state_root, false)?
2200
+ } else {
2201
+ logs::load_consent(&state_root)?
2202
+ };
2203
+ let payload = serde_json::json!({
2204
+ "ok": true,
2205
+ "consent": consent
2206
+ });
2207
+ if output == OutputFormat::Text {
1154
2208
  println!(
1155
- "Logged out. Server session revoked and local session removed from {}.",
1156
- path_display
2209
+ "Log upload consent is {}.",
2210
+ if consent.upload_enabled {
2211
+ "enabled"
2212
+ } else {
2213
+ "disabled"
2214
+ }
1157
2215
  );
1158
- } else if remote_unavailable {
1159
- println!("Logged out locally. Removed session from {}.", path_display);
1160
- println!("Server logout endpoint is not available on this API deployment yet.");
1161
2216
  } else {
1162
- println!("Logged out locally. Removed session from {}.", path_display);
2217
+ print_json(&payload)?;
2218
+ }
2219
+ Ok(())
2220
+ }
2221
+
2222
+ async fn logs_tail_command(args: LogsTailArgs, output: OutputFormat) -> Result<()> {
2223
+ let lines = logs::read_runtime_tail(&state_root_path()?, args.lines)?;
2224
+ if output == OutputFormat::Text {
2225
+ for line in lines {
2226
+ println!("{}", line);
2227
+ }
2228
+ } else {
2229
+ print_json(&serde_json::json!({
2230
+ "ok": true,
2231
+ "lines": lines
2232
+ }))?;
2233
+ }
2234
+ Ok(())
2235
+ }
2236
+
2237
+ async fn logs_upload_command(
2238
+ client: &reqwest::Client,
2239
+ args: LogsUploadArgs,
2240
+ output: OutputFormat,
2241
+ ) -> Result<()> {
2242
+ let state_root = state_root_path()?;
2243
+ let consent = logs::load_consent(&state_root)?;
2244
+ if !consent.upload_enabled {
2245
+ return Err(anyhow!(
2246
+ "Log upload consent is disabled. Run `reallink logs consent --enable` first."
2247
+ ));
2248
+ }
2249
+
2250
+ let candidates =
2251
+ logs::list_upload_candidates(&state_root, args.include_runtime, args.include_crash)?;
2252
+ if args.dry_run {
2253
+ let payload = serde_json::json!({
2254
+ "ok": true,
2255
+ "dryRun": true,
2256
+ "count": candidates.len(),
2257
+ "candidates": candidates
2258
+ });
2259
+ emit_text_or_json(
2260
+ output,
2261
+ &format!("Dry run complete. {} log files are ready to upload.", payload["count"]),
2262
+ payload,
2263
+ )?;
2264
+ return Ok(());
2265
+ }
2266
+ if candidates.is_empty() {
2267
+ let payload = serde_json::json!({
2268
+ "ok": true,
2269
+ "uploaded": [],
2270
+ "count": 0
2271
+ });
2272
+ emit_text_or_json(output, "No local log files to upload.", payload)?;
2273
+ return Ok(());
2274
+ }
2275
+
2276
+ let mut session = load_session()?;
2277
+ apply_base_url_override(&mut session, args.base_url);
2278
+ let mut uploaded = Vec::new();
2279
+
2280
+ for candidate in candidates {
2281
+ let bytes = fs::read(&candidate.local_path).with_context(|| {
2282
+ format!("Failed to read log file {}", candidate.local_path.display())
2283
+ })?;
2284
+ let asset = upload_asset_via_intent(
2285
+ client,
2286
+ &mut session,
2287
+ &args.project_id,
2288
+ &candidate.remote_path,
2289
+ bytes,
2290
+ &candidate.content_type,
2291
+ &args.asset_type,
2292
+ &args.visibility,
2293
+ )
2294
+ .await?;
2295
+
2296
+ if args.clear_on_success {
2297
+ let file_name = candidate
2298
+ .local_path
2299
+ .file_name()
2300
+ .and_then(|value| value.to_str())
2301
+ .unwrap_or_default();
2302
+ if file_name.eq_ignore_ascii_case("runtime.jsonl") {
2303
+ fs::write(&candidate.local_path, b"").with_context(|| {
2304
+ format!("Failed to clear runtime log {}", candidate.local_path.display())
2305
+ })?;
2306
+ } else {
2307
+ let _ = fs::remove_file(&candidate.local_path);
2308
+ }
2309
+ }
2310
+
2311
+ uploaded.push(serde_json::json!({
2312
+ "localPath": candidate.local_path.display().to_string(),
2313
+ "remotePath": candidate.remote_path,
2314
+ "asset": asset
2315
+ }));
1163
2316
  }
1164
2317
 
2318
+ save_session(&session)?;
2319
+ let payload = serde_json::json!({
2320
+ "ok": true,
2321
+ "count": uploaded.len(),
2322
+ "uploaded": uploaded
2323
+ });
2324
+ emit_text_or_json(
2325
+ output,
2326
+ &format!("Uploaded {} log files.", payload["count"]),
2327
+ payload,
2328
+ )?;
1165
2329
  Ok(())
1166
2330
  }
1167
2331
 
@@ -1247,29 +2411,22 @@ async fn token_revoke_command(client: &reqwest::Client, args: TokenRevokeArgs) -
1247
2411
  Ok(())
1248
2412
  }
1249
2413
 
1250
- async fn project_list_command(client: &reqwest::Client, args: ProjectListArgs) -> Result<()> {
2414
+ async fn org_list_command(client: &reqwest::Client, args: BaseArgs) -> Result<()> {
1251
2415
  let mut session = load_session()?;
1252
2416
  apply_base_url_override(&mut session, args.base_url);
1253
2417
 
1254
- let path = match args.org_id {
1255
- Some(org_id) if !org_id.trim().is_empty() => {
1256
- format!("/core/projects?orgId={}", org_id.trim())
1257
- }
1258
- _ => "/core/projects".to_string(),
1259
- };
1260
-
1261
- let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2418
+ let response = authed_request(client, &mut session, Method::GET, "/core/orgs", None).await?;
1262
2419
  if !response.status().is_success() {
1263
2420
  let body = read_error_body(response).await;
1264
- return Err(anyhow!("project list failed: {}", body));
2421
+ return Err(anyhow!("org list failed: {}", body));
1265
2422
  }
1266
- let payload: ListProjectsResponse = response.json().await?;
1267
- println!("{}", serde_json::to_string_pretty(&payload.projects)?);
2423
+ let payload: ListOrgsResponse = response.json().await?;
2424
+ println!("{}", serde_json::to_string_pretty(&payload.orgs)?);
1268
2425
  save_session(&session)?;
1269
2426
  Ok(())
1270
2427
  }
1271
2428
 
1272
- async fn project_create_command(client: &reqwest::Client, args: ProjectCreateArgs) -> Result<()> {
2429
+ async fn org_create_command(client: &reqwest::Client, args: OrgCreateArgs) -> Result<()> {
1273
2430
  let mut session = load_session()?;
1274
2431
  apply_base_url_override(&mut session, args.base_url);
1275
2432
 
@@ -1277,48 +2434,341 @@ async fn project_create_command(client: &reqwest::Client, args: ProjectCreateArg
1277
2434
  client,
1278
2435
  &mut session,
1279
2436
  Method::POST,
1280
- "/core/projects",
2437
+ "/core/orgs",
1281
2438
  Some(serde_json::json!({
1282
- "orgId": args.org_id,
1283
- "name": args.name,
1284
- "description": args.description
2439
+ "name": args.name
1285
2440
  })),
1286
2441
  )
1287
2442
  .await?;
1288
2443
  if !response.status().is_success() {
1289
2444
  let body = read_error_body(response).await;
1290
- return Err(anyhow!("project create failed: {}", body));
2445
+ return Err(anyhow!("org create failed: {}", body));
1291
2446
  }
1292
- let payload: ProjectResponse = response.json().await?;
1293
- println!("{}", serde_json::to_string_pretty(&payload.project)?);
2447
+ let payload: OrgResponse = response.json().await?;
2448
+ println!("{}", serde_json::to_string_pretty(&payload.org)?);
1294
2449
  save_session(&session)?;
1295
2450
  Ok(())
1296
2451
  }
1297
2452
 
1298
- async fn project_get_command(client: &reqwest::Client, args: ProjectGetArgs) -> Result<()> {
2453
+ async fn org_get_command(client: &reqwest::Client, args: OrgGetArgs) -> Result<()> {
1299
2454
  let mut session = load_session()?;
1300
2455
  apply_base_url_override(&mut session, args.base_url);
1301
2456
 
1302
- let path = format!("/core/projects/{}", args.project_id);
2457
+ let path = format!("/core/orgs/{}", args.org_id);
1303
2458
  let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
1304
2459
  if !response.status().is_success() {
1305
2460
  let body = read_error_body(response).await;
1306
- return Err(anyhow!("project get failed: {}", body));
2461
+ return Err(anyhow!("org get failed: {}", body));
1307
2462
  }
1308
- let payload: ProjectResponse = response.json().await?;
1309
- println!("{}", serde_json::to_string_pretty(&payload.project)?);
2463
+ let payload: OrgResponse = response.json().await?;
2464
+ println!("{}", serde_json::to_string_pretty(&payload)?);
1310
2465
  save_session(&session)?;
1311
2466
  Ok(())
1312
2467
  }
1313
2468
 
1314
- async fn project_update_command(client: &reqwest::Client, args: ProjectUpdateArgs) -> Result<()> {
2469
+ async fn org_update_command(client: &reqwest::Client, args: OrgUpdateArgs) -> Result<()> {
1315
2470
  let mut session = load_session()?;
1316
2471
  apply_base_url_override(&mut session, args.base_url);
1317
2472
 
1318
- let mut body = serde_json::Map::new();
1319
- if let Some(name) = args.name {
1320
- body.insert("name".to_string(), serde_json::Value::String(name));
1321
- }
2473
+ let path = format!("/core/orgs/{}", args.org_id);
2474
+ let response = authed_request(
2475
+ client,
2476
+ &mut session,
2477
+ Method::PATCH,
2478
+ &path,
2479
+ Some(serde_json::json!({
2480
+ "name": args.name
2481
+ })),
2482
+ )
2483
+ .await?;
2484
+ if !response.status().is_success() {
2485
+ let body = read_error_body(response).await;
2486
+ return Err(anyhow!("org update failed: {}", body));
2487
+ }
2488
+ let payload: OrgResponse = response.json().await?;
2489
+ println!("{}", serde_json::to_string_pretty(&payload.org)?);
2490
+ save_session(&session)?;
2491
+ Ok(())
2492
+ }
2493
+
2494
+ async fn org_delete_command(client: &reqwest::Client, args: OrgDeleteArgs) -> Result<()> {
2495
+ let mut session = load_session()?;
2496
+ apply_base_url_override(&mut session, args.base_url);
2497
+
2498
+ let path = format!("/core/orgs/{}", args.org_id);
2499
+ let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
2500
+ if !response.status().is_success() {
2501
+ let body = read_error_body(response).await;
2502
+ return Err(anyhow!("org delete failed: {}", body));
2503
+ }
2504
+ let payload: serde_json::Value = response.json().await?;
2505
+ println!("{}", serde_json::to_string_pretty(&payload)?);
2506
+ save_session(&session)?;
2507
+ Ok(())
2508
+ }
2509
+
2510
+ async fn org_invites_command(client: &reqwest::Client, args: OrgInvitesArgs) -> Result<()> {
2511
+ let mut session = load_session()?;
2512
+ apply_base_url_override(&mut session, args.base_url);
2513
+
2514
+ let path = format!("/core/orgs/{}/invites", args.org_id);
2515
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2516
+ if !response.status().is_success() {
2517
+ let body = read_error_body(response).await;
2518
+ if let Some(api_error) = parse_api_error(&body) {
2519
+ match api_error.code.as_deref() {
2520
+ Some("CLERK_ORG_NOT_LINKED") => {
2521
+ return Err(anyhow!(
2522
+ "org invites unavailable: {}. Action: create this organization from a Clerk-authenticated session, then retry.",
2523
+ api_error
2524
+ .message
2525
+ .unwrap_or_else(|| "organization is not linked to Clerk".to_string())
2526
+ ));
2527
+ }
2528
+ Some("CLERK_NOT_CONFIGURED") => {
2529
+ return Err(anyhow!(
2530
+ "org invites unavailable: {}. Action: configure CLERK_SECRET_KEY on the API deployment.",
2531
+ api_error
2532
+ .message
2533
+ .unwrap_or_else(|| "Clerk is not configured".to_string())
2534
+ ));
2535
+ }
2536
+ _ => {}
2537
+ }
2538
+ }
2539
+ return Err(anyhow!("org invites failed: {}", body));
2540
+ }
2541
+ let payload: ListOrgInvitesResponse = response.json().await?;
2542
+ println!("{}", serde_json::to_string_pretty(&payload.invites)?);
2543
+ save_session(&session)?;
2544
+ Ok(())
2545
+ }
2546
+
2547
+ async fn org_invite_command(client: &reqwest::Client, args: OrgInviteArgs) -> Result<()> {
2548
+ let mut session = load_session()?;
2549
+ apply_base_url_override(&mut session, args.base_url);
2550
+
2551
+ let role = args.role.trim().to_lowercase();
2552
+ if role != "member" && role != "admin" {
2553
+ return Err(anyhow!("org invite role must be either 'member' or 'admin'"));
2554
+ }
2555
+ if args.expires_in_days == 0 || args.expires_in_days > 30 {
2556
+ return Err(anyhow!("org invite expires_in_days must be between 1 and 30"));
2557
+ }
2558
+
2559
+ let path = format!("/core/orgs/{}/invites", args.org_id);
2560
+ let response = authed_request(
2561
+ client,
2562
+ &mut session,
2563
+ Method::POST,
2564
+ &path,
2565
+ Some(serde_json::json!({
2566
+ "email": args.email.trim(),
2567
+ "role": role,
2568
+ "expiresInDays": args.expires_in_days
2569
+ })),
2570
+ )
2571
+ .await?;
2572
+ if !response.status().is_success() {
2573
+ let body = read_error_body(response).await;
2574
+ if let Some(api_error) = parse_api_error(&body) {
2575
+ match api_error.code.as_deref() {
2576
+ Some("CLERK_USER_NOT_LINKED") => {
2577
+ return Err(anyhow!(
2578
+ "org invite unavailable: {}. Action: login via Clerk on the web once, then rerun this command.",
2579
+ api_error
2580
+ .message
2581
+ .unwrap_or_else(|| "current user is not linked to Clerk".to_string())
2582
+ ));
2583
+ }
2584
+ Some("CLERK_ORG_NOT_LINKED") => {
2585
+ return Err(anyhow!(
2586
+ "org invite unavailable: {}. Action: create this organization from a Clerk-authenticated session, then retry.",
2587
+ api_error
2588
+ .message
2589
+ .unwrap_or_else(|| "organization is not linked to Clerk".to_string())
2590
+ ));
2591
+ }
2592
+ Some("CLERK_NOT_CONFIGURED") => {
2593
+ return Err(anyhow!(
2594
+ "org invite unavailable: {}. Action: configure CLERK_SECRET_KEY on the API deployment.",
2595
+ api_error
2596
+ .message
2597
+ .unwrap_or_else(|| "Clerk is not configured".to_string())
2598
+ ));
2599
+ }
2600
+ _ => {}
2601
+ }
2602
+ }
2603
+ return Err(anyhow!("org invite failed: {}", body));
2604
+ }
2605
+ let payload: OrgInviteResponse = response.json().await?;
2606
+ println!("{}", serde_json::to_string_pretty(&payload.invite)?);
2607
+ save_session(&session)?;
2608
+ Ok(())
2609
+ }
2610
+
2611
+ async fn org_members_command(client: &reqwest::Client, args: OrgMembersArgs) -> Result<()> {
2612
+ let mut session = load_session()?;
2613
+ apply_base_url_override(&mut session, args.base_url);
2614
+
2615
+ let path = format!("/core/orgs/{}/members", args.org_id);
2616
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2617
+ if !response.status().is_success() {
2618
+ let body = read_error_body(response).await;
2619
+ return Err(anyhow!("org members failed: {}", body));
2620
+ }
2621
+ let payload: ListOrgMembersResponse = response.json().await?;
2622
+ println!("{}", serde_json::to_string_pretty(&payload.members)?);
2623
+ save_session(&session)?;
2624
+ Ok(())
2625
+ }
2626
+
2627
+ async fn org_add_member_command(client: &reqwest::Client, args: OrgAddMemberArgs) -> Result<()> {
2628
+ let mut session = load_session()?;
2629
+ apply_base_url_override(&mut session, args.base_url);
2630
+
2631
+ let path = format!("/core/orgs/{}/members", args.org_id);
2632
+ let response = authed_request(
2633
+ client,
2634
+ &mut session,
2635
+ Method::POST,
2636
+ &path,
2637
+ Some(serde_json::json!({
2638
+ "email": args.email,
2639
+ "role": args.role
2640
+ })),
2641
+ )
2642
+ .await?;
2643
+ if !response.status().is_success() {
2644
+ let body = read_error_body(response).await;
2645
+ return Err(anyhow!("org add member failed: {}", body));
2646
+ }
2647
+ let payload: OrgMemberResponse = response.json().await?;
2648
+ println!("{}", serde_json::to_string_pretty(&payload.member)?);
2649
+ save_session(&session)?;
2650
+ Ok(())
2651
+ }
2652
+
2653
+ async fn org_update_member_command(client: &reqwest::Client, args: OrgUpdateMemberArgs) -> Result<()> {
2654
+ let mut session = load_session()?;
2655
+ apply_base_url_override(&mut session, args.base_url);
2656
+
2657
+ let path = format!("/core/orgs/{}/members/{}", args.org_id, args.user_id);
2658
+ let response = authed_request(
2659
+ client,
2660
+ &mut session,
2661
+ Method::PATCH,
2662
+ &path,
2663
+ Some(serde_json::json!({
2664
+ "role": args.role
2665
+ })),
2666
+ )
2667
+ .await?;
2668
+ if !response.status().is_success() {
2669
+ let body = read_error_body(response).await;
2670
+ return Err(anyhow!("org update member failed: {}", body));
2671
+ }
2672
+ let payload: OrgMemberResponse = response.json().await?;
2673
+ println!("{}", serde_json::to_string_pretty(&payload.member)?);
2674
+ save_session(&session)?;
2675
+ Ok(())
2676
+ }
2677
+
2678
+ async fn org_remove_member_command(client: &reqwest::Client, args: OrgRemoveMemberArgs) -> Result<()> {
2679
+ let mut session = load_session()?;
2680
+ apply_base_url_override(&mut session, args.base_url);
2681
+
2682
+ let path = format!("/core/orgs/{}/members/{}", args.org_id, args.user_id);
2683
+ let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
2684
+ if !response.status().is_success() {
2685
+ let body = read_error_body(response).await;
2686
+ return Err(anyhow!("org remove member failed: {}", body));
2687
+ }
2688
+ let payload: serde_json::Value = response.json().await?;
2689
+ println!("{}", serde_json::to_string_pretty(&payload)?);
2690
+ save_session(&session)?;
2691
+ Ok(())
2692
+ }
2693
+
2694
+ async fn project_list_command(client: &reqwest::Client, args: ProjectListArgs) -> Result<()> {
2695
+ let mut session = load_session()?;
2696
+ apply_base_url_override(&mut session, args.base_url);
2697
+
2698
+ let path = match args.org_id {
2699
+ Some(org_id) if !org_id.trim().is_empty() => {
2700
+ format!("/core/projects?orgId={}", org_id.trim())
2701
+ }
2702
+ _ => "/core/projects".to_string(),
2703
+ };
2704
+
2705
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2706
+ if !response.status().is_success() {
2707
+ let body = read_error_body(response).await;
2708
+ return Err(anyhow!("project list failed: {}", body));
2709
+ }
2710
+ let payload: ListProjectsResponse = response.json().await?;
2711
+ println!("{}", serde_json::to_string_pretty(&payload.projects)?);
2712
+ save_session(&session)?;
2713
+ Ok(())
2714
+ }
2715
+
2716
+ async fn project_create_command(client: &reqwest::Client, args: ProjectCreateArgs) -> Result<()> {
2717
+ let mut session = load_session()?;
2718
+ apply_base_url_override(&mut session, args.base_url);
2719
+
2720
+ let mut body = serde_json::Map::new();
2721
+ body.insert("orgId".to_string(), serde_json::Value::String(args.org_id));
2722
+ body.insert("name".to_string(), serde_json::Value::String(args.name));
2723
+ if let Some(description) = args.description {
2724
+ body.insert(
2725
+ "description".to_string(),
2726
+ serde_json::Value::String(description),
2727
+ );
2728
+ }
2729
+
2730
+ let response = authed_request(
2731
+ client,
2732
+ &mut session,
2733
+ Method::POST,
2734
+ "/core/projects",
2735
+ Some(serde_json::Value::Object(body)),
2736
+ )
2737
+ .await?;
2738
+ if !response.status().is_success() {
2739
+ let body = read_error_body(response).await;
2740
+ return Err(anyhow!("project create failed: {}", body));
2741
+ }
2742
+ let payload: ProjectResponse = response.json().await?;
2743
+ println!("{}", serde_json::to_string_pretty(&payload.project)?);
2744
+ save_session(&session)?;
2745
+ Ok(())
2746
+ }
2747
+
2748
+ async fn project_get_command(client: &reqwest::Client, args: ProjectGetArgs) -> Result<()> {
2749
+ let mut session = load_session()?;
2750
+ apply_base_url_override(&mut session, args.base_url);
2751
+
2752
+ let path = format!("/core/projects/{}", args.project_id);
2753
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2754
+ if !response.status().is_success() {
2755
+ let body = read_error_body(response).await;
2756
+ return Err(anyhow!("project get failed: {}", body));
2757
+ }
2758
+ let payload: ProjectResponse = response.json().await?;
2759
+ println!("{}", serde_json::to_string_pretty(&payload.project)?);
2760
+ save_session(&session)?;
2761
+ Ok(())
2762
+ }
2763
+
2764
+ async fn project_update_command(client: &reqwest::Client, args: ProjectUpdateArgs) -> Result<()> {
2765
+ let mut session = load_session()?;
2766
+ apply_base_url_override(&mut session, args.base_url);
2767
+
2768
+ let mut body = serde_json::Map::new();
2769
+ if let Some(name) = args.name {
2770
+ body.insert("name".to_string(), serde_json::Value::String(name));
2771
+ }
1322
2772
  if args.clear_description {
1323
2773
  body.insert("description".to_string(), serde_json::Value::Null);
1324
2774
  } else if let Some(description) = args.description {
@@ -1328,44 +2778,1315 @@ async fn project_update_command(client: &reqwest::Client, args: ProjectUpdateArg
1328
2778
  );
1329
2779
  }
1330
2780
 
1331
- if body.is_empty() {
2781
+ if body.is_empty() {
2782
+ return Err(anyhow!(
2783
+ "project update requires at least one field (--name, --description, or --clear-description)"
2784
+ ));
2785
+ }
2786
+
2787
+ let path = format!("/core/projects/{}", args.project_id);
2788
+ let response = authed_request(
2789
+ client,
2790
+ &mut session,
2791
+ Method::PATCH,
2792
+ &path,
2793
+ Some(serde_json::Value::Object(body)),
2794
+ )
2795
+ .await?;
2796
+ if !response.status().is_success() {
2797
+ let body = read_error_body(response).await;
2798
+ return Err(anyhow!("project update failed: {}", body));
2799
+ }
2800
+ let payload: ProjectResponse = response.json().await?;
2801
+ println!("{}", serde_json::to_string_pretty(&payload.project)?);
2802
+ save_session(&session)?;
2803
+ Ok(())
2804
+ }
2805
+
2806
+ async fn project_delete_command(client: &reqwest::Client, args: ProjectDeleteArgs) -> Result<()> {
2807
+ let mut session = load_session()?;
2808
+ apply_base_url_override(&mut session, args.base_url);
2809
+
2810
+ let path = format!("/core/projects/{}", args.project_id);
2811
+ let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
2812
+ if !response.status().is_success() {
2813
+ let body = read_error_body(response).await;
2814
+ return Err(anyhow!("project delete failed: {}", body));
2815
+ }
2816
+ let payload: serde_json::Value = response.json().await?;
2817
+ println!("{}", serde_json::to_string_pretty(&payload)?);
2818
+ save_session(&session)?;
2819
+ Ok(())
2820
+ }
2821
+
2822
+ async fn project_members_command(client: &reqwest::Client, args: ProjectMembersArgs) -> Result<()> {
2823
+ let mut session = load_session()?;
2824
+ apply_base_url_override(&mut session, args.base_url);
2825
+
2826
+ let path = format!("/core/projects/{}/members", args.project_id);
2827
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2828
+ if !response.status().is_success() {
2829
+ let body = read_error_body(response).await;
2830
+ return Err(anyhow!("project members failed: {}", body));
2831
+ }
2832
+ let payload: ListProjectMembersResponse = response.json().await?;
2833
+ println!("{}", serde_json::to_string_pretty(&payload.members)?);
2834
+ save_session(&session)?;
2835
+ Ok(())
2836
+ }
2837
+
2838
+ async fn project_add_member_command(client: &reqwest::Client, args: ProjectAddMemberArgs) -> Result<()> {
2839
+ let mut session = load_session()?;
2840
+ apply_base_url_override(&mut session, args.base_url);
2841
+
2842
+ let path = format!("/core/projects/{}/members", args.project_id);
2843
+ let response = authed_request(
2844
+ client,
2845
+ &mut session,
2846
+ Method::POST,
2847
+ &path,
2848
+ Some(serde_json::json!({
2849
+ "email": args.email,
2850
+ "role": args.role
2851
+ })),
2852
+ )
2853
+ .await?;
2854
+ if !response.status().is_success() {
2855
+ let body = read_error_body(response).await;
2856
+ return Err(anyhow!("project add member failed: {}", body));
2857
+ }
2858
+ let payload: ProjectMemberResponse = response.json().await?;
2859
+ println!("{}", serde_json::to_string_pretty(&payload.member)?);
2860
+ save_session(&session)?;
2861
+ Ok(())
2862
+ }
2863
+
2864
+ async fn project_update_member_command(
2865
+ client: &reqwest::Client,
2866
+ args: ProjectUpdateMemberArgs,
2867
+ ) -> Result<()> {
2868
+ let mut session = load_session()?;
2869
+ apply_base_url_override(&mut session, args.base_url);
2870
+
2871
+ let path = format!("/core/projects/{}/members/{}", args.project_id, args.user_id);
2872
+ let response = authed_request(
2873
+ client,
2874
+ &mut session,
2875
+ Method::PATCH,
2876
+ &path,
2877
+ Some(serde_json::json!({
2878
+ "role": args.role
2879
+ })),
2880
+ )
2881
+ .await?;
2882
+ if !response.status().is_success() {
2883
+ let body = read_error_body(response).await;
2884
+ return Err(anyhow!("project update member failed: {}", body));
2885
+ }
2886
+ let payload: ProjectMemberResponse = response.json().await?;
2887
+ println!("{}", serde_json::to_string_pretty(&payload.member)?);
2888
+ save_session(&session)?;
2889
+ Ok(())
2890
+ }
2891
+
2892
+ async fn project_remove_member_command(
2893
+ client: &reqwest::Client,
2894
+ args: ProjectRemoveMemberArgs,
2895
+ ) -> Result<()> {
2896
+ let mut session = load_session()?;
2897
+ apply_base_url_override(&mut session, args.base_url);
2898
+
2899
+ let path = format!("/core/projects/{}/members/{}", args.project_id, args.user_id);
2900
+ let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
2901
+ if !response.status().is_success() {
2902
+ let body = read_error_body(response).await;
2903
+ return Err(anyhow!("project remove member failed: {}", body));
2904
+ }
2905
+ let payload: serde_json::Value = response.json().await?;
2906
+ println!("{}", serde_json::to_string_pretty(&payload)?);
2907
+ save_session(&session)?;
2908
+ Ok(())
2909
+ }
2910
+
2911
+ async fn verify_project_access(
2912
+ client: &reqwest::Client,
2913
+ session: &mut SessionConfig,
2914
+ project_id: &str,
2915
+ ) -> Result<()> {
2916
+ let path = format!("/core/projects/{}", project_id);
2917
+ let response = authed_request(client, session, Method::GET, &path, None).await?;
2918
+ if response.status().is_success() {
2919
+ return Ok(());
2920
+ }
2921
+ let body = read_error_body(response).await;
2922
+ Err(anyhow!(
2923
+ "project verification failed for {}: {}",
2924
+ project_id,
2925
+ body
2926
+ ))
2927
+ }
2928
+
2929
+ fn file_name_component(path: &str) -> String {
2930
+ Path::new(path)
2931
+ .file_name()
2932
+ .and_then(|value| value.to_str())
2933
+ .unwrap_or(path)
2934
+ .to_string()
2935
+ }
2936
+
2937
+ fn detect_local_runtime_platform_arch() -> (String, String) {
2938
+ let platform = match std::env::consts::OS {
2939
+ "windows" => "win32",
2940
+ "macos" => "darwin",
2941
+ "linux" => "linux",
2942
+ other => other,
2943
+ }
2944
+ .to_string();
2945
+ let arch = match std::env::consts::ARCH {
2946
+ "x86_64" => "x64",
2947
+ "aarch64" => "arm64",
2948
+ other => other,
2949
+ }
2950
+ .to_string();
2951
+ (platform, arch)
2952
+ }
2953
+
2954
+ fn build_unreal_link_manifest_payload(
2955
+ link: &UnrealLinkRecord,
2956
+ include_local_paths: bool,
2957
+ ) -> serde_json::Value {
2958
+ let mut payload = serde_json::json!({
2959
+ "schemaVersion": 1,
2960
+ "provider": "unreal",
2961
+ "projectId": link.project_id,
2962
+ "uprojectFile": file_name_component(&link.uproject_path),
2963
+ "projectRootName": file_name_component(&link.project_root),
2964
+ "editorBinary": file_name_component(&link.editor_path),
2965
+ "engineRootName": link.engine_root.as_ref().map(|value| file_name_component(value)),
2966
+ "updatedAtEpochMs": link.updated_at_epoch_ms
2967
+ });
2968
+
2969
+ if include_local_paths {
2970
+ payload["localPaths"] = serde_json::json!({
2971
+ "uprojectPath": link.uproject_path,
2972
+ "projectRoot": link.project_root,
2973
+ "editorPath": link.editor_path,
2974
+ "engineRoot": link.engine_root
2975
+ });
2976
+ }
2977
+ payload
2978
+ }
2979
+
2980
+ async fn sync_unreal_link_manifest_asset(
2981
+ client: &reqwest::Client,
2982
+ session: &mut SessionConfig,
2983
+ link: &UnrealLinkRecord,
2984
+ include_local_paths: bool,
2985
+ ) -> Result<AssetRecord> {
2986
+ let payload = build_unreal_link_manifest_payload(link, include_local_paths);
2987
+ let bytes = serde_json::to_vec_pretty(&payload)?;
2988
+ upload_asset_via_intent(
2989
+ client,
2990
+ session,
2991
+ &link.project_id,
2992
+ ".reallink/link/unreal-link.latest.json",
2993
+ bytes,
2994
+ "application/json",
2995
+ "other",
2996
+ "private",
2997
+ )
2998
+ .await
2999
+ }
3000
+
3001
+ pub(crate) async fn link_unreal_command(client: &reqwest::Client, args: LinkUnrealArgs) -> Result<()> {
3002
+ let uproject_path = resolve_uproject_path(&args.uproject)?;
3003
+ let project_root = uproject_path
3004
+ .parent()
3005
+ .ok_or_else(|| anyhow!("Invalid uproject path {}", uproject_path.display()))?
3006
+ .to_path_buf();
3007
+ let (editor_path, engine_root_path) = resolve_editor_and_engine(args.editor, args.engine_root)?;
3008
+
3009
+ let now = now_epoch_ms();
3010
+ let link_record = UnrealLinkRecord {
3011
+ project_id: args.project_id.clone(),
3012
+ uproject_path: uproject_path.display().to_string(),
3013
+ project_root: project_root.display().to_string(),
3014
+ engine_root: engine_root_path
3015
+ .as_ref()
3016
+ .map(|value| value.display().to_string()),
3017
+ editor_path: editor_path.display().to_string(),
3018
+ created_at_epoch_ms: now,
3019
+ updated_at_epoch_ms: now,
3020
+ };
3021
+
3022
+ let mut synced_asset: Option<AssetRecord> = None;
3023
+ if !args.no_verify_remote || args.sync_project {
3024
+ let mut session = load_session()?;
3025
+ apply_base_url_override(&mut session, args.base_url.clone());
3026
+ if !args.no_verify_remote {
3027
+ verify_project_access(client, &mut session, &args.project_id).await?;
3028
+ }
3029
+ if args.sync_project {
3030
+ let synced = sync_unreal_link_manifest_asset(
3031
+ client,
3032
+ &mut session,
3033
+ &link_record,
3034
+ args.include_local_paths,
3035
+ )
3036
+ .await?;
3037
+ synced_asset = Some(synced);
3038
+ }
3039
+ save_session(&session)?;
3040
+ }
3041
+
3042
+ let uproject_cmp = normalize_path_for_compare(&uproject_path);
3043
+ let mut config = load_unreal_links()?;
3044
+ config.links.retain(|entry| {
3045
+ entry.project_id != args.project_id
3046
+ && normalize_path_string_for_compare(&entry.uproject_path) != uproject_cmp
3047
+ });
3048
+ config.links.push(link_record.clone());
3049
+
3050
+ if args.set_default || config.default_project_id.is_none() {
3051
+ config.default_project_id = Some(args.project_id.clone());
3052
+ }
3053
+
3054
+ save_unreal_links(&config)?;
3055
+ println!(
3056
+ "{}",
3057
+ serde_json::to_string_pretty(&serde_json::json!({
3058
+ "ok": true,
3059
+ "defaultProjectId": config.default_project_id,
3060
+ "link": config.links.iter().find(|entry| entry.project_id == args.project_id),
3061
+ "syncedAssetId": synced_asset.as_ref().map(|entry| entry.id.clone()),
3062
+ "syncedManifestPath": if args.sync_project { Some(".reallink/link/unreal-link.latest.json") } else { None }
3063
+ }))?
3064
+ );
3065
+ Ok(())
3066
+ }
3067
+
3068
+ pub(crate) async fn link_list_command() -> Result<()> {
3069
+ let config = load_unreal_links()?;
3070
+ println!("{}", serde_json::to_string_pretty(&config)?);
3071
+ Ok(())
3072
+ }
3073
+
3074
+ pub(crate) async fn link_use_command(args: LinkUseArgs) -> Result<()> {
3075
+ let mut config = load_unreal_links()?;
3076
+ if !config
3077
+ .links
3078
+ .iter()
3079
+ .any(|entry| entry.project_id == args.project_id)
3080
+ {
3081
+ return Err(anyhow!(
3082
+ "No link found for project {}. Run `reallink link unreal ...` first.",
3083
+ args.project_id
3084
+ ));
3085
+ }
3086
+ config.default_project_id = Some(args.project_id.clone());
3087
+ save_unreal_links(&config)?;
3088
+ println!(
3089
+ "{}",
3090
+ serde_json::to_string_pretty(&serde_json::json!({
3091
+ "ok": true,
3092
+ "defaultProjectId": args.project_id
3093
+ }))?
3094
+ );
3095
+ Ok(())
3096
+ }
3097
+
3098
+ fn normalize_match_path(path: &Path) -> String {
3099
+ if let Ok(canonical) = canonicalize_existing_path(path, "uproject") {
3100
+ return normalize_path_for_compare(&canonical);
3101
+ }
3102
+ let absolute = if path.is_absolute() {
3103
+ path.to_path_buf()
3104
+ } else {
3105
+ match std::env::current_dir() {
3106
+ Ok(dir) => dir.join(path),
3107
+ Err(_) => path.to_path_buf(),
3108
+ }
3109
+ };
3110
+ normalize_path_for_compare(&absolute)
3111
+ }
3112
+
3113
+ pub(crate) async fn link_remove_command(args: LinkRemoveArgs) -> Result<()> {
3114
+ if args.project_id.is_none() && args.uproject.is_none() {
3115
+ return Err(anyhow!(
3116
+ "Provide --project-id or --uproject to remove a link."
3117
+ ));
3118
+ }
3119
+
3120
+ let mut config = load_unreal_links()?;
3121
+ let before = config.links.len();
3122
+ let target_uproject_cmp = args
3123
+ .uproject
3124
+ .as_ref()
3125
+ .map(|value| normalize_match_path(value));
3126
+ config.links.retain(|entry| {
3127
+ if let Some(project_id) = args.project_id.as_ref() {
3128
+ if &entry.project_id == project_id {
3129
+ return false;
3130
+ }
3131
+ }
3132
+ if let Some(target_cmp) = target_uproject_cmp.as_ref() {
3133
+ if normalize_path_string_for_compare(&entry.uproject_path) == *target_cmp {
3134
+ return false;
3135
+ }
3136
+ }
3137
+ true
3138
+ });
3139
+ let removed = before.saturating_sub(config.links.len());
3140
+ if removed == 0 {
3141
+ return Err(anyhow!("No matching link found."));
3142
+ }
3143
+
3144
+ if let Some(default_id) = config.default_project_id.clone() {
3145
+ let has_default = config
3146
+ .links
3147
+ .iter()
3148
+ .any(|entry| entry.project_id == default_id);
3149
+ if !has_default {
3150
+ config.default_project_id = config.links.first().map(|entry| entry.project_id.clone());
3151
+ }
3152
+ }
3153
+
3154
+ save_unreal_links(&config)?;
3155
+ println!(
3156
+ "{}",
3157
+ serde_json::to_string_pretty(&serde_json::json!({
3158
+ "ok": true,
3159
+ "removed": removed,
3160
+ "defaultProjectId": config.default_project_id
3161
+ }))?
3162
+ );
3163
+ Ok(())
3164
+ }
3165
+
3166
+ fn resolve_link_target(
3167
+ config: &UnrealLinksConfig,
3168
+ project_id: Option<&str>,
3169
+ uproject: Option<&Path>,
3170
+ ) -> Result<UnrealLinkRecord> {
3171
+ if let Some(project_id) = project_id {
3172
+ let entry = config
3173
+ .links
3174
+ .iter()
3175
+ .find(|item| item.project_id == project_id)
3176
+ .ok_or_else(|| anyhow!("No link found for project {}", project_id))?;
3177
+ return Ok(entry.clone());
3178
+ }
3179
+ if let Some(uproject) = uproject {
3180
+ let target_cmp = normalize_match_path(uproject);
3181
+ let entry = config
3182
+ .links
3183
+ .iter()
3184
+ .find(|item| normalize_path_string_for_compare(&item.uproject_path) == target_cmp)
3185
+ .ok_or_else(|| anyhow!("No link found for uproject {}", uproject.display()))?;
3186
+ return Ok(entry.clone());
3187
+ }
3188
+ if let Some(default_project_id) = config.default_project_id.as_ref() {
3189
+ if let Some(entry) = config
3190
+ .links
3191
+ .iter()
3192
+ .find(|item| &item.project_id == default_project_id)
3193
+ {
3194
+ return Ok(entry.clone());
3195
+ }
3196
+ }
3197
+ if config.links.len() == 1 {
3198
+ return Ok(config.links[0].clone());
3199
+ }
3200
+ Err(anyhow!(
3201
+ "No target specified. Use --project-id or set a default link with `reallink link use --project-id ...`."
3202
+ ))
3203
+ }
3204
+
3205
+ fn unreal_manifest_local_path(link: &UnrealLinkRecord) -> PathBuf {
3206
+ PathBuf::from(&link.project_root)
3207
+ .join(".reallink")
3208
+ .join("link")
3209
+ .join("unreal-link.latest.json")
3210
+ }
3211
+
3212
+ fn push_doctor_check(
3213
+ checks: &mut Vec<serde_json::Value>,
3214
+ errors: &mut Vec<String>,
3215
+ warnings: &mut Vec<String>,
3216
+ key: &str,
3217
+ ok: bool,
3218
+ detail: impl Into<String>,
3219
+ warning_only: bool,
3220
+ ) {
3221
+ let detail_text = detail.into();
3222
+ let status = if ok {
3223
+ "ok"
3224
+ } else if warning_only {
3225
+ "warn"
3226
+ } else {
3227
+ "error"
3228
+ };
3229
+ if !ok {
3230
+ if warning_only {
3231
+ warnings.push(format!("{}: {}", key, detail_text));
3232
+ } else {
3233
+ errors.push(format!("{}: {}", key, detail_text));
3234
+ }
3235
+ }
3236
+ checks.push(serde_json::json!({
3237
+ "key": key,
3238
+ "status": status,
3239
+ "detail": detail_text
3240
+ }));
3241
+ }
3242
+
3243
+ pub(crate) async fn link_paths_command(args: LinkPathsArgs) -> Result<()> {
3244
+ let config = load_unreal_links()?;
3245
+ if config.links.is_empty() {
3246
+ return Err(anyhow!(
3247
+ "No local links configured. Run `reallink link unreal ...` first."
3248
+ ));
3249
+ }
3250
+ let target = resolve_link_target(
3251
+ &config,
3252
+ args.project_id.as_deref(),
3253
+ args.uproject.as_deref(),
3254
+ )?;
3255
+ let project_plugins_dir = PathBuf::from(&target.project_root).join("Plugins");
3256
+ let engine_plugins_dir = target.engine_root.as_ref().map(|root| {
3257
+ PathBuf::from(root)
3258
+ .join("Engine")
3259
+ .join("Plugins")
3260
+ .join("Marketplace")
3261
+ });
3262
+ let manifest_path = unreal_manifest_local_path(&target);
3263
+
3264
+ print_json(&serde_json::json!({
3265
+ "ok": true,
3266
+ "projectId": target.project_id,
3267
+ "paths": {
3268
+ "uprojectPath": target.uproject_path,
3269
+ "projectRoot": target.project_root,
3270
+ "editorPath": target.editor_path,
3271
+ "engineRoot": target.engine_root,
3272
+ "projectPluginsDir": project_plugins_dir.display().to_string(),
3273
+ "enginePluginsDir": engine_plugins_dir.as_ref().map(|value| value.display().to_string()),
3274
+ "manifestPath": manifest_path.display().to_string(),
3275
+ "localLinkConfigPath": unreal_links_path()?.display().to_string()
3276
+ },
3277
+ "remoteManifestAssetPath": ".reallink/link/unreal-link.latest.json"
3278
+ }))?;
3279
+ Ok(())
3280
+ }
3281
+
3282
+ pub(crate) async fn link_doctor_command(client: &reqwest::Client, args: LinkDoctorArgs) -> Result<()> {
3283
+ let config = load_unreal_links()?;
3284
+ if config.links.is_empty() {
3285
+ return Err(anyhow!(
3286
+ "No local links configured. Run `reallink link unreal ...` first."
3287
+ ));
3288
+ }
3289
+ let target = resolve_link_target(
3290
+ &config,
3291
+ args.project_id.as_deref(),
3292
+ args.uproject.as_deref(),
3293
+ )?;
3294
+
3295
+ let mut checks: Vec<serde_json::Value> = Vec::new();
3296
+ let mut warnings: Vec<String> = Vec::new();
3297
+ let mut errors: Vec<String> = Vec::new();
3298
+
3299
+ let project_root = PathBuf::from(&target.project_root);
3300
+ let uproject_path = PathBuf::from(&target.uproject_path);
3301
+ let editor_path = PathBuf::from(&target.editor_path);
3302
+ let engine_root = target.engine_root.as_ref().map(PathBuf::from);
3303
+ let project_plugins_dir = project_root.join("Plugins");
3304
+ let engine_plugins_dir = engine_root
3305
+ .as_ref()
3306
+ .map(|root| root.join("Engine").join("Plugins").join("Marketplace"));
3307
+ let manifest_path = unreal_manifest_local_path(&target);
3308
+
3309
+ push_doctor_check(
3310
+ &mut checks,
3311
+ &mut errors,
3312
+ &mut warnings,
3313
+ "projectRoot.exists",
3314
+ project_root.exists() && project_root.is_dir(),
3315
+ format!("projectRoot={}", project_root.display()),
3316
+ false,
3317
+ );
3318
+ let uproject_ext_ok = uproject_path
3319
+ .extension()
3320
+ .and_then(|value| value.to_str())
3321
+ .map(|value| value.eq_ignore_ascii_case("uproject"))
3322
+ .unwrap_or(false);
3323
+ push_doctor_check(
3324
+ &mut checks,
3325
+ &mut errors,
3326
+ &mut warnings,
3327
+ "uproject.valid",
3328
+ uproject_path.exists() && uproject_path.is_file() && uproject_ext_ok,
3329
+ format!("uprojectPath={}", uproject_path.display()),
3330
+ false,
3331
+ );
3332
+ push_doctor_check(
3333
+ &mut checks,
3334
+ &mut errors,
3335
+ &mut warnings,
3336
+ "editor.valid",
3337
+ editor_path.exists() && editor_path.is_file(),
3338
+ format!("editorPath={}", editor_path.display()),
3339
+ false,
3340
+ );
3341
+ if let Some(root) = engine_root.as_ref() {
3342
+ let root_ok = root.exists() && root.is_dir();
3343
+ push_doctor_check(
3344
+ &mut checks,
3345
+ &mut errors,
3346
+ &mut warnings,
3347
+ "engineRoot.valid",
3348
+ root_ok,
3349
+ format!("engineRoot={}", root.display()),
3350
+ false,
3351
+ );
3352
+ let binaries_path = root.join("Engine").join("Binaries");
3353
+ push_doctor_check(
3354
+ &mut checks,
3355
+ &mut errors,
3356
+ &mut warnings,
3357
+ "engineRoot.layout",
3358
+ binaries_path.exists() && binaries_path.is_dir(),
3359
+ format!("expected Engine/Binaries under {}", root.display()),
3360
+ true,
3361
+ );
3362
+ } else {
3363
+ push_doctor_check(
3364
+ &mut checks,
3365
+ &mut errors,
3366
+ &mut warnings,
3367
+ "engineRoot.present",
3368
+ false,
3369
+ "engineRoot not set on this link; plugin engine-scope operations are unavailable",
3370
+ true,
3371
+ );
3372
+ }
3373
+ push_doctor_check(
3374
+ &mut checks,
3375
+ &mut errors,
3376
+ &mut warnings,
3377
+ "plugins.projectDir",
3378
+ project_plugins_dir.exists() && project_plugins_dir.is_dir(),
3379
+ format!("projectPluginsDir={}", project_plugins_dir.display()),
3380
+ true,
3381
+ );
3382
+ if let Some(engine_plugins) = engine_plugins_dir.as_ref() {
3383
+ push_doctor_check(
3384
+ &mut checks,
3385
+ &mut errors,
3386
+ &mut warnings,
3387
+ "plugins.engineDir",
3388
+ engine_plugins.exists() && engine_plugins.is_dir(),
3389
+ format!("enginePluginsDir={}", engine_plugins.display()),
3390
+ true,
3391
+ );
3392
+ }
3393
+
3394
+ if manifest_path.exists() {
3395
+ let manifest_ok = fs::read_to_string(&manifest_path)
3396
+ .ok()
3397
+ .and_then(|value| json5::from_str::<serde_json::Value>(&value).ok())
3398
+ .is_some();
3399
+ push_doctor_check(
3400
+ &mut checks,
3401
+ &mut errors,
3402
+ &mut warnings,
3403
+ "manifest.local",
3404
+ manifest_ok,
3405
+ format!("manifestPath={}", manifest_path.display()),
3406
+ true,
3407
+ );
3408
+ } else {
3409
+ push_doctor_check(
3410
+ &mut checks,
3411
+ &mut errors,
3412
+ &mut warnings,
3413
+ "manifest.local",
3414
+ false,
3415
+ format!("manifestPath={} (missing)", manifest_path.display()),
3416
+ true,
3417
+ );
3418
+ }
3419
+
3420
+ if args.verify_remote {
3421
+ let (remote_ok, remote_detail) = match load_session() {
3422
+ Ok(mut session) => {
3423
+ apply_base_url_override(&mut session, args.base_url.clone());
3424
+ let outcome = if let Err(error) =
3425
+ verify_project_access(client, &mut session, &target.project_id).await
3426
+ {
3427
+ (false, error.to_string())
3428
+ } else {
3429
+ (
3430
+ true,
3431
+ format!(
3432
+ "projectId={} verified against {}",
3433
+ target.project_id, session.base_url
3434
+ ),
3435
+ )
3436
+ };
3437
+ let _ = save_session(&session);
3438
+ outcome
3439
+ }
3440
+ Err(error) => (false, format!("no active session: {}", error)),
3441
+ };
3442
+ push_doctor_check(
3443
+ &mut checks,
3444
+ &mut errors,
3445
+ &mut warnings,
3446
+ "remote.verify",
3447
+ remote_ok,
3448
+ remote_detail,
3449
+ false,
3450
+ );
3451
+ }
3452
+
3453
+ let ok = errors.is_empty();
3454
+ print_json(&serde_json::json!({
3455
+ "ok": ok,
3456
+ "projectId": target.project_id,
3457
+ "checks": checks,
3458
+ "warnings": warnings,
3459
+ "errors": errors,
3460
+ "paths": {
3461
+ "uprojectPath": uproject_path.display().to_string(),
3462
+ "projectRoot": project_root.display().to_string(),
3463
+ "editorPath": editor_path.display().to_string(),
3464
+ "engineRoot": engine_root.as_ref().map(|value| value.display().to_string()),
3465
+ "projectPluginsDir": project_plugins_dir.display().to_string(),
3466
+ "enginePluginsDir": engine_plugins_dir.as_ref().map(|value| value.display().to_string()),
3467
+ "manifestPath": manifest_path.display().to_string()
3468
+ }
3469
+ }))?;
3470
+ Ok(())
3471
+ }
3472
+
3473
+ pub(crate) async fn link_open_command(args: LinkOpenArgs) -> Result<()> {
3474
+ let config = load_unreal_links()?;
3475
+ if config.links.is_empty() {
3476
+ return Err(anyhow!(
3477
+ "No local links configured. Run `reallink link unreal ...` first."
3478
+ ));
3479
+ }
3480
+ let target = resolve_link_target(
3481
+ &config,
3482
+ args.project_id.as_deref(),
3483
+ args.uproject.as_deref(),
3484
+ )?;
3485
+ let editor_path = PathBuf::from(&target.editor_path);
3486
+ let uproject_path = PathBuf::from(&target.uproject_path);
3487
+
3488
+ if !editor_path.exists() {
3489
+ return Err(anyhow!(
3490
+ "Editor path does not exist anymore: {}",
3491
+ editor_path.display()
3492
+ ));
3493
+ }
3494
+ if !uproject_path.exists() {
3495
+ return Err(anyhow!(
3496
+ "uproject path does not exist anymore: {}",
3497
+ uproject_path.display()
3498
+ ));
3499
+ }
3500
+
3501
+ let mut command = Command::new(&editor_path);
3502
+ command.arg(&uproject_path);
3503
+ for argument in args.extra_arg {
3504
+ command.arg(argument);
3505
+ }
3506
+
3507
+ if args.wait {
3508
+ let status = command
3509
+ .status()
3510
+ .with_context(|| format!("Failed to run Unreal editor {}", editor_path.display()))?;
3511
+ if !status.success() {
3512
+ return Err(anyhow!("Unreal editor exited with {}", status));
3513
+ }
3514
+ println!(
3515
+ "{}",
3516
+ serde_json::to_string_pretty(&serde_json::json!({
3517
+ "ok": true,
3518
+ "projectId": target.project_id,
3519
+ "waited": true,
3520
+ "status": status.code()
3521
+ }))?
3522
+ );
3523
+ } else {
3524
+ let child = command
3525
+ .spawn()
3526
+ .with_context(|| format!("Failed to launch Unreal editor {}", editor_path.display()))?;
3527
+ println!(
3528
+ "{}",
3529
+ serde_json::to_string_pretty(&serde_json::json!({
3530
+ "ok": true,
3531
+ "projectId": target.project_id,
3532
+ "waited": false,
3533
+ "pid": child.id()
3534
+ }))?
3535
+ );
3536
+ }
3537
+
3538
+ Ok(())
3539
+ }
3540
+
3541
+ pub(crate) async fn link_run_command(args: LinkRunArgs) -> Result<()> {
3542
+ let config = load_unreal_links()?;
3543
+ if config.links.is_empty() {
3544
+ return Err(anyhow!(
3545
+ "No local links configured. Run `reallink link unreal ...` first."
3546
+ ));
3547
+ }
3548
+ let target = resolve_link_target(
3549
+ &config,
3550
+ args.project_id.as_deref(),
3551
+ args.uproject.as_deref(),
3552
+ )?;
3553
+ let editor_path = PathBuf::from(&target.editor_path);
3554
+ let uproject_path = PathBuf::from(&target.uproject_path);
3555
+
3556
+ if !editor_path.exists() {
3557
+ return Err(anyhow!(
3558
+ "Editor path does not exist anymore: {}",
3559
+ editor_path.display()
3560
+ ));
3561
+ }
3562
+ if !uproject_path.exists() {
3563
+ return Err(anyhow!(
3564
+ "uproject path does not exist anymore: {}",
3565
+ uproject_path.display()
3566
+ ));
3567
+ }
3568
+
3569
+ let mut command = Command::new(&editor_path);
3570
+ command.arg(&uproject_path);
3571
+ if let Some(commandlet) = args.commandlet.as_ref() {
3572
+ let normalized = commandlet.trim();
3573
+ if normalized.is_empty() {
3574
+ return Err(anyhow!("--commandlet cannot be empty"));
3575
+ }
3576
+ command.arg(format!("-run={}", normalized));
3577
+ }
3578
+ if args.log {
3579
+ command.arg("-log");
3580
+ }
3581
+ if args.headless {
3582
+ command.arg("-unattended");
3583
+ command.arg("-nop4");
3584
+ command.arg("-nosplash");
3585
+ command.arg("-nullrhi");
3586
+ }
3587
+ for argument in args.extra_arg {
3588
+ command.arg(argument);
3589
+ }
3590
+
3591
+ if args.no_wait {
3592
+ let child = command
3593
+ .spawn()
3594
+ .with_context(|| format!("Failed to launch Unreal editor {}", editor_path.display()))?;
3595
+ println!(
3596
+ "{}",
3597
+ serde_json::to_string_pretty(&serde_json::json!({
3598
+ "ok": true,
3599
+ "projectId": target.project_id,
3600
+ "mode": if args.commandlet.is_some() { "commandlet" } else { "editor" },
3601
+ "waited": false,
3602
+ "pid": child.id()
3603
+ }))?
3604
+ );
3605
+ return Ok(());
3606
+ }
3607
+
3608
+ let status = command
3609
+ .status()
3610
+ .with_context(|| format!("Failed to run Unreal editor {}", editor_path.display()))?;
3611
+ if !status.success() {
3612
+ return Err(anyhow!("Unreal editor exited with {}", status));
3613
+ }
3614
+
3615
+ println!(
3616
+ "{}",
3617
+ serde_json::to_string_pretty(&serde_json::json!({
3618
+ "ok": true,
3619
+ "projectId": target.project_id,
3620
+ "mode": if args.commandlet.is_some() { "commandlet" } else { "editor" },
3621
+ "waited": true,
3622
+ "status": status.code()
3623
+ }))?
3624
+ );
3625
+ Ok(())
3626
+ }
3627
+
3628
+ fn normalize_public_bucket_base(base_url: &str) -> Result<String> {
3629
+ let trimmed = base_url.trim().trim_end_matches('/');
3630
+ if trimmed.is_empty() {
3631
+ return Err(anyhow!("Plugin base URL is empty"));
3632
+ }
3633
+ let parsed = reqwest::Url::parse(trimmed)
3634
+ .with_context(|| format!("Invalid plugin base URL {}", trimmed))?;
3635
+ Ok(parsed.to_string().trim_end_matches('/').to_string())
3636
+ }
3637
+
3638
+ fn compose_plugin_archive_url(base_url: &str, name: &str, version: &str) -> Result<String> {
3639
+ let plugin = name.trim();
3640
+ let release = version.trim();
3641
+ if plugin.is_empty() {
3642
+ return Err(anyhow!("Plugin name is required"));
3643
+ }
3644
+ if release.is_empty() {
3645
+ return Err(anyhow!("Plugin version is required"));
3646
+ }
3647
+ if plugin.contains('/') || plugin.contains('\\') {
3648
+ return Err(anyhow!("Plugin name cannot include path separators"));
3649
+ }
3650
+ let base = normalize_public_bucket_base(base_url)?;
3651
+ Ok(format!("{}/{}/{}/{}.zip", base, plugin, release, plugin))
3652
+ }
3653
+
3654
+ fn normalize_sha256_hex(input: &str) -> Result<String> {
3655
+ let cleaned = input.trim().to_ascii_lowercase();
3656
+ if cleaned.len() != 64 {
3657
+ return Err(anyhow!(
3658
+ "SHA-256 must be a 64-character hex string, got length {}",
3659
+ cleaned.len()
3660
+ ));
3661
+ }
3662
+ if !cleaned
3663
+ .chars()
3664
+ .all(|value| matches!(value, '0'..='9' | 'a'..='f'))
3665
+ {
3666
+ return Err(anyhow!("SHA-256 contains non-hex characters"));
3667
+ }
3668
+ Ok(cleaned)
3669
+ }
3670
+
3671
+ async fn fetch_plugin_index_value(
3672
+ client: &reqwest::Client,
3673
+ base_url: &str,
3674
+ index_path: &str,
3675
+ ) -> Result<serde_json::Value> {
3676
+ let base = normalize_public_bucket_base(base_url)?;
3677
+ let normalized_index_path = index_path.trim().trim_start_matches('/');
3678
+ if normalized_index_path.is_empty() {
3679
+ return Err(anyhow!("index path cannot be empty"));
3680
+ }
3681
+ let url = format!("{}/{}", base, normalized_index_path);
3682
+ let response = with_cli_headers(client.get(url.clone()))
3683
+ .send()
3684
+ .await
3685
+ .with_context(|| format!("Failed to fetch plugin index {}", url))?;
3686
+ if !response.status().is_success() {
3687
+ let body = read_error_body(response).await;
3688
+ return Err(anyhow!("plugin index fetch failed: {}", body));
3689
+ }
3690
+ let body = response
3691
+ .text()
3692
+ .await
3693
+ .with_context(|| format!("Failed to read plugin index {}", url))?;
3694
+ serde_json::from_str(&body)
3695
+ .or_else(|_| json5::from_str(&body))
3696
+ .with_context(|| format!("Plugin index is not valid JSON/JSONC: {}", url))
3697
+ }
3698
+
3699
+ async fn fetch_plugin_index(
3700
+ client: &reqwest::Client,
3701
+ base_url: &str,
3702
+ index_path: &str,
3703
+ ) -> Result<PluginIndexFile> {
3704
+ let value = fetch_plugin_index_value(client, base_url, index_path).await?;
3705
+ let parsed: PluginIndexFile = serde_json::from_value(value)
3706
+ .with_context(|| "Plugin index does not match expected schema")?;
3707
+ Ok(parsed)
3708
+ }
3709
+
3710
+ fn resolve_plugin_from_index(
3711
+ index: &PluginIndexFile,
3712
+ name: &str,
3713
+ requested_version: &str,
3714
+ base_url: &str,
3715
+ ) -> Result<(String, String, Option<String>)> {
3716
+ let plugin_name = name.trim();
3717
+ let version_request = requested_version.trim();
3718
+ if plugin_name.is_empty() {
3719
+ return Err(anyhow!("Plugin name is required"));
3720
+ }
3721
+ if version_request.is_empty() {
3722
+ return Err(anyhow!("Plugin version is required"));
3723
+ }
3724
+
3725
+ let plugin = index
3726
+ .plugins
3727
+ .iter()
3728
+ .find(|entry| entry.name.eq_ignore_ascii_case(plugin_name))
3729
+ .ok_or_else(|| anyhow!("Plugin {} not found in index", plugin_name))?;
3730
+
3731
+ let resolved_version = if version_request.eq_ignore_ascii_case("latest") {
3732
+ plugin
3733
+ .latest
3734
+ .as_ref()
3735
+ .map(|value| value.trim().to_string())
3736
+ .or_else(|| {
3737
+ plugin
3738
+ .versions
3739
+ .first()
3740
+ .map(|entry| entry.version.trim().to_string())
3741
+ })
3742
+ .ok_or_else(|| anyhow!("Plugin {} has no versions in index", plugin_name))?
3743
+ } else {
3744
+ version_request.to_string()
3745
+ };
3746
+
3747
+ let version_entry = plugin
3748
+ .versions
3749
+ .iter()
3750
+ .find(|entry| entry.version.trim() == resolved_version)
3751
+ .ok_or_else(|| {
3752
+ anyhow!(
3753
+ "Version {} for plugin {} not found in index",
3754
+ resolved_version,
3755
+ plugin_name
3756
+ )
3757
+ })?;
3758
+
3759
+ let archive_url = if let Some(url) = version_entry.archive_url.as_ref() {
3760
+ let trimmed = url.trim();
3761
+ if trimmed.is_empty() {
3762
+ compose_plugin_archive_url(base_url, plugin_name, &resolved_version)?
3763
+ } else {
3764
+ trimmed.to_string()
3765
+ }
3766
+ } else {
3767
+ compose_plugin_archive_url(base_url, plugin_name, &resolved_version)?
3768
+ };
3769
+
3770
+ let expected_sha256 = version_entry
3771
+ .sha256
3772
+ .as_ref()
3773
+ .map(|value| normalize_sha256_hex(value))
3774
+ .transpose()?;
3775
+
3776
+ Ok((resolved_version, archive_url, expected_sha256))
3777
+ }
3778
+
3779
+ fn compute_sha256_hex(path: &Path) -> Result<String> {
3780
+ let mut file =
3781
+ fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
3782
+ let mut hasher = sha2::Sha256::new();
3783
+ let mut buffer = [0u8; 64 * 1024];
3784
+ loop {
3785
+ let read = file
3786
+ .read(&mut buffer)
3787
+ .with_context(|| format!("Failed reading {}", path.display()))?;
3788
+ if read == 0 {
3789
+ break;
3790
+ }
3791
+ hasher.update(&buffer[..read]);
3792
+ }
3793
+ let digest = hasher.finalize();
3794
+ Ok(format!("{:x}", digest))
3795
+ }
3796
+
3797
+ async fn download_plugin_archive_to_file(
3798
+ client: &reqwest::Client,
3799
+ url: &str,
3800
+ destination: &Path,
3801
+ ) -> Result<()> {
3802
+ if let Some(parent) = destination.parent() {
3803
+ tokio_fs::create_dir_all(parent)
3804
+ .await
3805
+ .with_context(|| format!("Failed to create {}", parent.display()))?;
3806
+ }
3807
+
3808
+ let response = with_cli_headers(client.get(url.to_string()))
3809
+ .send()
3810
+ .await
3811
+ .with_context(|| format!("Failed to download plugin archive {}", url))?;
3812
+ if !response.status().is_success() {
3813
+ let body = read_error_body(response).await;
3814
+ return Err(anyhow!("Plugin download failed: {}", body));
3815
+ }
3816
+
3817
+ let mut file = tokio_fs::File::create(destination)
3818
+ .await
3819
+ .with_context(|| format!("Failed to create {}", destination.display()))?;
3820
+ let mut stream = response;
3821
+ while let Some(chunk) = stream
3822
+ .chunk()
3823
+ .await
3824
+ .with_context(|| format!("Failed while downloading {}", url))?
3825
+ {
3826
+ file.write_all(&chunk)
3827
+ .await
3828
+ .with_context(|| format!("Failed writing {}", destination.display()))?;
3829
+ }
3830
+ file.flush()
3831
+ .await
3832
+ .with_context(|| format!("Failed to finalize {}", destination.display()))?;
3833
+ Ok(())
3834
+ }
3835
+
3836
+ fn extract_zip_file(zip_path: &Path, destination: &Path) -> Result<()> {
3837
+ let file = fs::File::open(zip_path)
3838
+ .with_context(|| format!("Failed to open {}", zip_path.display()))?;
3839
+ let mut archive = zip::ZipArchive::new(file).with_context(|| "Failed to read zip archive")?;
3840
+
3841
+ for index in 0..archive.len() {
3842
+ let mut file = archive
3843
+ .by_index(index)
3844
+ .with_context(|| format!("Failed to read zip entry {}", index))?;
3845
+ let Some(name) = file.enclosed_name().map(|value| value.to_path_buf()) else {
3846
+ continue;
3847
+ };
3848
+ let output_path = destination.join(name);
3849
+
3850
+ if file.is_dir() {
3851
+ fs::create_dir_all(&output_path)
3852
+ .with_context(|| format!("Failed to create {}", output_path.display()))?;
3853
+ continue;
3854
+ }
3855
+
3856
+ if let Some(parent) = output_path.parent() {
3857
+ fs::create_dir_all(parent)
3858
+ .with_context(|| format!("Failed to create {}", parent.display()))?;
3859
+ }
3860
+
3861
+ let mut output_file = fs::File::create(&output_path)
3862
+ .with_context(|| format!("Failed to create {}", output_path.display()))?;
3863
+ io::copy(&mut file, &mut output_file)
3864
+ .with_context(|| format!("Failed to extract {}", output_path.display()))?;
3865
+ }
3866
+ Ok(())
3867
+ }
3868
+
3869
+ fn collect_uplugin_roots(root: &Path) -> Result<Vec<PathBuf>> {
3870
+ let mut stack = vec![root.to_path_buf()];
3871
+ let mut roots: Vec<PathBuf> = Vec::new();
3872
+
3873
+ while let Some(path) = stack.pop() {
3874
+ if !path.is_dir() {
3875
+ continue;
3876
+ }
3877
+ for entry in
3878
+ fs::read_dir(&path).with_context(|| format!("Failed to read {}", path.display()))?
3879
+ {
3880
+ let entry = entry?;
3881
+ let child = entry.path();
3882
+ if child.is_dir() {
3883
+ stack.push(child);
3884
+ continue;
3885
+ }
3886
+ let is_uplugin = child
3887
+ .extension()
3888
+ .and_then(|value| value.to_str())
3889
+ .map(|value| value.eq_ignore_ascii_case("uplugin"))
3890
+ .unwrap_or(false);
3891
+ if is_uplugin {
3892
+ if let Some(parent) = child.parent() {
3893
+ roots.push(parent.to_path_buf());
3894
+ }
3895
+ }
3896
+ }
3897
+ }
3898
+
3899
+ roots.sort();
3900
+ roots.dedup_by(|left, right| {
3901
+ normalize_path_for_compare(left) == normalize_path_for_compare(right)
3902
+ });
3903
+ Ok(roots)
3904
+ }
3905
+
3906
+ fn find_plugin_root_from_extracted(extracted_root: &Path) -> Result<PathBuf> {
3907
+ let roots = collect_uplugin_roots(extracted_root)?;
3908
+ if roots.is_empty() {
1332
3909
  return Err(anyhow!(
1333
- "project update requires at least one field (--name, --description, or --clear-description)"
3910
+ "Plugin archive does not contain a .uplugin descriptor"
1334
3911
  ));
1335
3912
  }
3913
+ if roots.len() > 1 {
3914
+ return Err(anyhow!(
3915
+ "Plugin archive contains multiple .uplugin roots; expected one"
3916
+ ));
3917
+ }
3918
+ canonicalize_existing_path(&roots[0], "plugin root")
3919
+ }
1336
3920
 
1337
- let path = format!("/core/projects/{}", args.project_id);
1338
- let response = authed_request(
1339
- client,
1340
- &mut session,
1341
- Method::PATCH,
1342
- &path,
1343
- Some(serde_json::Value::Object(body)),
1344
- )
1345
- .await?;
1346
- if !response.status().is_success() {
1347
- let body = read_error_body(response).await;
1348
- return Err(anyhow!("project update failed: {}", body));
3921
+ fn remove_existing_path(path: &Path) -> Result<()> {
3922
+ if !path.exists() {
3923
+ return Ok(());
3924
+ }
3925
+ if path.is_dir() {
3926
+ fs::remove_dir_all(path)
3927
+ .with_context(|| format!("Failed to remove directory {}", path.display()))?;
3928
+ } else {
3929
+ fs::remove_file(path)
3930
+ .with_context(|| format!("Failed to remove file {}", path.display()))?;
1349
3931
  }
1350
- let payload: ProjectResponse = response.json().await?;
1351
- println!("{}", serde_json::to_string_pretty(&payload.project)?);
1352
- save_session(&session)?;
1353
3932
  Ok(())
1354
3933
  }
1355
3934
 
1356
- async fn project_delete_command(client: &reqwest::Client, args: ProjectDeleteArgs) -> Result<()> {
1357
- let mut session = load_session()?;
1358
- apply_base_url_override(&mut session, args.base_url);
3935
+ fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<()> {
3936
+ fs::create_dir_all(destination)
3937
+ .with_context(|| format!("Failed to create {}", destination.display()))?;
3938
+ for entry in
3939
+ fs::read_dir(source).with_context(|| format!("Failed to read {}", source.display()))?
3940
+ {
3941
+ let entry = entry?;
3942
+ let child = entry.path();
3943
+ let target = destination.join(entry.file_name());
3944
+ if child.is_dir() {
3945
+ copy_dir_recursive(&child, &target)?;
3946
+ } else {
3947
+ if let Some(parent) = target.parent() {
3948
+ fs::create_dir_all(parent)
3949
+ .with_context(|| format!("Failed to create {}", parent.display()))?;
3950
+ }
3951
+ fs::copy(&child, &target).with_context(|| {
3952
+ format!("Failed to copy {} to {}", child.display(), target.display())
3953
+ })?;
3954
+ }
3955
+ }
3956
+ Ok(())
3957
+ }
1359
3958
 
1360
- let path = format!("/core/projects/{}", args.project_id);
1361
- let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
1362
- if !response.status().is_success() {
1363
- let body = read_error_body(response).await;
1364
- return Err(anyhow!("project delete failed: {}", body));
3959
+ pub(crate) async fn link_plugin_install_command(
3960
+ client: &reqwest::Client,
3961
+ args: LinkPluginInstallArgs,
3962
+ ) -> Result<()> {
3963
+ let config = load_unreal_links()?;
3964
+ if config.links.is_empty() {
3965
+ return Err(anyhow!(
3966
+ "No local links configured. Run `reallink link unreal ...` first."
3967
+ ));
1365
3968
  }
1366
- let payload: serde_json::Value = response.json().await?;
1367
- println!("{}", serde_json::to_string_pretty(&payload)?);
1368
- save_session(&session)?;
3969
+ let target = resolve_link_target(
3970
+ &config,
3971
+ args.project_id.as_deref(),
3972
+ args.uproject.as_deref(),
3973
+ )?;
3974
+
3975
+ let plugin_name = args.name.trim();
3976
+ if plugin_name.is_empty() {
3977
+ return Err(anyhow!("Plugin name is required"));
3978
+ }
3979
+ if plugin_name.contains('/') || plugin_name.contains('\\') {
3980
+ return Err(anyhow!("Plugin name cannot include path separators"));
3981
+ }
3982
+
3983
+ let mut expected_sha256 = args
3984
+ .sha256
3985
+ .as_ref()
3986
+ .map(|value| normalize_sha256_hex(value))
3987
+ .transpose()?;
3988
+ let mut resolved_version = args.version.trim().to_string();
3989
+ if resolved_version.is_empty() {
3990
+ return Err(anyhow!("Plugin version is required"));
3991
+ }
3992
+
3993
+ let archive_url = if let Some(url) = args.url {
3994
+ let trimmed = url.trim();
3995
+ if trimmed.is_empty() {
3996
+ return Err(anyhow!("--url cannot be empty"));
3997
+ }
3998
+ trimmed.to_string()
3999
+ } else if args.use_index {
4000
+ let index = fetch_plugin_index(client, &args.base_url, &args.index_path).await?;
4001
+ let (resolved, url, index_sha256) =
4002
+ resolve_plugin_from_index(&index, plugin_name, &resolved_version, &args.base_url)?;
4003
+ if expected_sha256.is_none() {
4004
+ expected_sha256 = index_sha256;
4005
+ }
4006
+ resolved_version = resolved;
4007
+ url
4008
+ } else {
4009
+ compose_plugin_archive_url(&args.base_url, plugin_name, &resolved_version)?
4010
+ };
4011
+
4012
+ let plugin_root_dir = if args.engine {
4013
+ let engine_root = target.engine_root.as_ref().ok_or_else(|| {
4014
+ anyhow!("Selected link has no engine_root; relink with --engine-root or --editor")
4015
+ })?;
4016
+ let root = PathBuf::from(engine_root);
4017
+ root.join("Engine").join("Plugins").join("Marketplace")
4018
+ } else {
4019
+ PathBuf::from(&target.project_root).join("Plugins")
4020
+ };
4021
+ fs::create_dir_all(&plugin_root_dir)
4022
+ .with_context(|| format!("Failed to create {}", plugin_root_dir.display()))?;
4023
+ let destination_dir = plugin_root_dir.join(plugin_name);
4024
+
4025
+ if destination_dir.exists() && !args.force {
4026
+ return Err(anyhow!(
4027
+ "Plugin destination already exists: {} (use --force to overwrite)",
4028
+ destination_dir.display()
4029
+ ));
4030
+ }
4031
+ if destination_dir.exists() && args.force {
4032
+ remove_existing_path(&destination_dir)?;
4033
+ }
4034
+
4035
+ let temp_root = std::env::temp_dir().join(format!(
4036
+ "reallink-plugin-install-{}-{}",
4037
+ plugin_name,
4038
+ now_epoch_ms()
4039
+ ));
4040
+ if temp_root.exists() {
4041
+ remove_existing_path(&temp_root)?;
4042
+ }
4043
+ fs::create_dir_all(&temp_root)
4044
+ .with_context(|| format!("Failed to create {}", temp_root.display()))?;
4045
+ let archive_path = temp_root.join("plugin.zip");
4046
+ let temp_extract_root = temp_root.join("extract");
4047
+
4048
+ download_plugin_archive_to_file(client, &archive_url, &archive_path).await?;
4049
+ let archive_sha256 = compute_sha256_hex(&archive_path)?;
4050
+ if let Some(expected) = expected_sha256.as_ref() {
4051
+ if &archive_sha256 != expected {
4052
+ let _ = remove_existing_path(&temp_root);
4053
+ return Err(anyhow!(
4054
+ "Plugin checksum mismatch (expected {}, got {})",
4055
+ expected,
4056
+ archive_sha256
4057
+ ));
4058
+ }
4059
+ }
4060
+ fs::create_dir_all(&temp_extract_root)
4061
+ .with_context(|| format!("Failed to create {}", temp_extract_root.display()))?;
4062
+ extract_zip_file(&archive_path, &temp_extract_root)?;
4063
+ let plugin_source_root = find_plugin_root_from_extracted(&temp_extract_root)?;
4064
+ copy_dir_recursive(&plugin_source_root, &destination_dir)?;
4065
+ let _ = remove_existing_path(&temp_root);
4066
+
4067
+ println!(
4068
+ "{}",
4069
+ serde_json::to_string_pretty(&serde_json::json!({
4070
+ "ok": true,
4071
+ "projectId": target.project_id,
4072
+ "plugin": plugin_name,
4073
+ "version": resolved_version,
4074
+ "scope": if args.engine { "engine" } else { "project" },
4075
+ "archiveUrl": archive_url,
4076
+ "archiveSha256": archive_sha256,
4077
+ "verifiedSha256": expected_sha256,
4078
+ "installedTo": destination_dir.display().to_string()
4079
+ }))?
4080
+ );
4081
+ Ok(())
4082
+ }
4083
+
4084
+ pub(crate) async fn link_plugin_list_command(
4085
+ client: &reqwest::Client,
4086
+ args: LinkPluginListArgs,
4087
+ ) -> Result<()> {
4088
+ let parsed = fetch_plugin_index_value(client, &args.base_url, &args.index_path).await?;
4089
+ println!("{}", serde_json::to_string_pretty(&parsed)?);
1369
4090
  Ok(())
1370
4091
  }
1371
4092
 
@@ -1535,29 +4256,51 @@ async fn file_list_command(client: &reqwest::Client, args: FileListArgs) -> Resu
1535
4256
  let mut session = load_session()?;
1536
4257
  apply_base_url_override(&mut session, args.base_url);
1537
4258
 
1538
- let path = format!("/assets?projectId={}", args.project_id);
1539
- let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
1540
- if !response.status().is_success() {
1541
- let body = read_error_body(response).await;
1542
- return Err(anyhow!("file list failed: {}", body));
1543
- }
1544
- let mut payload: ListAssetsResponse = response.json().await?;
4259
+ let mut query_parts = vec![format!("projectId={}", args.project_id)];
1545
4260
  if let Some(prefix) = args.path {
1546
4261
  let cleaned = clean_virtual_path(&prefix);
1547
4262
  if !cleaned.is_empty() {
1548
- let strict = format!("{}/", cleaned);
1549
- payload.assets = payload
1550
- .assets
1551
- .into_iter()
1552
- .filter(|asset| asset.file_name == cleaned || asset.file_name.starts_with(&strict))
1553
- .collect();
4263
+ query_parts.push(format!("path={}", cleaned));
1554
4264
  }
1555
4265
  }
4266
+ if let Some(offset) = args.offset {
4267
+ query_parts.push(format!("offset={}", offset));
4268
+ }
4269
+ if let Some(limit) = args.limit {
4270
+ query_parts.push(format!("limit={}", limit));
4271
+ }
4272
+ if let Some(include_folder_markers) = args.include_folder_markers {
4273
+ query_parts.push(format!("includeFolderMarkers={}", include_folder_markers));
4274
+ }
4275
+
4276
+ let path = format!("/assets?{}", query_parts.join("&"));
4277
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
4278
+ if !response.status().is_success() {
4279
+ let body = read_error_body(response).await;
4280
+ return Err(anyhow!("file list failed: {}", body));
4281
+ }
4282
+ let payload: ListAssetsResponse = response.json().await?;
1556
4283
  println!("{}", serde_json::to_string_pretty(&payload.assets)?);
1557
4284
  save_session(&session)?;
1558
4285
  Ok(())
1559
4286
  }
1560
4287
 
4288
+ async fn file_tree_command(client: &reqwest::Client, args: FileTreeArgs) -> Result<()> {
4289
+ let mut session = load_session()?;
4290
+ apply_base_url_override(&mut session, args.base_url);
4291
+
4292
+ let path = format!("/assets/tree?projectId={}", args.project_id);
4293
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
4294
+ if !response.status().is_success() {
4295
+ let body = read_error_body(response).await;
4296
+ return Err(anyhow!("file tree failed: {}", body));
4297
+ }
4298
+ let payload: serde_json::Value = response.json().await?;
4299
+ println!("{}", serde_json::to_string_pretty(&payload)?);
4300
+ save_session(&session)?;
4301
+ Ok(())
4302
+ }
4303
+
1561
4304
  async fn file_get_command(client: &reqwest::Client, args: FileGetArgs) -> Result<()> {
1562
4305
  let mut session = load_session()?;
1563
4306
  apply_base_url_override(&mut session, args.base_url);
@@ -1590,6 +4333,77 @@ async fn file_stat_command(client: &reqwest::Client, args: FileStatArgs) -> Resu
1590
4333
  Ok(())
1591
4334
  }
1592
4335
 
4336
+ fn infer_thumbnail_extension(content_type: Option<&str>) -> &'static str {
4337
+ let normalized = content_type.unwrap_or("").to_ascii_lowercase();
4338
+ if normalized.contains("image/png") {
4339
+ "png"
4340
+ } else if normalized.contains("image/jpeg") || normalized.contains("image/jpg") {
4341
+ "jpg"
4342
+ } else if normalized.contains("image/webp") {
4343
+ "webp"
4344
+ } else if normalized.contains("image/svg+xml") {
4345
+ "svg"
4346
+ } else {
4347
+ "bin"
4348
+ }
4349
+ }
4350
+
4351
+ async fn file_thumbnail_command(client: &reqwest::Client, args: FileThumbnailArgs) -> Result<()> {
4352
+ let mut session = load_session()?;
4353
+ apply_base_url_override(&mut session, args.base_url);
4354
+
4355
+ let size = args.size.as_str();
4356
+ let path = format!("/assets/{}/thumbnail/{}", args.asset_id, size);
4357
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
4358
+ if !response.status().is_success() {
4359
+ let body = read_error_body(response).await;
4360
+ return Err(anyhow!("file thumbnail failed: {}", body));
4361
+ }
4362
+
4363
+ let content_type = response
4364
+ .headers()
4365
+ .get("content-type")
4366
+ .and_then(|value| value.to_str().ok())
4367
+ .map(|value| value.to_string());
4368
+ let bytes = response.bytes().await?;
4369
+ let output_path = args.output_path.unwrap_or_else(|| {
4370
+ PathBuf::from(format!(
4371
+ "{}-{}.{}",
4372
+ args.asset_id,
4373
+ size,
4374
+ infer_thumbnail_extension(content_type.as_deref())
4375
+ ))
4376
+ });
4377
+ if let Some(parent) = output_path.parent() {
4378
+ if !parent.as_os_str().is_empty() {
4379
+ tokio_fs::create_dir_all(parent).await.with_context(|| {
4380
+ format!(
4381
+ "Failed to create output directory {}",
4382
+ parent.display()
4383
+ )
4384
+ })?;
4385
+ }
4386
+ }
4387
+ tokio_fs::write(&output_path, &bytes)
4388
+ .await
4389
+ .with_context(|| format!("Failed to write thumbnail {}", output_path.display()))?;
4390
+
4391
+ println!(
4392
+ "{}",
4393
+ serde_json::to_string_pretty(&serde_json::json!({
4394
+ "ok": true,
4395
+ "assetId": args.asset_id,
4396
+ "size": size,
4397
+ "output": output_path.display().to_string(),
4398
+ "bytesWritten": bytes.len(),
4399
+ "contentType": content_type
4400
+ }))?
4401
+ );
4402
+
4403
+ save_session(&session)?;
4404
+ Ok(())
4405
+ }
4406
+
1593
4407
  async fn file_download_command(client: &reqwest::Client, args: FileDownloadArgs) -> Result<()> {
1594
4408
  let mut session = load_session()?;
1595
4409
  apply_base_url_override(&mut session, args.base_url);
@@ -1605,7 +4419,7 @@ async fn file_download_command(client: &reqwest::Client, args: FileDownloadArgs)
1605
4419
  let fallback_name = base_name_from_virtual_path(&metadata_payload.asset.file_name);
1606
4420
 
1607
4421
  let mut output_path = args
1608
- .output
4422
+ .output_path
1609
4423
  .unwrap_or_else(|| PathBuf::from(fallback_name.as_str()));
1610
4424
  if output_path.exists() && output_path.is_dir() {
1611
4425
  output_path = output_path.join(fallback_name.as_str());
@@ -1728,73 +4542,189 @@ async fn file_upload_command(client: &reqwest::Client, args: FileUploadArgs) ->
1728
4542
  return Err(anyhow!("resolved remote file name is empty"));
1729
4543
  }
1730
4544
 
1731
- let asset = upload_asset_via_intent(
4545
+ let asset = upload_asset_via_intent(
4546
+ client,
4547
+ &mut session,
4548
+ &args.project_id,
4549
+ &remote_name,
4550
+ bytes,
4551
+ "application/octet-stream",
4552
+ &args.asset_type,
4553
+ &args.visibility,
4554
+ )
4555
+ .await?;
4556
+ println!("{}", serde_json::to_string_pretty(&asset)?);
4557
+ save_session(&session)?;
4558
+ Ok(())
4559
+ }
4560
+
4561
+ async fn file_mkdir_command(client: &reqwest::Client, args: FileMkdirArgs) -> Result<()> {
4562
+ let mut session = load_session()?;
4563
+ apply_base_url_override(&mut session, args.base_url);
4564
+
4565
+ let folder = clean_virtual_path(&args.path);
4566
+ if folder.is_empty() {
4567
+ return Err(anyhow!("folder path is empty"));
4568
+ }
4569
+ let response = authed_request(
4570
+ client,
4571
+ &mut session,
4572
+ Method::POST,
4573
+ "/assets/folder",
4574
+ Some(serde_json::json!({
4575
+ "projectId": args.project_id,
4576
+ "path": folder,
4577
+ "visibility": args.visibility
4578
+ })),
4579
+ )
4580
+ .await?;
4581
+ if !response.status().is_success() {
4582
+ let body = read_error_body(response).await;
4583
+ return Err(anyhow!("file mkdir failed: {}", body));
4584
+ }
4585
+ let payload: serde_json::Value = response.json().await?;
4586
+ println!("{}", serde_json::to_string_pretty(&payload)?);
4587
+ save_session(&session)?;
4588
+ Ok(())
4589
+ }
4590
+
4591
+ async fn file_move_command(client: &reqwest::Client, args: FileMoveArgs) -> Result<()> {
4592
+ let mut session = load_session()?;
4593
+ apply_base_url_override(&mut session, args.base_url);
4594
+
4595
+ let file_name = clean_virtual_path(&args.file_name);
4596
+ if file_name.is_empty() {
4597
+ return Err(anyhow!("file_name is empty"));
4598
+ }
4599
+ let path = format!("/assets/{}", args.asset_id);
4600
+ let response = authed_request(
4601
+ client,
4602
+ &mut session,
4603
+ Method::PATCH,
4604
+ &path,
4605
+ Some(serde_json::json!({
4606
+ "fileName": file_name
4607
+ })),
4608
+ )
4609
+ .await?;
4610
+ if !response.status().is_success() {
4611
+ let body = read_error_body(response).await;
4612
+ return Err(anyhow!("file move failed: {}", body));
4613
+ }
4614
+ let payload: AssetResponse = response.json().await?;
4615
+ println!("{}", serde_json::to_string_pretty(&payload.asset)?);
4616
+ save_session(&session)?;
4617
+ Ok(())
4618
+ }
4619
+
4620
+ async fn file_set_command(client: &reqwest::Client, args: FileSetArgs) -> Result<()> {
4621
+ let mut session = load_session()?;
4622
+ apply_base_url_override(&mut session, args.base_url);
4623
+
4624
+ if args.file_name.is_none() && args.asset_type.is_none() && args.visibility.is_none() {
4625
+ return Err(anyhow!(
4626
+ "At least one of --file-name, --asset-type, or --visibility is required"
4627
+ ));
4628
+ }
4629
+
4630
+ let mut body = serde_json::Map::new();
4631
+ if let Some(file_name) = args.file_name {
4632
+ let normalized = clean_virtual_path(&file_name);
4633
+ if normalized.is_empty() {
4634
+ return Err(anyhow!("--file-name resolved to an empty path"));
4635
+ }
4636
+ body.insert(
4637
+ "fileName".to_string(),
4638
+ serde_json::Value::String(normalized),
4639
+ );
4640
+ }
4641
+ if let Some(asset_type) = args.asset_type {
4642
+ body.insert("assetType".to_string(), serde_json::Value::String(asset_type));
4643
+ }
4644
+ if let Some(visibility) = args.visibility {
4645
+ body.insert("visibility".to_string(), serde_json::Value::String(visibility));
4646
+ }
4647
+
4648
+ let path = format!("/assets/{}", args.asset_id);
4649
+ let response = authed_request(
1732
4650
  client,
1733
4651
  &mut session,
1734
- &args.project_id,
1735
- &remote_name,
1736
- bytes,
1737
- "application/octet-stream",
1738
- &args.asset_type,
1739
- &args.visibility,
4652
+ Method::PATCH,
4653
+ &path,
4654
+ Some(serde_json::Value::Object(body)),
1740
4655
  )
1741
4656
  .await?;
1742
- println!("{}", serde_json::to_string_pretty(&asset)?);
4657
+ if !response.status().is_success() {
4658
+ let body = read_error_body(response).await;
4659
+ return Err(anyhow!("file set failed: {}", body));
4660
+ }
4661
+ let payload: AssetResponse = response.json().await?;
4662
+ println!("{}", serde_json::to_string_pretty(&payload.asset)?);
1743
4663
  save_session(&session)?;
1744
4664
  Ok(())
1745
4665
  }
1746
4666
 
1747
- async fn file_mkdir_command(client: &reqwest::Client, args: FileMkdirArgs) -> Result<()> {
4667
+ async fn file_move_folder_command(client: &reqwest::Client, args: FileMoveFolderArgs) -> Result<()> {
1748
4668
  let mut session = load_session()?;
1749
4669
  apply_base_url_override(&mut session, args.base_url);
1750
4670
 
1751
- let folder = clean_virtual_path(&args.path);
1752
- if folder.is_empty() {
1753
- return Err(anyhow!("folder path is empty"));
4671
+ let source_path = clean_virtual_path(&args.source_path);
4672
+ if source_path.is_empty() {
4673
+ return Err(anyhow!("source_path is empty"));
1754
4674
  }
1755
- let marker_file = join_remote_path(Some(&folder), ".reallink.keep");
1756
- let marker_bytes = format!("folder marker {}\n", now_epoch_ms()).into_bytes();
1757
- let asset = upload_asset_via_intent(
4675
+ let target_path = clean_virtual_path(&args.target_path);
4676
+
4677
+ let response = authed_request(
1758
4678
  client,
1759
4679
  &mut session,
1760
- &args.project_id,
1761
- &marker_file,
1762
- marker_bytes,
1763
- "text/plain",
1764
- "other",
1765
- "private",
4680
+ Method::POST,
4681
+ "/assets/folder/move",
4682
+ Some(serde_json::json!({
4683
+ "projectId": args.project_id,
4684
+ "sourcePath": source_path,
4685
+ "targetPath": target_path
4686
+ })),
1766
4687
  )
1767
4688
  .await?;
1768
- println!("{}", serde_json::to_string_pretty(&asset)?);
4689
+ if !response.status().is_success() {
4690
+ let body = read_error_body(response).await;
4691
+ return Err(anyhow!("file move-folder failed: {}", body));
4692
+ }
4693
+ let payload: serde_json::Value = response.json().await?;
4694
+ println!("{}", serde_json::to_string_pretty(&payload)?);
1769
4695
  save_session(&session)?;
1770
4696
  Ok(())
1771
4697
  }
1772
4698
 
1773
- async fn file_move_command(client: &reqwest::Client, args: FileMoveArgs) -> Result<()> {
4699
+ async fn file_rmdir_command(client: &reqwest::Client, args: FileRemoveFolderArgs) -> Result<()> {
1774
4700
  let mut session = load_session()?;
1775
4701
  apply_base_url_override(&mut session, args.base_url);
1776
4702
 
1777
- let file_name = clean_virtual_path(&args.file_name);
1778
- if file_name.is_empty() {
1779
- return Err(anyhow!("file_name is empty"));
4703
+ let folder_path = clean_virtual_path(&args.path);
4704
+ if folder_path.is_empty() {
4705
+ return Err(anyhow!("path is empty"));
1780
4706
  }
1781
- let path = format!("/assets/{}", args.asset_id);
4707
+
1782
4708
  let response = authed_request(
1783
4709
  client,
1784
4710
  &mut session,
1785
- Method::PATCH,
1786
- &path,
4711
+ Method::POST,
4712
+ "/assets/folder/delete",
1787
4713
  Some(serde_json::json!({
1788
- "fileName": file_name
4714
+ "projectId": args.project_id,
4715
+ "path": folder_path,
4716
+ "recursive": args.recursive,
4717
+ "dryRun": args.dry_run,
4718
+ "includeFolderMarkers": args.include_folder_markers
1789
4719
  })),
1790
4720
  )
1791
4721
  .await?;
1792
4722
  if !response.status().is_success() {
1793
4723
  let body = read_error_body(response).await;
1794
- return Err(anyhow!("file move failed: {}", body));
4724
+ return Err(anyhow!("file rmdir failed: {}", body));
1795
4725
  }
1796
- let payload: AssetResponse = response.json().await?;
1797
- println!("{}", serde_json::to_string_pretty(&payload.asset)?);
4726
+ let payload: serde_json::Value = response.json().await?;
4727
+ println!("{}", serde_json::to_string_pretty(&payload)?);
1798
4728
  save_session(&session)?;
1799
4729
  Ok(())
1800
4730
  }
@@ -1865,6 +4795,43 @@ async fn tool_register_command(client: &reqwest::Client, args: ToolRegisterArgs)
1865
4795
  Ok(())
1866
4796
  }
1867
4797
 
4798
+ async fn tool_publish_command(client: &reqwest::Client, args: ToolPublishArgs) -> Result<()> {
4799
+ let mut session = load_session()?;
4800
+ apply_base_url_override(&mut session, args.base_url);
4801
+
4802
+ let mut body = serde_json::Map::new();
4803
+ if let Some(channel) = args.channel {
4804
+ body.insert("channel".to_string(), serde_json::Value::String(channel));
4805
+ }
4806
+ if let Some(visibility) = args.visibility {
4807
+ body.insert("visibility".to_string(), serde_json::Value::String(visibility));
4808
+ }
4809
+ if let Some(notes) = args.notes {
4810
+ body.insert("notes".to_string(), serde_json::Value::String(notes));
4811
+ }
4812
+
4813
+ let path = format!("/tools/definitions/{}/publish", args.tool_id);
4814
+ let response = authed_request(
4815
+ client,
4816
+ &mut session,
4817
+ Method::POST,
4818
+ &path,
4819
+ Some(serde_json::Value::Object(body)),
4820
+ )
4821
+ .await?;
4822
+ if !response.status().is_success() {
4823
+ let body = read_error_body(response).await;
4824
+ return Err(anyhow!("tool publish failed: {}", body));
4825
+ }
4826
+ let payload: serde_json::Value = response.json().await?;
4827
+ println!(
4828
+ "{}",
4829
+ serde_json::to_string_pretty(payload.get("definition").unwrap_or(&payload))?
4830
+ );
4831
+ save_session(&session)?;
4832
+ Ok(())
4833
+ }
4834
+
1868
4835
  async fn tool_set_entitlement_command(
1869
4836
  client: &reqwest::Client,
1870
4837
  mut session: SessionConfig,
@@ -1986,23 +4953,222 @@ async fn tool_run_command(client: &reqwest::Client, args: ToolRunArgs) -> Result
1986
4953
  ));
1987
4954
  }
1988
4955
 
1989
- let input_value = if let Some(path) = args.input_file {
1990
- load_jsonc_file(&path, "tool run input")?
1991
- } else if let Some(input_json) = args.input_json {
1992
- parse_jsonc_str(&input_json, "tool run input")?
1993
- } else {
1994
- serde_json::Value::Object(serde_json::Map::new())
1995
- };
4956
+ let input_value = if let Some(path) = args.input_file {
4957
+ load_jsonc_file(&path, "tool run input")?
4958
+ } else if let Some(input_json) = args.input_json {
4959
+ parse_jsonc_str(&input_json, "tool run input")?
4960
+ } else {
4961
+ serde_json::Value::Object(serde_json::Map::new())
4962
+ };
4963
+
4964
+ let input_object =
4965
+ serde_json::Value::Object(parse_object_from_value(input_value, "tool run input")?);
4966
+
4967
+ let mut body = serde_json::Map::new();
4968
+ body.insert(
4969
+ "toolId".to_string(),
4970
+ serde_json::Value::String(args.tool_id),
4971
+ );
4972
+ body.insert("input".to_string(), input_object);
4973
+ if let Some(org_id) = args.org_id {
4974
+ body.insert("orgId".to_string(), serde_json::Value::String(org_id));
4975
+ }
4976
+ if let Some(project_id) = args.project_id {
4977
+ body.insert(
4978
+ "projectId".to_string(),
4979
+ serde_json::Value::String(project_id),
4980
+ );
4981
+ }
4982
+ let mut metadata_map = if let Some(path) = args.metadata_file {
4983
+ let metadata = load_jsonc_file(&path, "tool run metadata")?;
4984
+ parse_object_from_value(metadata, "tool run metadata")?
4985
+ } else {
4986
+ serde_json::Map::new()
4987
+ };
4988
+ if let Some(idempotency_key) = args.idempotency_key {
4989
+ let normalized = idempotency_key.trim();
4990
+ if !normalized.is_empty() {
4991
+ metadata_map.insert(
4992
+ "idempotencyKey".to_string(),
4993
+ serde_json::Value::String(normalized.to_string()),
4994
+ );
4995
+ }
4996
+ }
4997
+ if !metadata_map.is_empty() {
4998
+ body.insert(
4999
+ "metadata".to_string(),
5000
+ serde_json::Value::Object(metadata_map),
5001
+ );
5002
+ }
5003
+
5004
+ let response = authed_request(
5005
+ client,
5006
+ &mut session,
5007
+ Method::POST,
5008
+ "/tools/runs",
5009
+ Some(serde_json::Value::Object(body)),
5010
+ )
5011
+ .await?;
5012
+ if !response.status().is_success() {
5013
+ let body = read_error_body(response).await;
5014
+ return Err(anyhow!("tool run failed: {}", body));
5015
+ }
5016
+ let payload: serde_json::Value = response.json().await?;
5017
+ println!(
5018
+ "{}",
5019
+ serde_json::to_string_pretty(payload.get("run").unwrap_or(&payload))?
5020
+ );
5021
+ save_session(&session)?;
5022
+ Ok(())
5023
+ }
5024
+
5025
+ async fn tool_runs_command(client: &reqwest::Client, args: ToolRunsArgs) -> Result<()> {
5026
+ let mut session = load_session()?;
5027
+ apply_base_url_override(&mut session, args.base_url);
5028
+
5029
+ let mut query_parts: Vec<String> = Vec::new();
5030
+ if let Some(tool_id) = args.tool_id {
5031
+ query_parts.push(format!("toolId={}", tool_id));
5032
+ }
5033
+ if let Some(project_id) = args.project_id {
5034
+ query_parts.push(format!("projectId={}", project_id));
5035
+ }
5036
+ if let Some(requested_by_user_id) = args.requested_by_user_id {
5037
+ query_parts.push(format!("requestedByUserId={}", requested_by_user_id));
5038
+ }
5039
+ if let Some(status) = args.status {
5040
+ query_parts.push(format!("status={}", status));
5041
+ }
5042
+
5043
+ let path = if query_parts.is_empty() {
5044
+ "/tools/runs".to_string()
5045
+ } else {
5046
+ format!("/tools/runs?{}", query_parts.join("&"))
5047
+ };
5048
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
5049
+ if !response.status().is_success() {
5050
+ let body = read_error_body(response).await;
5051
+ return Err(anyhow!("tool runs failed: {}", body));
5052
+ }
5053
+ let payload: serde_json::Value = response.json().await?;
5054
+ println!(
5055
+ "{}",
5056
+ serde_json::to_string_pretty(payload.get("runs").unwrap_or(&payload))?
5057
+ );
5058
+ save_session(&session)?;
5059
+ Ok(())
5060
+ }
5061
+
5062
+ async fn tool_get_run_command(client: &reqwest::Client, args: ToolGetRunArgs) -> Result<()> {
5063
+ let mut session = load_session()?;
5064
+ apply_base_url_override(&mut session, args.base_url);
5065
+
5066
+ let path = format!("/tools/runs/{}", args.run_id);
5067
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
5068
+ if !response.status().is_success() {
5069
+ let body = read_error_body(response).await;
5070
+ return Err(anyhow!("tool get-run failed: {}", body));
5071
+ }
5072
+ let payload: serde_json::Value = response.json().await?;
5073
+ println!(
5074
+ "{}",
5075
+ serde_json::to_string_pretty(payload.get("run").unwrap_or(&payload))?
5076
+ );
5077
+ save_session(&session)?;
5078
+ Ok(())
5079
+ }
5080
+
5081
+ async fn tool_run_events_command(client: &reqwest::Client, args: ToolRunEventsArgs) -> Result<()> {
5082
+ let mut session = load_session()?;
5083
+ apply_base_url_override(&mut session, args.base_url);
5084
+
5085
+ let mut path = format!("/tools/runs/{}/events", args.run_id);
5086
+ let mut query_parts: Vec<String> = Vec::new();
5087
+ if let Some(limit) = args.limit {
5088
+ query_parts.push(format!("limit={}", limit));
5089
+ }
5090
+ if let Some(status) = args.status {
5091
+ query_parts.push(format!("status={}", status));
5092
+ }
5093
+ if let Some(stage_prefix) = args.stage_prefix {
5094
+ query_parts.push(format!("stagePrefix={}", stage_prefix));
5095
+ }
5096
+ if let Some(since) = args.since {
5097
+ query_parts.push(format!("since={}", since));
5098
+ }
5099
+ if let Some(until) = args.until {
5100
+ query_parts.push(format!("until={}", until));
5101
+ }
5102
+ if !query_parts.is_empty() {
5103
+ path.push_str(&format!("?{}", query_parts.join("&")));
5104
+ }
5105
+
5106
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
5107
+ if !response.status().is_success() {
5108
+ let body = read_error_body(response).await;
5109
+ return Err(anyhow!("tool run-events failed: {}", body));
5110
+ }
5111
+ let payload: serde_json::Value = response.json().await?;
5112
+ println!(
5113
+ "{}",
5114
+ serde_json::to_string_pretty(payload.get("events").unwrap_or(&payload))?
5115
+ );
5116
+ save_session(&session)?;
5117
+ Ok(())
5118
+ }
5119
+
5120
+ async fn tool_local_catalog_command(client: &reqwest::Client, args: ToolLocalCatalogArgs) -> Result<()> {
5121
+ let mut session = load_session()?;
5122
+ apply_base_url_override(&mut session, args.base_url);
5123
+
5124
+ let (default_platform, default_arch) = detect_local_runtime_platform_arch();
5125
+ let platform = args.platform.unwrap_or(default_platform);
5126
+ let arch = args.arch.unwrap_or(default_arch);
5127
+
5128
+ let mut query_parts = vec![
5129
+ format!("platform={}", platform),
5130
+ format!("arch={}", arch),
5131
+ ];
5132
+ if let Some(org_id) = args.org_id {
5133
+ query_parts.push(format!("orgId={}", org_id));
5134
+ }
5135
+ if let Some(project_id) = args.project_id {
5136
+ query_parts.push(format!("projectId={}", project_id));
5137
+ }
5138
+ let path = format!("/tools/local/catalog?{}", query_parts.join("&"));
5139
+
5140
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
5141
+ if !response.status().is_success() {
5142
+ let body = read_error_body(response).await;
5143
+ return Err(anyhow!("tool local catalog failed: {}", body));
5144
+ }
5145
+ let payload: serde_json::Value = response.json().await?;
5146
+ println!(
5147
+ "{}",
5148
+ serde_json::to_string_pretty(payload.get("tools").unwrap_or(&payload))?
5149
+ );
5150
+ save_session(&session)?;
5151
+ Ok(())
5152
+ }
5153
+
5154
+ async fn tool_local_install_command(client: &reqwest::Client, args: ToolLocalInstallArgs) -> Result<()> {
5155
+ let mut session = load_session()?;
5156
+ apply_base_url_override(&mut session, args.base_url);
1996
5157
 
1997
- let input_object =
1998
- serde_json::Value::Object(parse_object_from_value(input_value, "tool run input")?);
5158
+ let (default_platform, default_arch) = detect_local_runtime_platform_arch();
5159
+ let platform = args.platform.unwrap_or(default_platform);
5160
+ let arch = args.arch.unwrap_or(default_arch);
1999
5161
 
2000
5162
  let mut body = serde_json::Map::new();
2001
5163
  body.insert(
2002
5164
  "toolId".to_string(),
2003
- serde_json::Value::String(args.tool_id),
5165
+ serde_json::Value::String(args.tool_id.clone()),
2004
5166
  );
2005
- body.insert("input".to_string(), input_object);
5167
+ body.insert(
5168
+ "platform".to_string(),
5169
+ serde_json::Value::String(platform.clone()),
5170
+ );
5171
+ body.insert("arch".to_string(), serde_json::Value::String(arch.clone()));
2006
5172
  if let Some(org_id) = args.org_id {
2007
5173
  body.insert("orgId".to_string(), serde_json::Value::String(org_id));
2008
5174
  }
@@ -2012,95 +5178,231 @@ async fn tool_run_command(client: &reqwest::Client, args: ToolRunArgs) -> Result
2012
5178
  serde_json::Value::String(project_id),
2013
5179
  );
2014
5180
  }
2015
- let mut metadata_map = if let Some(path) = args.metadata_file {
2016
- let metadata = load_jsonc_file(&path, "tool run metadata")?;
2017
- parse_object_from_value(metadata, "tool run metadata")?
2018
- } else {
2019
- serde_json::Map::new()
2020
- };
2021
- if let Some(idempotency_key) = args.idempotency_key {
2022
- let normalized = idempotency_key.trim();
2023
- if !normalized.is_empty() {
2024
- metadata_map.insert(
2025
- "idempotencyKey".to_string(),
2026
- serde_json::Value::String(normalized.to_string()),
2027
- );
2028
- }
2029
- }
2030
- if !metadata_map.is_empty() {
2031
- body.insert(
2032
- "metadata".to_string(),
2033
- serde_json::Value::Object(metadata_map),
2034
- );
5181
+ if let Some(version) = args.version {
5182
+ body.insert("version".to_string(), serde_json::Value::String(version));
2035
5183
  }
2036
5184
 
2037
5185
  let response = authed_request(
2038
5186
  client,
2039
5187
  &mut session,
2040
5188
  Method::POST,
2041
- "/tools/runs",
5189
+ "/tools/local/install-intent",
2042
5190
  Some(serde_json::Value::Object(body)),
2043
5191
  )
2044
5192
  .await?;
2045
5193
  if !response.status().is_success() {
2046
5194
  let body = read_error_body(response).await;
2047
- return Err(anyhow!("tool run failed: {}", body));
5195
+ return Err(anyhow!("tool local install-intent failed: {}", body));
2048
5196
  }
5197
+
2049
5198
  let payload: serde_json::Value = response.json().await?;
2050
- println!(
2051
- "{}",
2052
- serde_json::to_string_pretty(payload.get("run").unwrap_or(&payload))?
2053
- );
2054
- save_session(&session)?;
2055
- Ok(())
2056
- }
5199
+ if args.no_download {
5200
+ println!("{}", serde_json::to_string_pretty(&payload)?);
5201
+ save_session(&session)?;
5202
+ return Ok(());
5203
+ }
2057
5204
 
2058
- async fn tool_runs_command(client: &reqwest::Client, args: ToolRunsArgs) -> Result<()> {
2059
- let mut session = load_session()?;
2060
- apply_base_url_override(&mut session, args.base_url);
5205
+ let download_path = payload
5206
+ .pointer("/install/downloadPath")
5207
+ .and_then(|value| value.as_str())
5208
+ .ok_or_else(|| anyhow!("install intent is missing install.downloadPath"))?;
5209
+ let asset_id = payload
5210
+ .pointer("/asset/assetId")
5211
+ .and_then(|value| value.as_str())
5212
+ .unwrap_or("unknown");
5213
+ let fallback_file_name = payload
5214
+ .pointer("/asset/fileName")
5215
+ .and_then(|value| value.as_str())
5216
+ .unwrap_or("tool-bundle.bin");
2061
5217
 
2062
- let mut query_parts: Vec<String> = Vec::new();
2063
- if let Some(tool_id) = args.tool_id {
2064
- query_parts.push(format!("toolId={}", tool_id));
5218
+ let mut output_path = args
5219
+ .output_path
5220
+ .unwrap_or_else(|| PathBuf::from(base_name_from_virtual_path(fallback_file_name)));
5221
+ if output_path.exists() && output_path.is_dir() {
5222
+ output_path = output_path.join(base_name_from_virtual_path(fallback_file_name));
2065
5223
  }
2066
- if let Some(project_id) = args.project_id {
2067
- query_parts.push(format!("projectId={}", project_id));
5224
+ if let Some(parent) = output_path.parent() {
5225
+ if !parent.as_os_str().is_empty() {
5226
+ tokio_fs::create_dir_all(parent)
5227
+ .await
5228
+ .with_context(|| format!("Failed to create output directory {}", parent.display()))?;
5229
+ }
2068
5230
  }
2069
- if let Some(requested_by_user_id) = args.requested_by_user_id {
2070
- query_parts.push(format!("requestedByUserId={}", requested_by_user_id));
5231
+
5232
+ let mut resume_from: Option<u64> = None;
5233
+ if args.resume && output_path.exists() && output_path.is_file() {
5234
+ let existing_size = tokio_fs::metadata(&output_path)
5235
+ .await
5236
+ .with_context(|| {
5237
+ format!(
5238
+ "Failed to read output file metadata {}",
5239
+ output_path.display()
5240
+ )
5241
+ })?
5242
+ .len();
5243
+ if existing_size > 0 {
5244
+ let remote_size = payload
5245
+ .pointer("/asset/sizeBytes")
5246
+ .and_then(|value| value.as_u64())
5247
+ .unwrap_or(0);
5248
+ if remote_size > 0 && existing_size >= remote_size {
5249
+ println!(
5250
+ "{}",
5251
+ serde_json::to_string_pretty(&serde_json::json!({
5252
+ "toolId": args.tool_id,
5253
+ "assetId": asset_id,
5254
+ "output": output_path.display().to_string(),
5255
+ "bytesWritten": 0,
5256
+ "resumedFrom": existing_size,
5257
+ "alreadyComplete": true
5258
+ }))?
5259
+ );
5260
+ save_session(&session)?;
5261
+ return Ok(());
5262
+ }
5263
+ resume_from = Some(existing_size);
5264
+ }
2071
5265
  }
2072
- if let Some(status) = args.status {
2073
- query_parts.push(format!("status={}", status));
5266
+
5267
+ let mut headers = Vec::new();
5268
+ if let Some(offset) = resume_from {
5269
+ headers.push(("range".to_string(), format!("bytes={}-", offset)));
5270
+ }
5271
+ let mut download_response = authed_request_with_headers(
5272
+ client,
5273
+ &mut session,
5274
+ Method::GET,
5275
+ download_path,
5276
+ None,
5277
+ &headers,
5278
+ )
5279
+ .await?;
5280
+ if !(download_response.status().is_success()
5281
+ || download_response.status() == StatusCode::PARTIAL_CONTENT)
5282
+ {
5283
+ let body = read_error_body(download_response).await;
5284
+ return Err(anyhow!("tool local install download failed: {}", body));
2074
5285
  }
2075
5286
 
2076
- let path = if query_parts.is_empty() {
2077
- "/tools/runs".to_string()
5287
+ let append_mode = resume_from.is_some() && download_response.status() == StatusCode::PARTIAL_CONTENT;
5288
+ let mut output_file = if append_mode {
5289
+ tokio_fs::OpenOptions::new()
5290
+ .append(true)
5291
+ .open(&output_path)
5292
+ .await
5293
+ .with_context(|| {
5294
+ format!(
5295
+ "Failed to open output file for append {}",
5296
+ output_path.display()
5297
+ )
5298
+ })?
2078
5299
  } else {
2079
- format!("/tools/runs?{}", query_parts.join("&"))
5300
+ tokio_fs::File::create(&output_path)
5301
+ .await
5302
+ .with_context(|| format!("Failed to create output file {}", output_path.display()))?
2080
5303
  };
2081
- let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2082
- if !response.status().is_success() {
2083
- let body = read_error_body(response).await;
2084
- return Err(anyhow!("tool runs failed: {}", body));
5304
+
5305
+ let mut bytes_written: u64 = 0;
5306
+ while let Some(chunk) = download_response.chunk().await? {
5307
+ output_file
5308
+ .write_all(&chunk)
5309
+ .await
5310
+ .with_context(|| format!("Failed to write to {}", output_path.display()))?;
5311
+ bytes_written = bytes_written.saturating_add(chunk.len() as u64);
2085
5312
  }
2086
- let payload: serde_json::Value = response.json().await?;
5313
+ output_file.flush().await?;
5314
+
2087
5315
  println!(
2088
5316
  "{}",
2089
- serde_json::to_string_pretty(payload.get("runs").unwrap_or(&payload))?
5317
+ serde_json::to_string_pretty(&serde_json::json!({
5318
+ "toolId": args.tool_id,
5319
+ "platform": platform,
5320
+ "arch": arch,
5321
+ "assetId": asset_id,
5322
+ "output": output_path.display().to_string(),
5323
+ "bytesWritten": bytes_written,
5324
+ "resumedFrom": resume_from,
5325
+ "partialContent": download_response.status() == StatusCode::PARTIAL_CONTENT
5326
+ }))?
2090
5327
  );
2091
5328
  save_session(&session)?;
2092
5329
  Ok(())
2093
5330
  }
2094
5331
 
2095
- async fn tool_get_run_command(client: &reqwest::Client, args: ToolGetRunArgs) -> Result<()> {
5332
+ async fn tool_local_complete_run_command(
5333
+ client: &reqwest::Client,
5334
+ args: ToolLocalCompleteRunArgs,
5335
+ ) -> Result<()> {
2096
5336
  let mut session = load_session()?;
2097
5337
  apply_base_url_override(&mut session, args.base_url);
2098
5338
 
2099
- let path = format!("/tools/runs/{}", args.run_id);
2100
- let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
5339
+ let normalized_status = args.status.trim().to_lowercase();
5340
+ if normalized_status != "succeeded"
5341
+ && normalized_status != "failed"
5342
+ && normalized_status != "cancelled"
5343
+ {
5344
+ return Err(anyhow!(
5345
+ "status must be one of: succeeded, failed, cancelled"
5346
+ ));
5347
+ }
5348
+
5349
+ if args.output_file.is_some() && args.output_json.is_some() {
5350
+ return Err(anyhow!(
5351
+ "Provide either --output-file or --output-json, not both"
5352
+ ));
5353
+ }
5354
+
5355
+ let output_value = if let Some(path) = args.output_file {
5356
+ Some(load_jsonc_file(&path, "tool local complete output")?)
5357
+ } else if let Some(raw) = args.output_json {
5358
+ Some(parse_jsonc_str(&raw, "tool local complete output")?)
5359
+ } else {
5360
+ None
5361
+ };
5362
+
5363
+ let mut body = serde_json::Map::new();
5364
+ body.insert(
5365
+ "status".to_string(),
5366
+ serde_json::Value::String(normalized_status),
5367
+ );
5368
+ if let Some(output) = output_value {
5369
+ body.insert(
5370
+ "output".to_string(),
5371
+ serde_json::Value::Object(parse_object_from_value(
5372
+ output,
5373
+ "tool local complete output",
5374
+ )?),
5375
+ );
5376
+ }
5377
+ if let Some(error_message) = args.error_message {
5378
+ body.insert(
5379
+ "errorMessage".to_string(),
5380
+ serde_json::Value::String(error_message),
5381
+ );
5382
+ }
5383
+ if let Some(path) = args.metadata_file {
5384
+ let metadata = load_jsonc_file(&path, "tool local complete metadata")?;
5385
+ body.insert(
5386
+ "metadata".to_string(),
5387
+ serde_json::Value::Object(parse_object_from_value(
5388
+ metadata,
5389
+ "tool local complete metadata",
5390
+ )?),
5391
+ );
5392
+ }
5393
+
5394
+ let path = format!("/tools/runs/{}/complete", args.run_id);
5395
+ let response = authed_request(
5396
+ client,
5397
+ &mut session,
5398
+ Method::POST,
5399
+ &path,
5400
+ Some(serde_json::Value::Object(body)),
5401
+ )
5402
+ .await?;
2101
5403
  if !response.status().is_success() {
2102
5404
  let body = read_error_body(response).await;
2103
- return Err(anyhow!("tool get-run failed: {}", body));
5405
+ return Err(anyhow!("tool local complete-run failed: {}", body));
2104
5406
  }
2105
5407
  let payload: serde_json::Value = response.json().await?;
2106
5408
  println!(
@@ -2121,23 +5423,58 @@ fn run_and_check_status(mut command: Command, context: &str) -> Result<()> {
2121
5423
  Ok(())
2122
5424
  }
2123
5425
 
2124
- async fn self_update_command(client: &reqwest::Client, args: SelfUpdateArgs) -> Result<()> {
5426
+ async fn self_update_command(
5427
+ client: &reqwest::Client,
5428
+ args: SelfUpdateArgs,
5429
+ output: OutputFormat,
5430
+ ) -> Result<()> {
2125
5431
  let current = env!("CARGO_PKG_VERSION");
2126
5432
  let latest = fetch_latest_cli_version(client).await;
2127
5433
 
2128
5434
  let Some(latest_version) = latest else {
2129
- println!("Could not check latest version right now.");
5435
+ let payload = serde_json::json!({
5436
+ "ok": false,
5437
+ "checked": false,
5438
+ "currentVersion": current,
5439
+ "message": "Could not check latest version right now."
5440
+ });
5441
+ emit_text_or_json(output, "Could not check latest version right now.", payload)?;
2130
5442
  return Ok(());
2131
5443
  };
2132
5444
 
2133
5445
  if !is_newer_version(current, &latest_version) {
2134
- println!("reallink is up to date ({})", current);
5446
+ let payload = serde_json::json!({
5447
+ "ok": true,
5448
+ "checked": true,
5449
+ "upToDate": true,
5450
+ "currentVersion": current,
5451
+ "latestVersion": latest_version
5452
+ });
5453
+ emit_text_or_json(
5454
+ output,
5455
+ &format!("reallink is up to date ({})", current),
5456
+ payload,
5457
+ )?;
2135
5458
  return Ok(());
2136
5459
  }
2137
5460
 
2138
- println!("Update available: {} -> {}", current, latest_version);
2139
5461
  if args.check {
2140
- println!("Run `reallink self-update` to install the update.");
5462
+ let payload = serde_json::json!({
5463
+ "ok": true,
5464
+ "checked": true,
5465
+ "upToDate": false,
5466
+ "currentVersion": current,
5467
+ "latestVersion": latest_version,
5468
+ "action": "run `reallink self-update` to install"
5469
+ });
5470
+ emit_text_or_json(
5471
+ output,
5472
+ &format!(
5473
+ "Update available: {} -> {}. Run `reallink self-update`.",
5474
+ current, latest_version
5475
+ ),
5476
+ payload,
5477
+ )?;
2141
5478
  return Ok(());
2142
5479
  }
2143
5480
 
@@ -2156,9 +5493,19 @@ async fn self_update_command(client: &reqwest::Client, args: SelfUpdateArgs) ->
2156
5493
  },
2157
5494
  "npm self-update",
2158
5495
  )?;
2159
- println!(
2160
- "Updated via npm. Restart your shell if `reallink --version` still shows old version."
2161
- );
5496
+ let payload = serde_json::json!({
5497
+ "ok": true,
5498
+ "checked": true,
5499
+ "updated": true,
5500
+ "method": "npm",
5501
+ "currentVersion": current,
5502
+ "latestVersion": latest_version
5503
+ });
5504
+ emit_text_or_json(
5505
+ output,
5506
+ "Updated via npm. Restart your shell if `reallink --version` still shows old version.",
5507
+ payload,
5508
+ )?;
2162
5509
  return Ok(());
2163
5510
  }
2164
5511
 
@@ -2188,33 +5535,191 @@ async fn self_update_command(client: &reqwest::Client, args: SelfUpdateArgs) ->
2188
5535
  )?;
2189
5536
  }
2190
5537
 
2191
- println!("Update installed. Verify with `reallink --version`.");
5538
+ let payload = serde_json::json!({
5539
+ "ok": true,
5540
+ "checked": true,
5541
+ "updated": true,
5542
+ "method": if cfg!(windows) { "powershell-installer" } else { "shell-installer" },
5543
+ "currentVersion": current,
5544
+ "latestVersion": latest_version
5545
+ });
5546
+ emit_text_or_json(
5547
+ output,
5548
+ "Update installed. Verify with `reallink --version`.",
5549
+ payload,
5550
+ )?;
2192
5551
  Ok(())
2193
5552
  }
2194
5553
 
2195
- #[tokio::main]
2196
- async fn main() -> Result<()> {
2197
- let cli = Cli::parse();
5554
+ #[cfg(test)]
5555
+ mod tests {
5556
+ use super::{
5557
+ compose_plugin_archive_url, default_login_scopes, default_login_scopes_with_tools,
5558
+ file_name_component, normalize_public_bucket_base, normalize_sha256_hex, parse_api_error,
5559
+ resolve_plugin_from_index,
5560
+ };
5561
+ use super::unreal::{PluginIndexFile, PluginIndexPlugin, PluginIndexVersion};
5562
+
5563
+ #[test]
5564
+ fn normalizes_public_bucket_base() {
5565
+ let normalized = normalize_public_bucket_base("https://real-agent.link/plugins/unreal/")
5566
+ .expect("base URL should normalize");
5567
+ assert_eq!(normalized, "https://real-agent.link/plugins/unreal");
5568
+ }
5569
+
5570
+ #[test]
5571
+ fn composes_archive_url() {
5572
+ let url = compose_plugin_archive_url(
5573
+ "https://real-agent.link/plugins/unreal/",
5574
+ "RealLinkUnreal",
5575
+ "latest",
5576
+ )
5577
+ .expect("archive URL should compose");
5578
+ assert_eq!(
5579
+ url,
5580
+ "https://real-agent.link/plugins/unreal/RealLinkUnreal/latest/RealLinkUnreal.zip"
5581
+ );
5582
+ }
5583
+
5584
+ #[test]
5585
+ fn rejects_plugin_name_with_path_separator() {
5586
+ let result = compose_plugin_archive_url(
5587
+ "https://real-agent.link/plugins/unreal",
5588
+ "RealLink/Unreal",
5589
+ "latest",
5590
+ );
5591
+ assert!(result.is_err());
5592
+ }
5593
+
5594
+ #[test]
5595
+ fn extracts_file_name_component() {
5596
+ let output = file_name_component("D:/Games/MyGame/MyGame.uproject");
5597
+ assert_eq!(output, "MyGame.uproject");
5598
+ }
5599
+
5600
+ #[test]
5601
+ fn normalizes_sha256_hex() {
5602
+ let value = normalize_sha256_hex(
5603
+ "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF",
5604
+ )
5605
+ .expect("sha256 should normalize");
5606
+ assert_eq!(
5607
+ value,
5608
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
5609
+ );
5610
+ }
5611
+
5612
+ #[test]
5613
+ fn resolves_plugin_from_index_with_latest() {
5614
+ let index = PluginIndexFile {
5615
+ schema_version: Some(1),
5616
+ plugins: vec![PluginIndexPlugin {
5617
+ name: "RealLinkUnreal".to_string(),
5618
+ latest: Some("0.1.2".to_string()),
5619
+ versions: vec![PluginIndexVersion {
5620
+ version: "0.1.2".to_string(),
5621
+ archive_url: Some(
5622
+ "https://cdn.example.com/RealLinkUnreal-0.1.2.zip".to_string(),
5623
+ ),
5624
+ sha256: Some(
5625
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
5626
+ .to_string(),
5627
+ ),
5628
+ }],
5629
+ }],
5630
+ };
5631
+
5632
+ let (version, url, sha) = resolve_plugin_from_index(
5633
+ &index,
5634
+ "RealLinkUnreal",
5635
+ "latest",
5636
+ "https://real-agent.link/plugins/unreal",
5637
+ )
5638
+ .expect("index resolve should succeed");
5639
+ assert_eq!(version, "0.1.2");
5640
+ assert_eq!(url, "https://cdn.example.com/RealLinkUnreal-0.1.2.zip");
5641
+ assert_eq!(
5642
+ sha,
5643
+ Some("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string())
5644
+ );
5645
+ }
5646
+
5647
+ #[test]
5648
+ fn default_login_scopes_exclude_tool_scopes() {
5649
+ let scopes = default_login_scopes();
5650
+ assert!(scopes.contains(&"core:read".to_string()));
5651
+ assert!(scopes.contains(&"core:write".to_string()));
5652
+ assert!(scopes.contains(&"scheduler:read".to_string()));
5653
+ assert!(scopes.contains(&"scheduler:write".to_string()));
5654
+ assert!(!scopes.iter().any(|value| value.starts_with("tools:")));
5655
+ }
5656
+
5657
+ #[test]
5658
+ fn default_login_scopes_with_tools_include_tool_scopes() {
5659
+ let scopes = default_login_scopes_with_tools();
5660
+ assert!(scopes.contains(&"tools:read".to_string()));
5661
+ assert!(scopes.contains(&"tools:write".to_string()));
5662
+ assert!(scopes.contains(&"tools:run".to_string()));
5663
+ }
5664
+
5665
+ #[test]
5666
+ fn parses_api_error_envelope() {
5667
+ let parsed = parse_api_error(r#"{"code":"CLERK_ORG_NOT_LINKED","message":"Not linked"}"#)
5668
+ .expect("api error should parse");
5669
+ assert_eq!(parsed.code.as_deref(), Some("CLERK_ORG_NOT_LINKED"));
5670
+ assert_eq!(parsed.message.as_deref(), Some("Not linked"));
5671
+ }
5672
+
5673
+ #[test]
5674
+ fn ignores_non_json_api_error_body() {
5675
+ let parsed = parse_api_error("plain text error");
5676
+ assert!(parsed.is_none());
5677
+ }
5678
+ }
5679
+
5680
+ async fn run_cli(cli: Cli) -> Result<()> {
2198
5681
  let client = reqwest::Client::builder()
2199
5682
  .user_agent(concat!("reallink-cli/", env!("CARGO_PKG_VERSION")))
2200
5683
  .build()?;
2201
5684
 
2202
5685
  if !matches!(&cli.command, Commands::SelfUpdate(_)) {
2203
5686
  let allow_update_fetch = matches!(&cli.command, Commands::Login(_));
2204
- maybe_notify_update(&client, false, allow_update_fetch).await;
5687
+ maybe_notify_update(&client, false, allow_update_fetch, cli.output).await;
2205
5688
  }
2206
5689
 
2207
5690
  match cli.command {
2208
- Commands::Login(args) => login_command(&client, args).await?,
5691
+ Commands::Login(args) => login_command(&client, args, cli.output).await?,
2209
5692
  Commands::Whoami(args) => whoami_command(&client, args).await?,
2210
- Commands::Logout => logout_command(&client).await?,
2211
- Commands::SelfUpdate(args) => self_update_command(&client, args).await?,
5693
+ Commands::Logout => logout_command(&client, cli.output).await?,
5694
+ Commands::SelfUpdate(args) => self_update_command(&client, args, cli.output).await?,
5695
+ Commands::Org { command } => match command {
5696
+ OrgCommands::List(args) => org_list_command(&client, args).await?,
5697
+ OrgCommands::Create(args) => org_create_command(&client, args).await?,
5698
+ OrgCommands::Get(args) => org_get_command(&client, args).await?,
5699
+ OrgCommands::Update(args) => org_update_command(&client, args).await?,
5700
+ OrgCommands::Delete(args) => org_delete_command(&client, args).await?,
5701
+ OrgCommands::Invites(args) => org_invites_command(&client, args).await?,
5702
+ OrgCommands::Invite(args) => org_invite_command(&client, args).await?,
5703
+ OrgCommands::Members(args) => org_members_command(&client, args).await?,
5704
+ OrgCommands::AddMember(args) => org_add_member_command(&client, args).await?,
5705
+ OrgCommands::UpdateMember(args) => org_update_member_command(&client, args).await?,
5706
+ OrgCommands::RemoveMember(args) => org_remove_member_command(&client, args).await?,
5707
+ },
5708
+ Commands::Link { command } => unreal::dispatch(&client, command).await?,
2212
5709
  Commands::Project { command } => match command {
2213
5710
  ProjectCommands::List(args) => project_list_command(&client, args).await?,
2214
5711
  ProjectCommands::Create(args) => project_create_command(&client, args).await?,
2215
5712
  ProjectCommands::Get(args) => project_get_command(&client, args).await?,
2216
5713
  ProjectCommands::Update(args) => project_update_command(&client, args).await?,
2217
5714
  ProjectCommands::Delete(args) => project_delete_command(&client, args).await?,
5715
+ ProjectCommands::Members(args) => project_members_command(&client, args).await?,
5716
+ ProjectCommands::AddMember(args) => project_add_member_command(&client, args).await?,
5717
+ ProjectCommands::UpdateMember(args) => {
5718
+ project_update_member_command(&client, args).await?
5719
+ }
5720
+ ProjectCommands::RemoveMember(args) => {
5721
+ project_remove_member_command(&client, args).await?
5722
+ }
2218
5723
  },
2219
5724
  Commands::Token { command } => match command {
2220
5725
  TokenCommands::List(args) => token_list_command(&client, args).await?,
@@ -2223,24 +5728,89 @@ async fn main() -> Result<()> {
2223
5728
  },
2224
5729
  Commands::File { command } => match command {
2225
5730
  FileCommands::List(args) => file_list_command(&client, args).await?,
5731
+ FileCommands::Tree(args) => file_tree_command(&client, args).await?,
2226
5732
  FileCommands::Get(args) => file_get_command(&client, args).await?,
2227
5733
  FileCommands::Stat(args) => file_stat_command(&client, args).await?,
5734
+ FileCommands::Thumbnail(args) => file_thumbnail_command(&client, args).await?,
2228
5735
  FileCommands::Download(args) => file_download_command(&client, args).await?,
2229
5736
  FileCommands::Upload(args) => file_upload_command(&client, args).await?,
2230
5737
  FileCommands::Mkdir(args) => file_mkdir_command(&client, args).await?,
2231
5738
  FileCommands::Move(args) => file_move_command(&client, args).await?,
5739
+ FileCommands::Set(args) => file_set_command(&client, args).await?,
5740
+ FileCommands::MoveFolder(args) => file_move_folder_command(&client, args).await?,
5741
+ FileCommands::Rmdir(args) => file_rmdir_command(&client, args).await?,
2232
5742
  FileCommands::Remove(args) => file_remove_command(&client, args).await?,
2233
5743
  },
2234
5744
  Commands::Tool { command } => match command {
2235
5745
  ToolCommands::List(args) => tool_list_command(&client, args).await?,
2236
5746
  ToolCommands::Register(args) => tool_register_command(&client, args).await?,
5747
+ ToolCommands::Publish(args) => tool_publish_command(&client, args).await?,
2237
5748
  ToolCommands::Enable(args) => tool_enable_command(&client, args).await?,
2238
5749
  ToolCommands::Disable(args) => tool_disable_command(&client, args).await?,
2239
5750
  ToolCommands::Run(args) => tool_run_command(&client, args).await?,
2240
5751
  ToolCommands::Runs(args) => tool_runs_command(&client, args).await?,
2241
5752
  ToolCommands::GetRun(args) => tool_get_run_command(&client, args).await?,
5753
+ ToolCommands::RunEvents(args) => tool_run_events_command(&client, args).await?,
5754
+ ToolCommands::Local { command } => match command {
5755
+ ToolLocalCommands::Catalog(args) => tool_local_catalog_command(&client, args).await?,
5756
+ ToolLocalCommands::Install(args) => tool_local_install_command(&client, args).await?,
5757
+ ToolLocalCommands::CompleteRun(args) => {
5758
+ tool_local_complete_run_command(&client, args).await?
5759
+ }
5760
+ },
5761
+ },
5762
+ Commands::Logs { command } => match command {
5763
+ LogsCommands::Status => logs_status_command(cli.output).await?,
5764
+ LogsCommands::Consent(args) => logs_consent_command(args, cli.output).await?,
5765
+ LogsCommands::Tail(args) => logs_tail_command(args, cli.output).await?,
5766
+ LogsCommands::Upload(args) => logs_upload_command(&client, args, cli.output).await?,
2242
5767
  },
2243
5768
  }
2244
5769
 
2245
5770
  Ok(())
2246
5771
  }
5772
+
5773
+ #[tokio::main]
5774
+ async fn main() {
5775
+ let cli = Cli::parse();
5776
+ let output = cli.output;
5777
+ let command_summary = command_summary_for_logs();
5778
+ let started_at = now_epoch_ms();
5779
+
5780
+ if let Err(error) = run_cli(cli).await {
5781
+ let duration_ms = now_epoch_ms().saturating_sub(started_at);
5782
+ append_runtime_log_event(
5783
+ &command_summary,
5784
+ &format!("command failed: {}", error),
5785
+ "error",
5786
+ Some(duration_ms),
5787
+ Some(1),
5788
+ );
5789
+ record_cli_crash_report(&command_summary, &error);
5790
+ match output {
5791
+ OutputFormat::Json => {
5792
+ let payload = serde_json::json!({
5793
+ "ok": false,
5794
+ "error": format!("{:#}", error)
5795
+ });
5796
+ if let Err(emit_error) = print_json(&payload) {
5797
+ eprintln!("Error: {}", error);
5798
+ eprintln!("Failed to emit JSON error payload: {}", emit_error);
5799
+ }
5800
+ }
5801
+ OutputFormat::Text => {
5802
+ eprintln!("Error: {:#}", error);
5803
+ }
5804
+ }
5805
+ std::process::exit(1);
5806
+ }
5807
+
5808
+ let duration_ms = now_epoch_ms().saturating_sub(started_at);
5809
+ append_runtime_log_event(
5810
+ &command_summary,
5811
+ "command completed",
5812
+ "info",
5813
+ Some(duration_ms),
5814
+ Some(0),
5815
+ );
5816
+ }