reallink-cli 0.1.1 → 0.1.5

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
@@ -3,11 +3,15 @@ use clap::{ArgAction, Args, Parser, Subcommand};
3
3
  use reqwest::{Method, StatusCode};
4
4
  use serde::{Deserialize, Serialize};
5
5
  use std::fs;
6
- use std::path::PathBuf;
6
+ use std::io::{self, Write};
7
+ use std::path::{Path, PathBuf};
7
8
  use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9
  use tokio::time::sleep;
9
10
 
10
11
  const DEFAULT_BASE_URL: &str = "https://api.real-agent.link";
12
+ const CONFIG_DIR_ENV: &str = "REALLINK_CONFIG_DIR";
13
+ const SESSION_DIR_NAME: &str = "reallink";
14
+ const SESSION_FILE_NAME: &str = "session.json";
11
15
 
12
16
  #[derive(Parser)]
13
17
  #[command(name = "reallink", version, about = "Reallink CLI")]
@@ -21,10 +25,22 @@ enum Commands {
21
25
  Login(LoginArgs),
22
26
  Whoami(BaseArgs),
23
27
  Logout,
28
+ Project {
29
+ #[command(subcommand)]
30
+ command: ProjectCommands,
31
+ },
24
32
  Token {
25
33
  #[command(subcommand)]
26
34
  command: TokenCommands,
27
35
  },
36
+ File {
37
+ #[command(subcommand)]
38
+ command: FileCommands,
39
+ },
40
+ Tool {
41
+ #[command(subcommand)]
42
+ command: ToolCommands,
43
+ },
28
44
  }
29
45
 
30
46
  #[derive(Args)]
@@ -41,6 +57,8 @@ struct LoginArgs {
41
57
  client_id: String,
42
58
  #[arg(long = "scope", action = ArgAction::Append)]
43
59
  scope: Vec<String>,
60
+ #[arg(long)]
61
+ force: bool,
44
62
  }
45
63
 
46
64
  #[derive(Subcommand)]
@@ -50,6 +68,36 @@ enum TokenCommands {
50
68
  Revoke(TokenRevokeArgs),
51
69
  }
52
70
 
71
+ #[derive(Subcommand)]
72
+ enum ProjectCommands {
73
+ List(ProjectListArgs),
74
+ Create(ProjectCreateArgs),
75
+ Get(ProjectGetArgs),
76
+ Update(ProjectUpdateArgs),
77
+ Delete(ProjectDeleteArgs),
78
+ }
79
+
80
+ #[derive(Subcommand)]
81
+ enum FileCommands {
82
+ List(FileListArgs),
83
+ Get(FileGetArgs),
84
+ Upload(FileUploadArgs),
85
+ Mkdir(FileMkdirArgs),
86
+ Move(FileMoveArgs),
87
+ Remove(FileRemoveArgs),
88
+ }
89
+
90
+ #[derive(Subcommand)]
91
+ enum ToolCommands {
92
+ List(ToolListArgs),
93
+ Register(ToolRegisterArgs),
94
+ Enable(ToolEnableArgs),
95
+ Disable(ToolDisableArgs),
96
+ Run(ToolRunArgs),
97
+ Runs(ToolRunsArgs),
98
+ GetRun(ToolGetRunArgs),
99
+ }
100
+
53
101
  #[derive(Args)]
54
102
  struct TokenCreateArgs {
55
103
  #[arg(long)]
@@ -74,6 +122,212 @@ struct TokenRevokeArgs {
74
122
  base_url: Option<String>,
75
123
  }
76
124
 
125
+ #[derive(Args)]
126
+ struct FileListArgs {
127
+ #[arg(long)]
128
+ project_id: String,
129
+ #[arg(long)]
130
+ path: Option<String>,
131
+ #[arg(long)]
132
+ base_url: Option<String>,
133
+ }
134
+
135
+ #[derive(Args)]
136
+ struct FileGetArgs {
137
+ #[arg(long)]
138
+ asset_id: String,
139
+ #[arg(long)]
140
+ base_url: Option<String>,
141
+ }
142
+
143
+ #[derive(Args)]
144
+ struct FileUploadArgs {
145
+ #[arg(long)]
146
+ project_id: String,
147
+ #[arg(long)]
148
+ source: PathBuf,
149
+ #[arg(long)]
150
+ path: Option<String>,
151
+ #[arg(long, default_value = "other")]
152
+ asset_type: String,
153
+ #[arg(long, default_value = "private")]
154
+ visibility: String,
155
+ #[arg(long)]
156
+ base_url: Option<String>,
157
+ }
158
+
159
+ #[derive(Args)]
160
+ struct FileMkdirArgs {
161
+ #[arg(long)]
162
+ project_id: String,
163
+ #[arg(long)]
164
+ path: String,
165
+ #[arg(long)]
166
+ base_url: Option<String>,
167
+ }
168
+
169
+ #[derive(Args)]
170
+ struct FileMoveArgs {
171
+ #[arg(long)]
172
+ asset_id: String,
173
+ #[arg(long)]
174
+ file_name: String,
175
+ #[arg(long)]
176
+ base_url: Option<String>,
177
+ }
178
+
179
+ #[derive(Args)]
180
+ struct FileRemoveArgs {
181
+ #[arg(long)]
182
+ asset_id: String,
183
+ #[arg(long)]
184
+ base_url: Option<String>,
185
+ }
186
+
187
+ #[derive(Args)]
188
+ struct ToolListArgs {
189
+ #[arg(long)]
190
+ include_inactive: bool,
191
+ #[arg(long)]
192
+ include_disabled_channel: bool,
193
+ #[arg(long)]
194
+ base_url: Option<String>,
195
+ }
196
+
197
+ #[derive(Args)]
198
+ struct ToolRegisterArgs {
199
+ #[arg(long, help = "Path to a JSON/JSONC tool manifest")]
200
+ manifest: PathBuf,
201
+ #[arg(long)]
202
+ base_url: Option<String>,
203
+ }
204
+
205
+ #[derive(Args)]
206
+ struct ToolEnableArgs {
207
+ #[arg(long)]
208
+ tool_id: String,
209
+ #[arg(long)]
210
+ org_id: Option<String>,
211
+ #[arg(long)]
212
+ project_id: Option<String>,
213
+ #[arg(long)]
214
+ user_id: Option<String>,
215
+ #[arg(long)]
216
+ expires_at: Option<String>,
217
+ #[arg(long, help = "Path to JSON/JSONC metadata file")]
218
+ metadata_file: Option<PathBuf>,
219
+ #[arg(long)]
220
+ base_url: Option<String>,
221
+ }
222
+
223
+ #[derive(Args)]
224
+ struct ToolDisableArgs {
225
+ #[arg(long)]
226
+ tool_id: String,
227
+ #[arg(long)]
228
+ org_id: Option<String>,
229
+ #[arg(long)]
230
+ project_id: Option<String>,
231
+ #[arg(long)]
232
+ user_id: Option<String>,
233
+ #[arg(long, help = "Path to JSON/JSONC metadata file")]
234
+ metadata_file: Option<PathBuf>,
235
+ #[arg(long)]
236
+ base_url: Option<String>,
237
+ }
238
+
239
+ #[derive(Args)]
240
+ struct ToolRunArgs {
241
+ #[arg(long)]
242
+ tool_id: String,
243
+ #[arg(long)]
244
+ org_id: Option<String>,
245
+ #[arg(long)]
246
+ project_id: Option<String>,
247
+ #[arg(long, help = "Inline JSON object for tool input")]
248
+ input_json: Option<String>,
249
+ #[arg(long, help = "Path to JSON/JSONC file for tool input")]
250
+ input_file: Option<PathBuf>,
251
+ #[arg(long, help = "Path to JSON/JSONC metadata file")]
252
+ metadata_file: Option<PathBuf>,
253
+ #[arg(long, help = "Idempotency key for deduplicating retries")]
254
+ idempotency_key: Option<String>,
255
+ #[arg(long)]
256
+ base_url: Option<String>,
257
+ }
258
+
259
+ #[derive(Args)]
260
+ struct ToolRunsArgs {
261
+ #[arg(long)]
262
+ tool_id: Option<String>,
263
+ #[arg(long)]
264
+ project_id: Option<String>,
265
+ #[arg(long)]
266
+ requested_by_user_id: Option<String>,
267
+ #[arg(long)]
268
+ status: Option<String>,
269
+ #[arg(long)]
270
+ base_url: Option<String>,
271
+ }
272
+
273
+ #[derive(Args)]
274
+ struct ToolGetRunArgs {
275
+ #[arg(long)]
276
+ run_id: String,
277
+ #[arg(long)]
278
+ base_url: Option<String>,
279
+ }
280
+
281
+ #[derive(Args)]
282
+ struct ProjectListArgs {
283
+ #[arg(long)]
284
+ org_id: Option<String>,
285
+ #[arg(long)]
286
+ base_url: Option<String>,
287
+ }
288
+
289
+ #[derive(Args)]
290
+ struct ProjectCreateArgs {
291
+ #[arg(long)]
292
+ org_id: String,
293
+ #[arg(long)]
294
+ name: String,
295
+ #[arg(long)]
296
+ description: Option<String>,
297
+ #[arg(long)]
298
+ base_url: Option<String>,
299
+ }
300
+
301
+ #[derive(Args)]
302
+ struct ProjectGetArgs {
303
+ #[arg(long)]
304
+ project_id: String,
305
+ #[arg(long)]
306
+ base_url: Option<String>,
307
+ }
308
+
309
+ #[derive(Args)]
310
+ struct ProjectUpdateArgs {
311
+ #[arg(long)]
312
+ project_id: String,
313
+ #[arg(long)]
314
+ name: Option<String>,
315
+ #[arg(long)]
316
+ description: Option<String>,
317
+ #[arg(long)]
318
+ clear_description: bool,
319
+ #[arg(long)]
320
+ base_url: Option<String>,
321
+ }
322
+
323
+ #[derive(Args)]
324
+ struct ProjectDeleteArgs {
325
+ #[arg(long)]
326
+ project_id: String,
327
+ #[arg(long)]
328
+ base_url: Option<String>,
329
+ }
330
+
77
331
  #[derive(Debug, Serialize, Deserialize, Clone)]
78
332
  struct SessionConfig {
79
333
  base_url: String,
@@ -185,8 +439,153 @@ struct CreateApiTokenResponse {
185
439
  expires_at: Option<String>,
186
440
  }
187
441
 
442
+ #[derive(Debug, Serialize, Deserialize)]
443
+ #[serde(rename_all = "camelCase")]
444
+ struct ProjectRecord {
445
+ id: String,
446
+ #[serde(alias = "orgId")]
447
+ org_id: String,
448
+ name: String,
449
+ description: Option<String>,
450
+ #[serde(alias = "createdAt")]
451
+ created_at: String,
452
+ }
453
+
454
+ #[derive(Debug, Serialize, Deserialize)]
455
+ struct ListProjectsResponse {
456
+ projects: Vec<ProjectRecord>,
457
+ }
458
+
459
+ #[derive(Debug, Serialize, Deserialize)]
460
+ #[serde(rename_all = "camelCase")]
461
+ struct ProjectResponse {
462
+ project: ProjectRecord,
463
+ #[serde(alias = "accessLevel")]
464
+ access_level: Option<String>,
465
+ }
466
+
467
+ #[derive(Debug, Serialize, Deserialize)]
468
+ #[serde(rename_all = "camelCase")]
469
+ struct AssetRecord {
470
+ id: String,
471
+ project_id: String,
472
+ file_name: String,
473
+ content_type: String,
474
+ size_bytes: i64,
475
+ asset_type: String,
476
+ visibility: String,
477
+ status: String,
478
+ object_key: String,
479
+ etag: Option<String>,
480
+ created_at: String,
481
+ updated_at: String,
482
+ }
483
+
484
+ #[derive(Debug, Serialize, Deserialize)]
485
+ struct ListAssetsResponse {
486
+ assets: Vec<AssetRecord>,
487
+ }
488
+
489
+ #[derive(Debug, Serialize, Deserialize)]
490
+ struct AssetResponse {
491
+ asset: AssetRecord,
492
+ }
493
+
494
+ #[derive(Debug, Serialize)]
495
+ #[serde(rename_all = "camelCase")]
496
+ struct UploadIntentRequest {
497
+ project_id: String,
498
+ file_name: String,
499
+ content_type: String,
500
+ size_bytes: i64,
501
+ asset_type: String,
502
+ visibility: String,
503
+ }
504
+
505
+ #[derive(Debug, Serialize, Deserialize)]
506
+ #[serde(rename_all = "camelCase")]
507
+ struct UploadIntentResponse {
508
+ asset_id: String,
509
+ #[serde(default = "default_upload_strategy")]
510
+ strategy: String,
511
+ upload_url: Option<String>,
512
+ method: Option<String>,
513
+ required_headers: Option<std::collections::HashMap<String, String>>,
514
+ session_id: Option<String>,
515
+ part_size_bytes: Option<i64>,
516
+ part_count: Option<i64>,
517
+ parts: Option<Vec<UploadPartIntent>>,
518
+ }
519
+
520
+ fn default_upload_strategy() -> String {
521
+ "single".to_string()
522
+ }
523
+
524
+ #[derive(Debug, Serialize, Deserialize, Clone)]
525
+ #[serde(rename_all = "camelCase")]
526
+ struct UploadPartIntent {
527
+ part_number: i64,
528
+ upload_url: String,
529
+ method: String,
530
+ required_headers: std::collections::HashMap<String, String>,
531
+ }
532
+
533
+ #[derive(Debug, Serialize)]
534
+ #[serde(rename_all = "camelCase")]
535
+ struct UploadCompletedPart {
536
+ part_number: i64,
537
+ etag: String,
538
+ size_bytes: i64,
539
+ }
540
+
541
+ #[derive(Debug, Serialize)]
542
+ #[serde(rename_all = "camelCase")]
543
+ struct UploadCompleteRequest {
544
+ asset_id: String,
545
+ #[serde(skip_serializing_if = "Option::is_none")]
546
+ session_id: Option<String>,
547
+ #[serde(skip_serializing_if = "Option::is_none")]
548
+ etag: Option<String>,
549
+ #[serde(skip_serializing_if = "Option::is_none")]
550
+ size_bytes: Option<i64>,
551
+ #[serde(skip_serializing_if = "Option::is_none")]
552
+ completed_parts: Option<Vec<UploadCompletedPart>>,
553
+ }
554
+
188
555
  fn normalize_base_url(input: &str) -> String {
189
- input.trim().trim_end_matches('/').to_string()
556
+ let trimmed = input.trim().trim_end_matches('/');
557
+ match reqwest::Url::parse(trimmed) {
558
+ Ok(url) => {
559
+ let Some(host) = url.host_str() else {
560
+ return trimmed.to_string();
561
+ };
562
+ let mut normalized = format!("{}://{}", url.scheme(), host);
563
+ if let Some(port) = url.port() {
564
+ normalized.push_str(&format!(":{}", port));
565
+ }
566
+ normalized
567
+ }
568
+ Err(_) => trimmed.to_string(),
569
+ }
570
+ }
571
+
572
+ fn parse_jsonc_str(raw: &str, context: &str) -> Result<serde_json::Value> {
573
+ let value: serde_json::Value =
574
+ json5::from_str(raw).with_context(|| format!("Failed to parse {}", context))?;
575
+ Ok(value)
576
+ }
577
+
578
+ fn load_jsonc_file(path: &Path, label: &str) -> Result<serde_json::Value> {
579
+ let raw = fs::read_to_string(path)
580
+ .with_context(|| format!("Failed to read {} file {}", label, path.display()))?;
581
+ parse_jsonc_str(&raw, &format!("{} file {}", label, path.display()))
582
+ }
583
+
584
+ fn parse_object_from_value(value: serde_json::Value, context: &str) -> Result<serde_json::Map<String, serde_json::Value>> {
585
+ match value {
586
+ serde_json::Value::Object(map) => Ok(map),
587
+ _ => Err(anyhow!("{} must be a JSON object", context)),
588
+ }
190
589
  }
191
590
 
192
591
  fn now_epoch_ms() -> u128 {
@@ -196,9 +595,43 @@ fn now_epoch_ms() -> u128 {
196
595
  .as_millis()
197
596
  }
198
597
 
598
+ fn resolve_config_root() -> Result<PathBuf> {
599
+ if let Ok(custom_root) = std::env::var(CONFIG_DIR_ENV) {
600
+ let trimmed = custom_root.trim();
601
+ if !trimmed.is_empty() {
602
+ return Ok(PathBuf::from(trimmed));
603
+ }
604
+ }
605
+
606
+ dirs::config_dir().ok_or_else(|| anyhow!("Could not resolve config directory"))
607
+ }
608
+
199
609
  fn config_path() -> Result<PathBuf> {
200
- let base = dirs::config_dir().ok_or_else(|| anyhow!("Could not resolve config directory"))?;
201
- Ok(base.join("reallink").join("session.json"))
610
+ let base = resolve_config_root()?;
611
+ Ok(base.join(SESSION_DIR_NAME).join(SESSION_FILE_NAME))
612
+ }
613
+
614
+ fn session_path_display() -> String {
615
+ config_path()
616
+ .map(|path| path.display().to_string())
617
+ .unwrap_or_else(|_| format!("<{}>/{}", SESSION_DIR_NAME, SESSION_FILE_NAME))
618
+ }
619
+
620
+ fn write_atomic(path: &Path, payload: &[u8]) -> Result<()> {
621
+ let temp_path = path.with_extension(format!("tmp.{}", now_epoch_ms()));
622
+ fs::write(&temp_path, payload)
623
+ .with_context(|| format!("Failed to write temporary file {}", temp_path.display()))?;
624
+ if path.exists() {
625
+ fs::remove_file(path).with_context(|| format!("Failed to replace {}", path.display()))?;
626
+ }
627
+ fs::rename(&temp_path, path).with_context(|| {
628
+ format!(
629
+ "Failed to move temporary file {} to {}",
630
+ temp_path.display(),
631
+ path.display()
632
+ )
633
+ })?;
634
+ Ok(())
202
635
  }
203
636
 
204
637
  fn save_session(session: &SessionConfig) -> Result<()> {
@@ -207,7 +640,7 @@ fn save_session(session: &SessionConfig) -> Result<()> {
207
640
  fs::create_dir_all(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
208
641
  }
209
642
  let payload = serde_json::to_vec_pretty(session)?;
210
- fs::write(&path, payload).with_context(|| format!("Failed to write {}", path.display()))?;
643
+ write_atomic(&path, &payload)?;
211
644
  Ok(())
212
645
  }
213
646
 
@@ -224,12 +657,13 @@ fn load_session() -> Result<SessionConfig> {
224
657
  Ok(session)
225
658
  }
226
659
 
227
- fn clear_session() -> Result<()> {
660
+ fn clear_session() -> Result<bool> {
228
661
  let path = config_path()?;
229
662
  if path.exists() {
230
663
  fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?;
664
+ return Ok(true);
231
665
  }
232
- Ok(())
666
+ Ok(false)
233
667
  }
234
668
 
235
669
  async fn read_error_body(response: reqwest::Response) -> String {
@@ -239,6 +673,33 @@ async fn read_error_body(response: reqwest::Response) -> String {
239
673
  }
240
674
  }
241
675
 
676
+ fn clean_virtual_path(value: &str) -> String {
677
+ value
678
+ .split('/')
679
+ .map(|segment| segment.trim())
680
+ .filter(|segment| !segment.is_empty() && *segment != "." && *segment != "..")
681
+ .collect::<Vec<_>>()
682
+ .join("/")
683
+ }
684
+
685
+ fn apply_base_url_override(session: &mut SessionConfig, base_url: Option<String>) {
686
+ if let Some(base_url) = base_url {
687
+ session.base_url = normalize_base_url(&base_url);
688
+ }
689
+ }
690
+
691
+ fn join_remote_path(prefix: Option<&str>, file_name: &str) -> String {
692
+ let file_clean = clean_virtual_path(file_name);
693
+ let prefix_clean = prefix.map(clean_virtual_path).unwrap_or_default();
694
+ if prefix_clean.is_empty() {
695
+ return file_clean;
696
+ }
697
+ if file_clean.is_empty() {
698
+ return prefix_clean;
699
+ }
700
+ format!("{}/{}", prefix_clean, file_clean)
701
+ }
702
+
242
703
  async fn authed_request(
243
704
  client: &reqwest::Client,
244
705
  session: &mut SessionConfig,
@@ -289,8 +750,41 @@ async fn refresh_session(client: &reqwest::Client, session: &mut SessionConfig)
289
750
  Ok(())
290
751
  }
291
752
 
753
+ async fn existing_session_identity_for_base_url(
754
+ client: &reqwest::Client,
755
+ base_url: &str,
756
+ ) -> Option<String> {
757
+ let mut session = load_session().ok()?;
758
+ if normalize_base_url(&session.base_url) != base_url {
759
+ return None;
760
+ }
761
+
762
+ let response = authed_request(client, &mut session, Method::GET, "/auth/me", None)
763
+ .await
764
+ .ok()?;
765
+ if !response.status().is_success() {
766
+ return None;
767
+ }
768
+
769
+ let payload: serde_json::Value = response.json().await.ok()?;
770
+ let _ = save_session(&session);
771
+ payload
772
+ .get("user")
773
+ .and_then(|user| user.get("email"))
774
+ .and_then(|value| value.as_str())
775
+ .map(|email| email.to_string())
776
+ }
777
+
292
778
  async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()> {
293
779
  let base_url = normalize_base_url(&args.base_url);
780
+ if !args.force {
781
+ if let Some(email) = existing_session_identity_for_base_url(client, &base_url).await {
782
+ println!("Already logged in as {} on {}.", email, base_url);
783
+ println!("Use `reallink logout` to sign out or `reallink login --force` to replace this session.");
784
+ return Ok(());
785
+ }
786
+ }
787
+
294
788
  let scope = if args.scope.is_empty() {
295
789
  vec![
296
790
  "core:read".to_string(),
@@ -331,9 +825,14 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
331
825
 
332
826
  let expires_at = std::time::Instant::now() + Duration::from_secs(device_code.expires_in);
333
827
  let mut poll_interval = Duration::from_secs(device_code.interval.max(1));
828
+ let mut pending_polls = 0u32;
829
+ println!("Waiting for approval (press Ctrl+C to cancel)");
334
830
 
335
831
  loop {
336
832
  if std::time::Instant::now() >= expires_at {
833
+ if pending_polls > 0 {
834
+ println!();
835
+ }
337
836
  return Err(anyhow!("Device code expired before approval"));
338
837
  }
339
838
 
@@ -351,6 +850,9 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
351
850
 
352
851
  if token_response.status().is_success() {
353
852
  let tokens: DeviceTokenSuccess = token_response.json().await?;
853
+ if pending_polls > 0 {
854
+ println!();
855
+ }
354
856
  let session = SessionConfig {
355
857
  base_url: base_url.clone(),
356
858
  access_token: tokens.access_token,
@@ -360,6 +862,7 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
360
862
  };
361
863
  save_session(&session)?;
362
864
  println!("Login successful.");
865
+ println!("Session stored at {}", session_path_display());
363
866
  return Ok(());
364
867
  }
365
868
 
@@ -372,12 +875,23 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
372
875
  });
373
876
 
374
877
  match error_payload.error.as_str() {
375
- "authorization_pending" => continue,
878
+ "authorization_pending" => {
879
+ pending_polls = pending_polls.saturating_add(1);
880
+ print!(".");
881
+ let _ = io::stdout().flush();
882
+ continue;
883
+ }
376
884
  "slow_down" => {
377
885
  poll_interval += Duration::from_secs(1);
886
+ pending_polls = pending_polls.saturating_add(1);
887
+ print!("+");
888
+ let _ = io::stdout().flush();
378
889
  continue;
379
890
  }
380
891
  _ => {
892
+ if pending_polls > 0 {
893
+ println!();
894
+ }
381
895
  return Err(anyhow!(
382
896
  "Device login failed: {} ({})",
383
897
  error_payload.error,
@@ -388,11 +902,56 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
388
902
  }
389
903
  }
390
904
 
905
+ async fn logout_command(client: &reqwest::Client) -> Result<()> {
906
+ let path_display = session_path_display();
907
+ let mut session = match load_session() {
908
+ Ok(session) => session,
909
+ Err(_) => {
910
+ println!("No local session found at {}.", path_display);
911
+ println!("You are already logged out.");
912
+ return Ok(());
913
+ }
914
+ };
915
+
916
+ let mut remote_revoked = false;
917
+ let mut remote_unavailable = false;
918
+ match authed_request(client, &mut session, Method::POST, "/auth/logout", None).await {
919
+ Ok(response) if response.status().is_success() => {
920
+ remote_revoked = true;
921
+ }
922
+ Ok(response) if response.status() == StatusCode::NOT_FOUND => {
923
+ remote_unavailable = true;
924
+ }
925
+ Ok(response) => {
926
+ let body = read_error_body(response).await;
927
+ eprintln!("Warning: remote logout request failed: {}", body);
928
+ }
929
+ Err(error) => {
930
+ eprintln!("Warning: remote logout request failed: {}", error);
931
+ }
932
+ }
933
+
934
+ let removed = clear_session()?;
935
+ if !removed {
936
+ println!("Local session was already cleared.");
937
+ return Ok(());
938
+ }
939
+
940
+ if remote_revoked {
941
+ println!("Logged out. Server session revoked and local session removed from {}.", path_display);
942
+ } else if remote_unavailable {
943
+ println!("Logged out locally. Removed session from {}.", path_display);
944
+ println!("Server logout endpoint is not available on this API deployment yet.");
945
+ } else {
946
+ println!("Logged out locally. Removed session from {}.", path_display);
947
+ }
948
+
949
+ Ok(())
950
+ }
951
+
391
952
  async fn whoami_command(client: &reqwest::Client, args: BaseArgs) -> Result<()> {
392
953
  let mut session = load_session()?;
393
- if let Some(base_url) = args.base_url {
394
- session.base_url = normalize_base_url(&base_url);
395
- }
954
+ apply_base_url_override(&mut session, args.base_url);
396
955
 
397
956
  let response = authed_request(client, &mut session, Method::GET, "/auth/me", None).await?;
398
957
  if !response.status().is_success() {
@@ -407,9 +966,7 @@ async fn whoami_command(client: &reqwest::Client, args: BaseArgs) -> Result<()>
407
966
 
408
967
  async fn token_list_command(client: &reqwest::Client, args: BaseArgs) -> Result<()> {
409
968
  let mut session = load_session()?;
410
- if let Some(base_url) = args.base_url {
411
- session.base_url = normalize_base_url(&base_url);
412
- }
969
+ apply_base_url_override(&mut session, args.base_url);
413
970
 
414
971
  let response = authed_request(client, &mut session, Method::GET, "/auth/tokens", None).await?;
415
972
  if !response.status().is_success() {
@@ -424,9 +981,7 @@ async fn token_list_command(client: &reqwest::Client, args: BaseArgs) -> Result<
424
981
 
425
982
  async fn token_create_command(client: &reqwest::Client, args: TokenCreateArgs) -> Result<()> {
426
983
  let mut session = load_session()?;
427
- if let Some(base_url) = args.base_url {
428
- session.base_url = normalize_base_url(&base_url);
429
- }
984
+ apply_base_url_override(&mut session, args.base_url);
430
985
 
431
986
  let scopes = if args.scope.is_empty() {
432
987
  return Err(anyhow!("At least one --scope must be provided"));
@@ -455,9 +1010,7 @@ async fn token_create_command(client: &reqwest::Client, args: TokenCreateArgs) -
455
1010
 
456
1011
  async fn token_revoke_command(client: &reqwest::Client, args: TokenRevokeArgs) -> Result<()> {
457
1012
  let mut session = load_session()?;
458
- if let Some(base_url) = args.base_url {
459
- session.base_url = normalize_base_url(&base_url);
460
- }
1013
+ apply_base_url_override(&mut session, args.base_url);
461
1014
 
462
1015
  let path = format!("/auth/tokens/{}", args.token_id);
463
1016
  let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
@@ -471,25 +1024,741 @@ async fn token_revoke_command(client: &reqwest::Client, args: TokenRevokeArgs) -
471
1024
  Ok(())
472
1025
  }
473
1026
 
1027
+ async fn project_list_command(client: &reqwest::Client, args: ProjectListArgs) -> Result<()> {
1028
+ let mut session = load_session()?;
1029
+ apply_base_url_override(&mut session, args.base_url);
1030
+
1031
+ let path = match args.org_id {
1032
+ Some(org_id) if !org_id.trim().is_empty() => format!("/core/projects?orgId={}", org_id.trim()),
1033
+ _ => "/core/projects".to_string(),
1034
+ };
1035
+
1036
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
1037
+ if !response.status().is_success() {
1038
+ let body = read_error_body(response).await;
1039
+ return Err(anyhow!("project list failed: {}", body));
1040
+ }
1041
+ let payload: ListProjectsResponse = response.json().await?;
1042
+ println!("{}", serde_json::to_string_pretty(&payload.projects)?);
1043
+ save_session(&session)?;
1044
+ Ok(())
1045
+ }
1046
+
1047
+ async fn project_create_command(client: &reqwest::Client, args: ProjectCreateArgs) -> Result<()> {
1048
+ let mut session = load_session()?;
1049
+ apply_base_url_override(&mut session, args.base_url);
1050
+
1051
+ let response = authed_request(
1052
+ client,
1053
+ &mut session,
1054
+ Method::POST,
1055
+ "/core/projects",
1056
+ Some(serde_json::json!({
1057
+ "orgId": args.org_id,
1058
+ "name": args.name,
1059
+ "description": args.description
1060
+ })),
1061
+ )
1062
+ .await?;
1063
+ if !response.status().is_success() {
1064
+ let body = read_error_body(response).await;
1065
+ return Err(anyhow!("project create failed: {}", body));
1066
+ }
1067
+ let payload: ProjectResponse = response.json().await?;
1068
+ println!("{}", serde_json::to_string_pretty(&payload.project)?);
1069
+ save_session(&session)?;
1070
+ Ok(())
1071
+ }
1072
+
1073
+ async fn project_get_command(client: &reqwest::Client, args: ProjectGetArgs) -> Result<()> {
1074
+ let mut session = load_session()?;
1075
+ apply_base_url_override(&mut session, args.base_url);
1076
+
1077
+ let path = format!("/core/projects/{}", args.project_id);
1078
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
1079
+ if !response.status().is_success() {
1080
+ let body = read_error_body(response).await;
1081
+ return Err(anyhow!("project get failed: {}", body));
1082
+ }
1083
+ let payload: ProjectResponse = response.json().await?;
1084
+ println!("{}", serde_json::to_string_pretty(&payload.project)?);
1085
+ save_session(&session)?;
1086
+ Ok(())
1087
+ }
1088
+
1089
+ async fn project_update_command(client: &reqwest::Client, args: ProjectUpdateArgs) -> Result<()> {
1090
+ let mut session = load_session()?;
1091
+ apply_base_url_override(&mut session, args.base_url);
1092
+
1093
+ let mut body = serde_json::Map::new();
1094
+ if let Some(name) = args.name {
1095
+ body.insert("name".to_string(), serde_json::Value::String(name));
1096
+ }
1097
+ if args.clear_description {
1098
+ body.insert("description".to_string(), serde_json::Value::Null);
1099
+ } else if let Some(description) = args.description {
1100
+ body.insert(
1101
+ "description".to_string(),
1102
+ serde_json::Value::String(description),
1103
+ );
1104
+ }
1105
+
1106
+ if body.is_empty() {
1107
+ return Err(anyhow!(
1108
+ "project update requires at least one field (--name, --description, or --clear-description)"
1109
+ ));
1110
+ }
1111
+
1112
+ let path = format!("/core/projects/{}", args.project_id);
1113
+ let response = authed_request(
1114
+ client,
1115
+ &mut session,
1116
+ Method::PATCH,
1117
+ &path,
1118
+ Some(serde_json::Value::Object(body)),
1119
+ )
1120
+ .await?;
1121
+ if !response.status().is_success() {
1122
+ let body = read_error_body(response).await;
1123
+ return Err(anyhow!("project update failed: {}", body));
1124
+ }
1125
+ let payload: ProjectResponse = response.json().await?;
1126
+ println!("{}", serde_json::to_string_pretty(&payload.project)?);
1127
+ save_session(&session)?;
1128
+ Ok(())
1129
+ }
1130
+
1131
+ async fn project_delete_command(client: &reqwest::Client, args: ProjectDeleteArgs) -> Result<()> {
1132
+ let mut session = load_session()?;
1133
+ apply_base_url_override(&mut session, args.base_url);
1134
+
1135
+ let path = format!("/core/projects/{}", args.project_id);
1136
+ let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
1137
+ if !response.status().is_success() {
1138
+ let body = read_error_body(response).await;
1139
+ return Err(anyhow!("project delete failed: {}", body));
1140
+ }
1141
+ let payload: serde_json::Value = response.json().await?;
1142
+ println!("{}", serde_json::to_string_pretty(&payload)?);
1143
+ save_session(&session)?;
1144
+ Ok(())
1145
+ }
1146
+
1147
+ async fn upload_asset_via_intent(
1148
+ client: &reqwest::Client,
1149
+ session: &mut SessionConfig,
1150
+ project_id: &str,
1151
+ remote_file_name: &str,
1152
+ bytes: Vec<u8>,
1153
+ content_type: &str,
1154
+ asset_type: &str,
1155
+ visibility: &str,
1156
+ ) -> Result<AssetRecord> {
1157
+ let size_bytes = bytes.len() as i64;
1158
+ let intent_body = serde_json::to_value(UploadIntentRequest {
1159
+ project_id: project_id.to_string(),
1160
+ file_name: remote_file_name.to_string(),
1161
+ content_type: content_type.to_string(),
1162
+ size_bytes,
1163
+ asset_type: asset_type.to_string(),
1164
+ visibility: visibility.to_string(),
1165
+ })?;
1166
+
1167
+ let intent_response = authed_request(
1168
+ client,
1169
+ session,
1170
+ Method::POST,
1171
+ "/assets/upload-intent",
1172
+ Some(intent_body),
1173
+ )
1174
+ .await?;
1175
+ if !intent_response.status().is_success() {
1176
+ let body = read_error_body(intent_response).await;
1177
+ return Err(anyhow!("upload-intent failed: {}", body));
1178
+ }
1179
+ let intent: UploadIntentResponse = intent_response.json().await?;
1180
+ let strategy = intent.strategy.trim().to_ascii_lowercase();
1181
+ let complete_payload = if strategy == "multipart" {
1182
+ let part_size = intent
1183
+ .part_size_bytes
1184
+ .and_then(|value| if value > 0 { Some(value as usize) } else { None })
1185
+ .ok_or_else(|| anyhow!("multipart upload intent is missing partSizeBytes"))?;
1186
+ let part_count = intent
1187
+ .part_count
1188
+ .and_then(|value| if value > 0 { Some(value as usize) } else { None })
1189
+ .ok_or_else(|| anyhow!("multipart upload intent is missing partCount"))?;
1190
+ let session_id = intent
1191
+ .session_id
1192
+ .clone()
1193
+ .ok_or_else(|| anyhow!("multipart upload intent is missing sessionId"))?;
1194
+ let mut parts = intent.parts.clone().unwrap_or_default();
1195
+ if parts.is_empty() {
1196
+ return Err(anyhow!("multipart upload intent is missing parts"));
1197
+ }
1198
+ parts.sort_by_key(|part| part.part_number);
1199
+ if parts.len() != part_count {
1200
+ return Err(anyhow!(
1201
+ "multipart upload intent has mismatched part count (expected {}, got {})",
1202
+ part_count,
1203
+ parts.len()
1204
+ ));
1205
+ }
1206
+
1207
+ let mut completed_parts = Vec::with_capacity(parts.len());
1208
+ for part in parts {
1209
+ let part_number = part.part_number;
1210
+ if part_number <= 0 {
1211
+ return Err(anyhow!("multipart part has invalid part number"));
1212
+ }
1213
+ let offset = (part_number as usize - 1) * part_size;
1214
+ let end = std::cmp::min(offset + part_size, bytes.len());
1215
+ if offset >= end {
1216
+ return Err(anyhow!("multipart part range is invalid"));
1217
+ }
1218
+ let chunk = bytes[offset..end].to_vec();
1219
+
1220
+ let upload_method = Method::from_bytes(part.method.as_bytes()).unwrap_or(Method::PUT);
1221
+ let etag = format!("etag_{}_{}", now_epoch_ms(), part_number);
1222
+ let mut upload_request = client.request(upload_method, &part.upload_url);
1223
+ for (key, value) in part.required_headers.iter() {
1224
+ upload_request = upload_request.header(key, value);
1225
+ }
1226
+ upload_request = upload_request.header("x-mock-etag", &etag).body(chunk);
1227
+ let upload_response = upload_request.send().await?;
1228
+ if !upload_response.status().is_success() {
1229
+ let body = read_error_body(upload_response).await;
1230
+ return Err(anyhow!(
1231
+ "multipart upload failed on part {}: {}",
1232
+ part_number,
1233
+ body
1234
+ ));
1235
+ }
1236
+
1237
+ completed_parts.push(UploadCompletedPart {
1238
+ part_number,
1239
+ etag,
1240
+ size_bytes: (end - offset) as i64,
1241
+ });
1242
+ }
1243
+
1244
+ serde_json::to_value(UploadCompleteRequest {
1245
+ asset_id: intent.asset_id,
1246
+ session_id: Some(session_id),
1247
+ etag: None,
1248
+ size_bytes: Some(size_bytes),
1249
+ completed_parts: Some(completed_parts),
1250
+ })?
1251
+ } else {
1252
+ let upload_url = intent
1253
+ .upload_url
1254
+ .clone()
1255
+ .ok_or_else(|| anyhow!("single upload intent is missing uploadUrl"))?;
1256
+ let method = intent.method.clone().unwrap_or_else(|| "PUT".to_string());
1257
+ let required_headers = intent.required_headers.clone().unwrap_or_default();
1258
+
1259
+ let upload_method = Method::from_bytes(method.as_bytes()).unwrap_or(Method::PUT);
1260
+ let etag = format!("etag_{}", now_epoch_ms());
1261
+ let mut upload_request = client.request(upload_method, &upload_url);
1262
+ for (key, value) in required_headers.iter() {
1263
+ upload_request = upload_request.header(key, value);
1264
+ }
1265
+ upload_request = upload_request.header("x-mock-etag", &etag).body(bytes);
1266
+ let upload_response = upload_request.send().await?;
1267
+ if !upload_response.status().is_success() {
1268
+ let body = read_error_body(upload_response).await;
1269
+ return Err(anyhow!("upload PUT failed: {}", body));
1270
+ }
1271
+
1272
+ serde_json::to_value(UploadCompleteRequest {
1273
+ asset_id: intent.asset_id,
1274
+ session_id: intent.session_id.clone(),
1275
+ etag: Some(etag),
1276
+ size_bytes: Some(size_bytes),
1277
+ completed_parts: None,
1278
+ })?
1279
+ };
1280
+
1281
+ let complete_response = authed_request(
1282
+ client,
1283
+ session,
1284
+ Method::POST,
1285
+ "/assets/upload-complete",
1286
+ Some(complete_payload),
1287
+ )
1288
+ .await?;
1289
+ if !complete_response.status().is_success() {
1290
+ let body = read_error_body(complete_response).await;
1291
+ return Err(anyhow!("upload-complete failed: {}", body));
1292
+ }
1293
+ let payload: AssetResponse = complete_response.json().await?;
1294
+ Ok(payload.asset)
1295
+ }
1296
+
1297
+ async fn file_list_command(client: &reqwest::Client, args: FileListArgs) -> Result<()> {
1298
+ let mut session = load_session()?;
1299
+ apply_base_url_override(&mut session, args.base_url);
1300
+
1301
+ let path = format!("/assets?projectId={}", args.project_id);
1302
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
1303
+ if !response.status().is_success() {
1304
+ let body = read_error_body(response).await;
1305
+ return Err(anyhow!("file list failed: {}", body));
1306
+ }
1307
+ let mut payload: ListAssetsResponse = response.json().await?;
1308
+ if let Some(prefix) = args.path {
1309
+ let cleaned = clean_virtual_path(&prefix);
1310
+ if !cleaned.is_empty() {
1311
+ let strict = format!("{}/", cleaned);
1312
+ payload.assets = payload
1313
+ .assets
1314
+ .into_iter()
1315
+ .filter(|asset| asset.file_name == cleaned || asset.file_name.starts_with(&strict))
1316
+ .collect();
1317
+ }
1318
+ }
1319
+ println!("{}", serde_json::to_string_pretty(&payload.assets)?);
1320
+ save_session(&session)?;
1321
+ Ok(())
1322
+ }
1323
+
1324
+ async fn file_get_command(client: &reqwest::Client, args: FileGetArgs) -> Result<()> {
1325
+ let mut session = load_session()?;
1326
+ apply_base_url_override(&mut session, args.base_url);
1327
+
1328
+ let path = format!("/assets/{}", args.asset_id);
1329
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
1330
+ if !response.status().is_success() {
1331
+ let body = read_error_body(response).await;
1332
+ return Err(anyhow!("file get failed: {}", body));
1333
+ }
1334
+ let payload: AssetResponse = response.json().await?;
1335
+ println!("{}", serde_json::to_string_pretty(&payload.asset)?);
1336
+ save_session(&session)?;
1337
+ Ok(())
1338
+ }
1339
+
1340
+ async fn file_upload_command(client: &reqwest::Client, args: FileUploadArgs) -> Result<()> {
1341
+ let mut session = load_session()?;
1342
+ apply_base_url_override(&mut session, args.base_url);
1343
+
1344
+ let bytes = fs::read(&args.source)
1345
+ .with_context(|| format!("Failed to read source file {}", args.source.display()))?;
1346
+ let source_name = args
1347
+ .source
1348
+ .file_name()
1349
+ .and_then(|name| name.to_str())
1350
+ .ok_or_else(|| anyhow!("source file name is invalid"))?;
1351
+ let remote_name = join_remote_path(args.path.as_deref(), source_name);
1352
+ if remote_name.is_empty() {
1353
+ return Err(anyhow!("resolved remote file name is empty"));
1354
+ }
1355
+
1356
+ let asset = upload_asset_via_intent(
1357
+ client,
1358
+ &mut session,
1359
+ &args.project_id,
1360
+ &remote_name,
1361
+ bytes,
1362
+ "application/octet-stream",
1363
+ &args.asset_type,
1364
+ &args.visibility,
1365
+ )
1366
+ .await?;
1367
+ println!("{}", serde_json::to_string_pretty(&asset)?);
1368
+ save_session(&session)?;
1369
+ Ok(())
1370
+ }
1371
+
1372
+ async fn file_mkdir_command(client: &reqwest::Client, args: FileMkdirArgs) -> Result<()> {
1373
+ let mut session = load_session()?;
1374
+ apply_base_url_override(&mut session, args.base_url);
1375
+
1376
+ let folder = clean_virtual_path(&args.path);
1377
+ if folder.is_empty() {
1378
+ return Err(anyhow!("folder path is empty"));
1379
+ }
1380
+ let marker_file = join_remote_path(Some(&folder), ".reallink.keep");
1381
+ let marker_bytes = format!("folder marker {}\n", now_epoch_ms()).into_bytes();
1382
+ let asset = upload_asset_via_intent(
1383
+ client,
1384
+ &mut session,
1385
+ &args.project_id,
1386
+ &marker_file,
1387
+ marker_bytes,
1388
+ "text/plain",
1389
+ "other",
1390
+ "private",
1391
+ )
1392
+ .await?;
1393
+ println!("{}", serde_json::to_string_pretty(&asset)?);
1394
+ save_session(&session)?;
1395
+ Ok(())
1396
+ }
1397
+
1398
+ async fn file_move_command(client: &reqwest::Client, args: FileMoveArgs) -> Result<()> {
1399
+ let mut session = load_session()?;
1400
+ apply_base_url_override(&mut session, args.base_url);
1401
+
1402
+ let file_name = clean_virtual_path(&args.file_name);
1403
+ if file_name.is_empty() {
1404
+ return Err(anyhow!("file_name is empty"));
1405
+ }
1406
+ let path = format!("/assets/{}", args.asset_id);
1407
+ let response = authed_request(
1408
+ client,
1409
+ &mut session,
1410
+ Method::PATCH,
1411
+ &path,
1412
+ Some(serde_json::json!({
1413
+ "fileName": file_name
1414
+ })),
1415
+ )
1416
+ .await?;
1417
+ if !response.status().is_success() {
1418
+ let body = read_error_body(response).await;
1419
+ return Err(anyhow!("file move failed: {}", body));
1420
+ }
1421
+ let payload: AssetResponse = response.json().await?;
1422
+ println!("{}", serde_json::to_string_pretty(&payload.asset)?);
1423
+ save_session(&session)?;
1424
+ Ok(())
1425
+ }
1426
+
1427
+ async fn file_remove_command(client: &reqwest::Client, args: FileRemoveArgs) -> Result<()> {
1428
+ let mut session = load_session()?;
1429
+ apply_base_url_override(&mut session, args.base_url);
1430
+
1431
+ let path = format!("/assets/{}", args.asset_id);
1432
+ let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
1433
+ if !response.status().is_success() {
1434
+ let body = read_error_body(response).await;
1435
+ return Err(anyhow!("file remove failed: {}", body));
1436
+ }
1437
+ let payload: serde_json::Value = response.json().await?;
1438
+ println!("{}", serde_json::to_string_pretty(&payload)?);
1439
+ save_session(&session)?;
1440
+ Ok(())
1441
+ }
1442
+
1443
+ async fn tool_list_command(client: &reqwest::Client, args: ToolListArgs) -> Result<()> {
1444
+ let mut session = load_session()?;
1445
+ apply_base_url_override(&mut session, args.base_url);
1446
+
1447
+ let path = format!(
1448
+ "/tools/definitions?includeInactive={}&includeDisabledChannel={}",
1449
+ args.include_inactive, args.include_disabled_channel
1450
+ );
1451
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
1452
+ if !response.status().is_success() {
1453
+ let body = read_error_body(response).await;
1454
+ return Err(anyhow!("tool list failed: {}", body));
1455
+ }
1456
+ let payload: serde_json::Value = response.json().await?;
1457
+ println!(
1458
+ "{}",
1459
+ serde_json::to_string_pretty(payload.get("definitions").unwrap_or(&payload))?
1460
+ );
1461
+ save_session(&session)?;
1462
+ Ok(())
1463
+ }
1464
+
1465
+ async fn tool_register_command(client: &reqwest::Client, args: ToolRegisterArgs) -> Result<()> {
1466
+ let mut session = load_session()?;
1467
+ apply_base_url_override(&mut session, args.base_url);
1468
+
1469
+ let manifest = load_jsonc_file(&args.manifest, "tool manifest")?;
1470
+ let body = serde_json::Value::Object(parse_object_from_value(
1471
+ manifest,
1472
+ "tool manifest payload",
1473
+ )?);
1474
+ let response =
1475
+ authed_request(client, &mut session, Method::POST, "/tools/definitions", Some(body)).await?;
1476
+ if !response.status().is_success() {
1477
+ let body = read_error_body(response).await;
1478
+ return Err(anyhow!("tool register failed: {}", body));
1479
+ }
1480
+ let payload: serde_json::Value = response.json().await?;
1481
+ println!(
1482
+ "{}",
1483
+ serde_json::to_string_pretty(payload.get("definition").unwrap_or(&payload))?
1484
+ );
1485
+ save_session(&session)?;
1486
+ Ok(())
1487
+ }
1488
+
1489
+ async fn tool_set_entitlement_command(
1490
+ client: &reqwest::Client,
1491
+ mut session: SessionConfig,
1492
+ tool_id: String,
1493
+ org_id: Option<String>,
1494
+ project_id: Option<String>,
1495
+ user_id: Option<String>,
1496
+ status: &str,
1497
+ expires_at: Option<String>,
1498
+ metadata_file: Option<PathBuf>,
1499
+ ) -> Result<()> {
1500
+ let scoped_count =
1501
+ (org_id.is_some() as u8) + (project_id.is_some() as u8) + (user_id.is_some() as u8);
1502
+ if scoped_count > 1 {
1503
+ return Err(anyhow!(
1504
+ "Only one of --org-id, --project-id, or --user-id can be set"
1505
+ ));
1506
+ }
1507
+
1508
+ let mut body = serde_json::Map::new();
1509
+ body.insert("toolId".to_string(), serde_json::Value::String(tool_id));
1510
+ body.insert(
1511
+ "status".to_string(),
1512
+ serde_json::Value::String(status.to_string()),
1513
+ );
1514
+ if let Some(org_id) = org_id {
1515
+ body.insert("orgId".to_string(), serde_json::Value::String(org_id));
1516
+ }
1517
+ if let Some(project_id) = project_id {
1518
+ body.insert("projectId".to_string(), serde_json::Value::String(project_id));
1519
+ }
1520
+ if let Some(user_id) = user_id {
1521
+ body.insert("userId".to_string(), serde_json::Value::String(user_id));
1522
+ }
1523
+ if let Some(expires_at) = expires_at {
1524
+ body.insert("expiresAt".to_string(), serde_json::Value::String(expires_at));
1525
+ }
1526
+ if let Some(path) = metadata_file {
1527
+ let metadata = load_jsonc_file(&path, "tool entitlement metadata")?;
1528
+ body.insert(
1529
+ "metadata".to_string(),
1530
+ serde_json::Value::Object(parse_object_from_value(
1531
+ metadata,
1532
+ "tool entitlement metadata",
1533
+ )?),
1534
+ );
1535
+ }
1536
+
1537
+ let response = authed_request(
1538
+ client,
1539
+ &mut session,
1540
+ Method::PUT,
1541
+ "/tools/entitlements",
1542
+ Some(serde_json::Value::Object(body)),
1543
+ )
1544
+ .await?;
1545
+ if !response.status().is_success() {
1546
+ let body = read_error_body(response).await;
1547
+ return Err(anyhow!("tool entitlement update failed: {}", body));
1548
+ }
1549
+ let payload: serde_json::Value = response.json().await?;
1550
+ println!(
1551
+ "{}",
1552
+ serde_json::to_string_pretty(payload.get("entitlement").unwrap_or(&payload))?
1553
+ );
1554
+ save_session(&session)?;
1555
+ Ok(())
1556
+ }
1557
+
1558
+ async fn tool_enable_command(client: &reqwest::Client, args: ToolEnableArgs) -> Result<()> {
1559
+ let mut session = load_session()?;
1560
+ apply_base_url_override(&mut session, args.base_url);
1561
+
1562
+ tool_set_entitlement_command(
1563
+ client,
1564
+ session,
1565
+ args.tool_id,
1566
+ args.org_id,
1567
+ args.project_id,
1568
+ args.user_id,
1569
+ "enabled",
1570
+ args.expires_at,
1571
+ args.metadata_file,
1572
+ )
1573
+ .await
1574
+ }
1575
+
1576
+ async fn tool_disable_command(client: &reqwest::Client, args: ToolDisableArgs) -> Result<()> {
1577
+ let mut session = load_session()?;
1578
+ apply_base_url_override(&mut session, args.base_url);
1579
+
1580
+ tool_set_entitlement_command(
1581
+ client,
1582
+ session,
1583
+ args.tool_id,
1584
+ args.org_id,
1585
+ args.project_id,
1586
+ args.user_id,
1587
+ "disabled",
1588
+ None,
1589
+ args.metadata_file,
1590
+ )
1591
+ .await
1592
+ }
1593
+
1594
+ async fn tool_run_command(client: &reqwest::Client, args: ToolRunArgs) -> Result<()> {
1595
+ let mut session = load_session()?;
1596
+ apply_base_url_override(&mut session, args.base_url);
1597
+
1598
+ if args.input_json.is_some() && args.input_file.is_some() {
1599
+ return Err(anyhow!(
1600
+ "Provide either --input-json or --input-file, not both"
1601
+ ));
1602
+ }
1603
+
1604
+ let input_value = if let Some(path) = args.input_file {
1605
+ load_jsonc_file(&path, "tool run input")?
1606
+ } else if let Some(input_json) = args.input_json {
1607
+ parse_jsonc_str(&input_json, "tool run input")?
1608
+ } else {
1609
+ serde_json::Value::Object(serde_json::Map::new())
1610
+ };
1611
+
1612
+ let input_object = serde_json::Value::Object(parse_object_from_value(
1613
+ input_value,
1614
+ "tool run input",
1615
+ )?);
1616
+
1617
+ let mut body = serde_json::Map::new();
1618
+ body.insert("toolId".to_string(), serde_json::Value::String(args.tool_id));
1619
+ body.insert("input".to_string(), input_object);
1620
+ if let Some(org_id) = args.org_id {
1621
+ body.insert("orgId".to_string(), serde_json::Value::String(org_id));
1622
+ }
1623
+ if let Some(project_id) = args.project_id {
1624
+ body.insert("projectId".to_string(), serde_json::Value::String(project_id));
1625
+ }
1626
+ let mut metadata_map = if let Some(path) = args.metadata_file {
1627
+ let metadata = load_jsonc_file(&path, "tool run metadata")?;
1628
+ parse_object_from_value(metadata, "tool run metadata")?
1629
+ } else {
1630
+ serde_json::Map::new()
1631
+ };
1632
+ if let Some(idempotency_key) = args.idempotency_key {
1633
+ let normalized = idempotency_key.trim();
1634
+ if !normalized.is_empty() {
1635
+ metadata_map.insert(
1636
+ "idempotencyKey".to_string(),
1637
+ serde_json::Value::String(normalized.to_string()),
1638
+ );
1639
+ }
1640
+ }
1641
+ if !metadata_map.is_empty() {
1642
+ body.insert("metadata".to_string(), serde_json::Value::Object(metadata_map));
1643
+ }
1644
+
1645
+ let response = authed_request(
1646
+ client,
1647
+ &mut session,
1648
+ Method::POST,
1649
+ "/tools/runs",
1650
+ Some(serde_json::Value::Object(body)),
1651
+ )
1652
+ .await?;
1653
+ if !response.status().is_success() {
1654
+ let body = read_error_body(response).await;
1655
+ return Err(anyhow!("tool run failed: {}", body));
1656
+ }
1657
+ let payload: serde_json::Value = response.json().await?;
1658
+ println!(
1659
+ "{}",
1660
+ serde_json::to_string_pretty(payload.get("run").unwrap_or(&payload))?
1661
+ );
1662
+ save_session(&session)?;
1663
+ Ok(())
1664
+ }
1665
+
1666
+ async fn tool_runs_command(client: &reqwest::Client, args: ToolRunsArgs) -> Result<()> {
1667
+ let mut session = load_session()?;
1668
+ apply_base_url_override(&mut session, args.base_url);
1669
+
1670
+ let mut query_parts: Vec<String> = Vec::new();
1671
+ if let Some(tool_id) = args.tool_id {
1672
+ query_parts.push(format!("toolId={}", tool_id));
1673
+ }
1674
+ if let Some(project_id) = args.project_id {
1675
+ query_parts.push(format!("projectId={}", project_id));
1676
+ }
1677
+ if let Some(requested_by_user_id) = args.requested_by_user_id {
1678
+ query_parts.push(format!("requestedByUserId={}", requested_by_user_id));
1679
+ }
1680
+ if let Some(status) = args.status {
1681
+ query_parts.push(format!("status={}", status));
1682
+ }
1683
+
1684
+ let path = if query_parts.is_empty() {
1685
+ "/tools/runs".to_string()
1686
+ } else {
1687
+ format!("/tools/runs?{}", query_parts.join("&"))
1688
+ };
1689
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
1690
+ if !response.status().is_success() {
1691
+ let body = read_error_body(response).await;
1692
+ return Err(anyhow!("tool runs failed: {}", body));
1693
+ }
1694
+ let payload: serde_json::Value = response.json().await?;
1695
+ println!(
1696
+ "{}",
1697
+ serde_json::to_string_pretty(payload.get("runs").unwrap_or(&payload))?
1698
+ );
1699
+ save_session(&session)?;
1700
+ Ok(())
1701
+ }
1702
+
1703
+ async fn tool_get_run_command(client: &reqwest::Client, args: ToolGetRunArgs) -> Result<()> {
1704
+ let mut session = load_session()?;
1705
+ apply_base_url_override(&mut session, args.base_url);
1706
+
1707
+ let path = format!("/tools/runs/{}", args.run_id);
1708
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
1709
+ if !response.status().is_success() {
1710
+ let body = read_error_body(response).await;
1711
+ return Err(anyhow!("tool get-run failed: {}", body));
1712
+ }
1713
+ let payload: serde_json::Value = response.json().await?;
1714
+ println!(
1715
+ "{}",
1716
+ serde_json::to_string_pretty(payload.get("run").unwrap_or(&payload))?
1717
+ );
1718
+ save_session(&session)?;
1719
+ Ok(())
1720
+ }
1721
+
474
1722
  #[tokio::main]
475
1723
  async fn main() -> Result<()> {
476
1724
  let cli = Cli::parse();
477
1725
  let client = reqwest::Client::builder()
478
- .user_agent("reallink-cli/0.1.1")
1726
+ .user_agent(concat!("reallink-cli/", env!("CARGO_PKG_VERSION")))
479
1727
  .build()?;
480
1728
 
481
1729
  match cli.command {
482
1730
  Commands::Login(args) => login_command(&client, args).await?,
483
1731
  Commands::Whoami(args) => whoami_command(&client, args).await?,
484
- Commands::Logout => {
485
- clear_session()?;
486
- println!("Logged out.");
487
- }
1732
+ Commands::Logout => logout_command(&client).await?,
1733
+ Commands::Project { command } => match command {
1734
+ ProjectCommands::List(args) => project_list_command(&client, args).await?,
1735
+ ProjectCommands::Create(args) => project_create_command(&client, args).await?,
1736
+ ProjectCommands::Get(args) => project_get_command(&client, args).await?,
1737
+ ProjectCommands::Update(args) => project_update_command(&client, args).await?,
1738
+ ProjectCommands::Delete(args) => project_delete_command(&client, args).await?,
1739
+ },
488
1740
  Commands::Token { command } => match command {
489
1741
  TokenCommands::List(args) => token_list_command(&client, args).await?,
490
1742
  TokenCommands::Create(args) => token_create_command(&client, args).await?,
491
1743
  TokenCommands::Revoke(args) => token_revoke_command(&client, args).await?,
492
1744
  },
1745
+ Commands::File { command } => match command {
1746
+ FileCommands::List(args) => file_list_command(&client, args).await?,
1747
+ FileCommands::Get(args) => file_get_command(&client, args).await?,
1748
+ FileCommands::Upload(args) => file_upload_command(&client, args).await?,
1749
+ FileCommands::Mkdir(args) => file_mkdir_command(&client, args).await?,
1750
+ FileCommands::Move(args) => file_move_command(&client, args).await?,
1751
+ FileCommands::Remove(args) => file_remove_command(&client, args).await?,
1752
+ },
1753
+ Commands::Tool { command } => match command {
1754
+ ToolCommands::List(args) => tool_list_command(&client, args).await?,
1755
+ ToolCommands::Register(args) => tool_register_command(&client, args).await?,
1756
+ ToolCommands::Enable(args) => tool_enable_command(&client, args).await?,
1757
+ ToolCommands::Disable(args) => tool_disable_command(&client, args).await?,
1758
+ ToolCommands::Run(args) => tool_run_command(&client, args).await?,
1759
+ ToolCommands::Runs(args) => tool_runs_command(&client, args).await?,
1760
+ ToolCommands::GetRun(args) => tool_get_run_command(&client, args).await?,
1761
+ },
493
1762
  }
494
1763
 
495
1764
  Ok(())